Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow multiple redemptions per epoch #1009

Merged
merged 10 commits into from
Jan 8, 2024
1 change: 0 additions & 1 deletion x/records/types/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import errorsmod "cosmossdk.io/errors"
// x/records module sentinel errors
var (
ErrInvalidVersion = errorsmod.Register(ModuleName, 1501, "invalid version")
ErrRedemptionAlreadyExists = errorsmod.Register(ModuleName, 1502, "redemption record already exists")
ErrEpochUnbondingRecordNotFound = errorsmod.Register(ModuleName, 1503, "epoch unbonding record not found")
ErrUnknownDepositRecord = errorsmod.Register(ModuleName, 1504, "unknown deposit record")
ErrUnmarshalFailure = errorsmod.Register(ModuleName, 1505, "cannot unmarshal")
Expand Down
53 changes: 34 additions & 19 deletions x/stakeibc/keeper/msg_server_redeem_stake.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func (k msgServer) RedeemStake(goCtx context.Context, msg *types.MsgRedeemStake)
ctx := sdk.UnwrapSDKContext(goCtx)
k.Logger(ctx).Info(fmt.Sprintf("redeem stake: %s", msg.String()))

// ----------------- PRELIMINARY CHECKS -----------------
// get our addresses, make sure they're valid
sender, err := sdk.AccAddressFromBech32(msg.Creator)
if err != nil {
Expand All @@ -39,12 +40,6 @@ func (k msgServer) RedeemStake(goCtx context.Context, msg *types.MsgRedeemStake)
if !found {
return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "epoch tracker found: %s", "day")
}
senderAddr := sender.String()
redemptionId := recordstypes.UserRedemptionRecordKeyFormatter(hostZone.ChainId, epochTracker.EpochNumber, senderAddr)
_, found = k.RecordsKeeper.GetUserRedemptionRecord(ctx, redemptionId)
if found {
return nil, errorsmod.Wrapf(recordstypes.ErrRedemptionAlreadyExists, "user already redeemed this epoch: %s", redemptionId)
}

// ensure the recipient address is a valid bech32 address on the hostZone
_, err = utils.AccAddressFromBech32(msg.Receiver, hostZone.Bech32Prefix)
Expand All @@ -54,6 +49,7 @@ func (k msgServer) RedeemStake(goCtx context.Context, msg *types.MsgRedeemStake)

// construct desired unstaking amount from host zone
stDenom := types.StAssetDenomFromHostZoneDenom(hostZone.HostDenom)
// ASSUMPTION (CHECK ME): it doesn't matter that RRs can change _within_ an epoch (due to LSM)
sampocs marked this conversation as resolved.
Show resolved Hide resolved
nativeAmount := sdk.NewDecFromInt(msg.Amount).Mul(hostZone.RedemptionRate).RoundInt()

if nativeAmount.GT(hostZone.TotalDelegations) {
Expand Down Expand Up @@ -84,20 +80,36 @@ func (k msgServer) RedeemStake(goCtx context.Context, msg *types.MsgRedeemStake)
if balance.Amount.LT(msg.Amount) {
return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidCoins, "balance is lower than redemption amount. redemption amount: %v, balance %v: ", msg.Amount, balance.Amount)
}
// UNBONDING RECORD KEEPING
userRedemptionRecord := recordstypes.UserRedemptionRecord{
Id: redemptionId,
Sender: senderAddr,
Receiver: msg.Receiver,
Amount: nativeAmount,
Denom: hostZone.HostDenom,
HostZoneId: hostZone.ChainId,
EpochNumber: epochTracker.EpochNumber,
// claimIsPending represents whether a redemption is currently being claimed,
// contingent on the host zone unbonding having status CLAIMABLE
ClaimIsPending: false,

// ----------------- UNBONDING RECORD KEEPING -----------------
// Fetch the record
senderAddr := sender.String()
redemptionId := recordstypes.UserRedemptionRecordKeyFormatter(hostZone.ChainId, epochTracker.EpochNumber, senderAddr)
userRedemptionRecord, userHasRedeemedThisEpoch := k.RecordsKeeper.GetUserRedemptionRecord(ctx, redemptionId)
if userHasRedeemedThisEpoch {
k.Logger(ctx).Info(fmt.Sprintf("UserRedemptionRecord found for %s", redemptionId))
// Add the unbonded amount to the UserRedemptionRecord
// The record is set below
userRedemptionRecord.Amount = userRedemptionRecord.Amount.Add(nativeAmount)
} else {
// First time a user is redeeming this epoch
userRedemptionRecord = recordstypes.UserRedemptionRecord{
Id: redemptionId,
Sender: senderAddr,
Receiver: msg.Receiver,
Amount: nativeAmount,
Denom: hostZone.HostDenom,
HostZoneId: hostZone.ChainId,
EpochNumber: epochTracker.EpochNumber,
// claimIsPending represents whether a redemption is currently being claimed,
// contingent on the host zone unbonding having status CLAIMABLE
ClaimIsPending: false,
}
k.Logger(ctx).Info(fmt.Sprintf("UserRedemptionRecord not found - creating for %s", redemptionId))
}

// then add undelegation amount to epoch unbonding records
// ASSUMPTION: we don't need to update the EpochUnbondingRecord directly (only the HostZoneUnbonding)
sampocs marked this conversation as resolved.
Show resolved Hide resolved
epochUnbondingRecord, found := k.RecordsKeeper.GetEpochUnbondingRecord(ctx, epochTracker.EpochNumber)
if !found {
k.Logger(ctx).Error("latest epoch unbonding record not found")
Expand All @@ -109,7 +121,10 @@ func (k msgServer) RedeemStake(goCtx context.Context, msg *types.MsgRedeemStake)
return nil, errorsmod.Wrapf(types.ErrInvalidHostZone, "host zone not found in unbondings: %s", hostZone.ChainId)
}
hostZoneUnbonding.NativeTokenAmount = hostZoneUnbonding.NativeTokenAmount.Add(nativeAmount)
hostZoneUnbonding.UserRedemptionRecords = append(hostZoneUnbonding.UserRedemptionRecords, userRedemptionRecord.Id)
if !userHasRedeemedThisEpoch {
sampocs marked this conversation as resolved.
Show resolved Hide resolved
// Only append if a UserRedemptionRecord to the HZU if it wasn't previously appended
asalzmann marked this conversation as resolved.
Show resolved Hide resolved
hostZoneUnbonding.UserRedemptionRecords = append(hostZoneUnbonding.UserRedemptionRecords, userRedemptionRecord.Id)
sampocs marked this conversation as resolved.
Show resolved Hide resolved
}

// Escrow user's balance
redeemCoin := sdk.NewCoins(sdk.NewCoin(stDenom, msg.Amount))
Expand Down
34 changes: 15 additions & 19 deletions x/stakeibc/keeper/msg_server_redeem_stake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,19 @@ func (s *KeeperTestSuite) TestRedeemStake_Successful() {
user := tc.user
redeemAmount := msg.Amount

// get the initial unbonding amount *before* calling liquid stake, so we can use it to calc expected vs actual in diff space
_, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &msg)
s.Require().NoError(err)
// Split the message amount in 2, and call redeem stake twice (each with half the amount)
// This will check that the same user can redeem multiple times
sampocs marked this conversation as resolved.
Show resolved Hide resolved
msg1 := msg
msg1.Amount = msg1.Amount.Quo(sdkmath.NewInt(2)) // half the amount

msg2 := msg
msg2.Amount = msg.Amount.Sub(msg1.Amount) // remaining half

_, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &msg1)
s.Require().NoError(err, "no error expected during first redemption")

_, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &msg2)
s.Require().NoError(err, "no error expected during second redemption")

// User STUATOM balance should have DECREASED by the amount to be redeemed
expectedUserStAtomBalance := user.stAtomBalance.SubAmount(redeemAmount)
Expand Down Expand Up @@ -139,15 +149,11 @@ func (s *KeeperTestSuite) TestRedeemStake_Successful() {
userRedemptionRecordId := userRedemptionRecords[0]
userRedemptionRecord, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, userRedemptionRecordId)
s.Require().True(found)
// check amount
s.Require().Equal(expectedHostZoneUnbondingNativeAmount, userRedemptionRecord.Amount, "redemption record amount")
// check sender

s.Require().Equal(msg.Amount, userRedemptionRecord.Amount, "redemption record amount")
s.Require().Equal(msg.Creator, userRedemptionRecord.Sender, "redemption record sender")
// check receiver
s.Require().Equal(msg.Receiver, userRedemptionRecord.Receiver, "redemption record receiver")
// check host zone
s.Require().Equal(msg.HostZone, userRedemptionRecord.HostZoneId, "redemption record host zone")
// check claimIsPending
s.Require().False(userRedemptionRecord.ClaimIsPending, "redemption record is not claimable")
s.Require().NotEqual(hostZoneUnbonding.Status, recordtypes.HostZoneUnbonding_CLAIMABLE, "host zone unbonding should NOT be marked as CLAIMABLE")
}
Expand Down Expand Up @@ -244,16 +250,6 @@ func (s *KeeperTestSuite) TestRedeemStake_NoEpochTrackerDay() {
s.Require().EqualError(err, "latest epoch unbonding record not found: epoch unbonding record not found")
}

func (s *KeeperTestSuite) TestRedeemStake_UserAlreadyRedeemedThisEpoch() {
tc := s.SetupRedeemStake()

invalidMsg := tc.validMsg
_, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg)
s.Require().NoError(err)
_, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg)
s.Require().EqualError(err, fmt.Sprintf("user already redeemed this epoch: GAIA.1.%s: redemption record already exists", s.TestAccs[0]))
}

func (s *KeeperTestSuite) TestRedeemStake_HostZoneNoUnbondings() {
tc := s.SetupRedeemStake()

Expand Down
Loading