diff --git a/tests/integration/staking/keeper/msg_server_test.go b/tests/integration/staking/keeper/msg_server_test.go index 4c7336c2f52c..bd71617fd85a 100644 --- a/tests/integration/staking/keeper/msg_server_test.go +++ b/tests/integration/staking/keeper/msg_server_test.go @@ -19,6 +19,7 @@ import ( bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" banktestutil "github.com/cosmos/cosmos-sdk/x/bank/testutil" minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" + "github.com/cosmos/cosmos-sdk/x/staking" "github.com/cosmos/cosmos-sdk/x/staking/keeper" "github.com/cosmos/cosmos-sdk/x/staking/testutil" "github.com/cosmos/cosmos-sdk/x/staking/types" @@ -161,9 +162,7 @@ func TestCancelUnbondingDelegation(t *testing.T) { func TestTokenizeSharesAndRedeemTokens(t *testing.T) { _, app, ctx := createTestInput(t) - var ( - stakingKeeper = app.StakingKeeper - ) + stakingKeeper := app.StakingKeeper liquidStakingCapStrict := sdk.ZeroDec() liquidStakingCapConservative := sdk.MustNewDecFromStr("0.8") @@ -1712,3 +1711,340 @@ func createICAAccount(ctx sdk.Context, ak accountkeeper.AccountKeeper) sdk.AccAd return icaAddress } + +func TestSlashTokenizedSharesFromRedelegations(t *testing.T) { + _, app, ctx := createTestInput(t) + var ( + stakingKeeper = app.StakingKeeper + bankKeeper = app.BankKeeper + accountKeeper = app.AccountKeeper + ) + msgServer := keeper.NewMsgServerImpl(stakingKeeper) + validatorA := stakingKeeper.GetAllValidators(ctx)[0] + validatorAAddress := validatorA.GetOperator() + _, validatorBAddress := setupTestTokenizeAndRedeemConversion(t, *stakingKeeper, bankKeeper, ctx) + _ = validatorBAddress + addrs := simtestutil.AddTestAddrs(bankKeeper, stakingKeeper, ctx, 2, stakingKeeper.TokensFromConsensusPower(ctx, 10000)) + alice, _ := addrs[0], addrs[1] + + delegateAmount := sdk.TokensFromConsensusPower(10, sdk.DefaultPowerReduction) + delegateCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), delegateAmount) + + // Create ICA module account + icaAccountAddress := createICAAccount(ctx, accountKeeper) + + // Fund module account + delegationCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), delegateAmount) + err := bankKeeper.MintCoins(ctx, minttypes.ModuleName, sdk.NewCoins(delegationCoin)) + require.NoError(t, err) + err = bankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, icaAccountAddress, sdk.NewCoins(delegationCoin)) + require.NoError(t, err) + + // Alice delegates to validatorA + _, err = msgServer.Delegate(sdk.WrapSDKContext(ctx), &types.MsgDelegate{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorAAddress.String(), + Amount: delegateCoin, + }) + require.NoError(t, err, "no error expected when delegating") + + _, found := stakingKeeper.GetDelegation(ctx, alice, validatorAAddress) + require.True(t, found, "delegation should have been found") + + validatorA, found = stakingKeeper.GetValidator(ctx, validatorAAddress) + require.True(t, found, "validator should have been found") + + // save validatorA's voting power at the current block height + validatorAInfractionPower := sdk.TokensToConsensusPower(validatorA.Tokens, sdk.DefaultPowerReduction) + + // pass one block + bondedPool := stakingKeeper.GetBondedPool(ctx) + balances := bankKeeper.GetAllBalances(ctx, bondedPool.GetAddress()) + ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 1) + + // Alice delegates to validatorB + delegateAmountB := sdk.TokensFromConsensusPower(10, sdk.DefaultPowerReduction) + delegateCoinToB := sdk.NewCoin(stakingKeeper.BondDenom(ctx), delegateAmountB) + + _, err = msgServer.Delegate(sdk.WrapSDKContext(ctx), &types.MsgDelegate{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorBAddress.String(), + Amount: delegateCoinToB, + }) + require.NoError(t, err, "no error expected when delegating") + + redelegateAmount := sdk.TokensFromConsensusPower(5, sdk.DefaultPowerReduction) + redelegateCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), redelegateAmount) + + validatorB, found := stakingKeeper.GetValidator(ctx, validatorBAddress) + require.True(t, found, "validator should have been found") + + // Alice redelegates half of its shares from validatorA to validatorB + _, err = msgServer.BeginRedelegate(sdk.WrapSDKContext(ctx), &types.MsgBeginRedelegate{ + DelegatorAddress: alice.String(), + ValidatorSrcAddress: validatorAAddress.String(), + ValidatorDstAddress: validatorBAddress.String(), + Amount: redelegateCoin, + }) + require.NoError(t, err, "no error expected during redelegation") + + // Alice redelegates half of its shares from validatorA to validatorB again + _, err = msgServer.BeginRedelegate(sdk.WrapSDKContext(ctx), &types.MsgBeginRedelegate{ + DelegatorAddress: alice.String(), + ValidatorSrcAddress: validatorAAddress.String(), + ValidatorDstAddress: validatorBAddress.String(), + Amount: redelegateCoin, + }) + require.NoError(t, err, "no error expected during redelegation") + + totalredelegatedAmount := redelegateAmount.Add(redelegateAmount) + del, found := stakingKeeper.GetDelegation(ctx, alice, validatorBAddress) + require.True(t, found, "delegation should have been found") + require.Equal(t, del.Shares.TruncateInt(), totalredelegatedAmount.Add(delegateCoinToB.Amount), "incorrect amount of delegation shares") + + redelegation := stakingKeeper.GetRedelegationsFromSrcValidator(ctx, validatorAAddress) + require.Len(t, redelegation, 1, "expect one redelegation") + require.Len(t, redelegation[0].Entries, 2, "expect two redelegation entries") + + // Alice tokenizes 3/4 its shares in validatorB + tokenizedAmount := sdk.TokensFromConsensusPower(15, sdk.DefaultPowerReduction) + tokenizedCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), tokenizedAmount) + token, err := msgServer.TokenizeShares(sdk.WrapSDKContext(ctx), &types.MsgTokenizeShares{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorBAddress.String(), + Amount: tokenizedCoin, + TokenizedShareOwner: alice.String(), + }) + shareRecord, err := stakingKeeper.GetTokenizeShareRecord(ctx, stakingKeeper.GetLastTokenizeShareRecordID(ctx)) + require.NoError(t, err, "expect to found token share record") + + // check that Alice's remaining delegation to validatorB after tokenization + delAlice, found := stakingKeeper.GetDelegation(ctx, alice, validatorBAddress) + require.True(t, found, "delegation should have been found") + require.Equal(t, delAlice.Shares, del.Shares.Sub(tokenizedAmount.ToLegacyDec())) + + // check that a delegation from the share record address to validatorB is created + delShareRecord, found := stakingKeeper.GetDelegation(ctx, shareRecord.GetModuleAddress(), validatorBAddress) + require.True(t, found, "delegation should have been found") + require.Equal(t, delShareRecord.Shares, tokenizedAmount.ToLegacyDec()) + + // check that half of that Alice's redelegations are transferred to the share record address + tokenRed := stakingKeeper.GetRedelegations(ctx, shareRecord.GetModuleAddress(), uint16(10)) + require.Len(t, tokenRed, 1, "expect one redelegation entry") + require.Len(t, tokenRed[0].Entries, 1, "expect one redelegation entry") + require.Equal(t, tokenRed[0].Entries[0].SharesDst.TruncateInt(), redelegateAmount) + + // check that half of Alice's redelegations aren't altered + remRed := stakingKeeper.GetRedelegations(ctx, alice, uint16(10)) + require.Len(t, remRed, 1, "expect one redelegation entry") + require.Len(t, remRed[0].Entries, 1, "expect one redelegation entry") + require.Equal(t, remRed[0].Entries[0].SharesDst.TruncateInt(), redelegateAmount) + + // save bonded pool balance + bondedPool = stakingKeeper.GetBondedPool(ctx) + balances = bankKeeper.GetAllBalances(ctx, bondedPool.GetAddress()) + + // save validatorB tokens + validatorB, found = stakingKeeper.GetValidator(ctx, validatorBAddress) + require.True(t, found, "validator should have been found") + validatorBTokens := validatorB.Tokens + + validatorAconsAddr, err := validatorA.GetConsAddr() + require.NoError(t, err) + + // slash validatorA + slashFrac := sdk.NewDecWithPrec(75, 2) + stakingKeeper.Slash(ctx, validatorAconsAddr, 0, validatorAInfractionPower, slashFrac) + + // check that all redelegated shares in ValidatorB from validatorA are slashed + expRedsSlashedAmt := slashFrac.MulInt(totalredelegatedAmount).TruncateInt() + validatorBAfterSlash, found := stakingKeeper.GetValidator(ctx, validatorBAddress) + require.True(t, found, "validator should have been found") + require.Equal(t, validatorBTokens.Sub(expRedsSlashedAmt), validatorBAfterSlash.Tokens) + + // check that the shares of the share record in validatorB decreased + delShareRecordAfterSlash, found := stakingKeeper.GetDelegation(ctx, shareRecord.GetModuleAddress(), validatorBAddress) + require.True(t, found, "delegation should have been found") + require.Equal(t, delShareRecord.Shares.Sub(tokenRed[0].Entries[0].SharesDst.Mul(slashFrac)), delShareRecordAfterSlash.Shares) + + // verify that Alice's shares in validatorB decreased + delAliceAfterSlash, found := stakingKeeper.GetDelegation(ctx, alice, validatorBAddress) + require.True(t, found, "delegation should have been found") + require.Equal(t, delAlice.Shares.Sub(tokenRed[0].Entries[0].SharesDst.Mul(slashFrac)), delAliceAfterSlash.Shares) + + // check that the burned tokens are removed + // and from the bonded pool + burnedAmount := slashFrac.MulInt(sdk.TokensFromConsensusPower(validatorAInfractionPower, sdk.DefaultPowerReduction)) + require.Equal( + t, + balances.Sub(sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, burnedAmount.TruncateInt()))...), + bankKeeper.GetAllBalances(ctx, bondedPool.GetAddress()), + ) + + // Alice redeems all tokens + _, err = msgServer.RedeemTokensForShares(sdk.WrapSDKContext(ctx), + &types.MsgRedeemTokensForShares{ + DelegatorAddress: alice.String(), + Amount: token.Amount, + }, + ) + require.NoError(t, err, "no error expected during redemption") + + // Check that Alice gets a normalized amount of shares after the slashing + delAliceAfterRedemption, found := stakingKeeper.GetDelegation(ctx, alice, validatorBAddress) + require.True(t, found, "delegation should have been found") + require.Equal(t, delAliceAfterSlash.Shares.Add(delShareRecordAfterSlash.Shares), delAliceAfterRedemption.Shares) +} + +func TestRedelegationRemoval(t *testing.T) { + // Test that a redelegation that has been tokenized is still removed + // when the unbonding period has passed + _, app, ctx := createTestInput(t) + var ( + stakingKeeper = app.StakingKeeper + bankKeeper = app.BankKeeper + ) + msgServer := keeper.NewMsgServerImpl(stakingKeeper) + validatorA := stakingKeeper.GetAllValidators(ctx)[0] + validatorAAddress := validatorA.GetOperator() + _, validatorBAddress := setupTestTokenizeAndRedeemConversion(t, *stakingKeeper, bankKeeper, ctx) + + addrs := simtestutil.AddTestAddrs(bankKeeper, stakingKeeper, ctx, 2, stakingKeeper.TokensFromConsensusPower(ctx, 10000)) + alice, _ := addrs[0], addrs[1] + + delegateAmount := sdk.TokensFromConsensusPower(10, sdk.DefaultPowerReduction) + delegateCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), delegateAmount) + + // Alice delegates to validatorA + _, err := msgServer.Delegate(sdk.WrapSDKContext(ctx), &types.MsgDelegate{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorAAddress.String(), + Amount: delegateCoin, + }) + + // Alice redelegates to validatorB + redelegateAmount := sdk.TokensFromConsensusPower(5, sdk.DefaultPowerReduction) + redelegateCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), redelegateAmount) + _, err = msgServer.BeginRedelegate(sdk.WrapSDKContext(ctx), &types.MsgBeginRedelegate{ + DelegatorAddress: alice.String(), + ValidatorSrcAddress: validatorAAddress.String(), + ValidatorDstAddress: validatorBAddress.String(), + Amount: redelegateCoin, + }) + require.NoError(t, err) + + redelegation := stakingKeeper.GetRedelegations(ctx, alice, uint16(10)) + require.Len(t, redelegation, 1, "expect one redelegation") + require.Len(t, redelegation[0].Entries, 1, "expect one redelegation entry") + + // Alice tokenizes the redelegation + tokenizedAmount := sdk.TokensFromConsensusPower(5, sdk.DefaultPowerReduction) + tokenizedCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), tokenizedAmount) + _, err = msgServer.TokenizeShares(sdk.WrapSDKContext(ctx), &types.MsgTokenizeShares{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorBAddress.String(), + Amount: tokenizedCoin, + TokenizedShareOwner: alice.String(), + }) + require.NoError(t, err) + + // get the module account for the tokenization + shareRecord, err := stakingKeeper.GetTokenizeShareRecord(ctx, stakingKeeper.GetLastTokenizeShareRecordID(ctx)) + require.NoError(t, err, "expect to found token share record") + + // Check that the redelegation is still present + redelegation = stakingKeeper.GetRedelegations(ctx, shareRecord.GetModuleAddress(), uint16(10)) + require.Len(t, redelegation, 1, "expect one redelegation") + require.Len(t, redelegation[0].Entries, 1, "expect one redelegation entry") + + // advance time until the redelegations should mature + // end block + staking.EndBlocker(ctx, stakingKeeper) + ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 1) + // advance by 22 days + ctx = ctx.WithBlockTime(ctx.BlockTime().Add(22 * 24 * time.Hour)) + // begin block + staking.BeginBlocker(ctx, stakingKeeper) + // end block + staking.EndBlocker(ctx, stakingKeeper) + + // check that there the redelegation is removed + redelegation = stakingKeeper.GetRedelegations(ctx, shareRecord.GetModuleAddress(), uint16(10)) + require.Len(t, redelegation, 0, "expect no redelegations") +} + +func TestRedelegateSharesTwiceUsingTokenization(t *testing.T) { + _, app, ctx := createTestInput(t) + var ( + stakingKeeper = app.StakingKeeper + bankKeeper = app.BankKeeper + ) + msgServer := keeper.NewMsgServerImpl(stakingKeeper) + validatorA := stakingKeeper.GetAllValidators(ctx)[0] + validatorAAddress := validatorA.GetOperator() + _, validatorBAddress := setupTestTokenizeAndRedeemConversion(t, *stakingKeeper, bankKeeper, ctx) + _, validatorCAddress := setupTestTokenizeAndRedeemConversion(t, *stakingKeeper, bankKeeper, ctx) + + addrs := simtestutil.AddTestAddrs(bankKeeper, stakingKeeper, ctx, 2, stakingKeeper.TokensFromConsensusPower(ctx, 10000)) + alice, bob := addrs[0], addrs[1] + + delegateAmount := sdk.NewInt(1000) + delegateCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), delegateAmount) + + // Alice delegates to validatorA + _, err := msgServer.Delegate(sdk.WrapSDKContext(ctx), &types.MsgDelegate{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorAAddress.String(), + Amount: delegateCoin, + }) + require.NoError(t, err, "no error expected when delegating") + + del, found := stakingKeeper.GetDelegation(ctx, alice, validatorAAddress) + require.True(t, found, "delegation should have been found") + redelegateAmount := delegateAmount.ToLegacyDec().QuoInt64(2) + redelegateCoin := sdk.NewCoin(stakingKeeper.BondDenom(ctx), redelegateAmount.TruncateInt()) + + // Alice redelegates to validatorB + _, err = msgServer.BeginRedelegate(sdk.WrapSDKContext(ctx), &types.MsgBeginRedelegate{ + DelegatorAddress: alice.String(), + ValidatorSrcAddress: validatorAAddress.String(), + ValidatorDstAddress: validatorBAddress.String(), + Amount: redelegateCoin, + }) + require.NoError(t, err, "no error expected during redelegation") + + // Alice tokenizes redelegations in validatorB + token, err := msgServer.TokenizeShares(sdk.WrapSDKContext(ctx), &types.MsgTokenizeShares{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorBAddress.String(), + Amount: redelegateCoin, + TokenizedShareOwner: alice.String(), + }) + + // Alice sends share tokens to Bob + err = bankKeeper.SendCoins(ctx, alice, bob, sdk.NewCoins(token.Amount)) + require.NoError(t, err, "no error expected during coins transfer") + + balance := bankKeeper.GetBalance(ctx, bob, token.Amount.Denom) + require.Equal(t, token.Amount, balance) + + // Bob redeems tokens from validatorB + _, err = msgServer.RedeemTokensForShares(sdk.WrapSDKContext(ctx), &types.MsgRedeemTokensForShares{ + DelegatorAddress: bob.String(), + Amount: balance, + }) + require.NoError(t, err, "no error expected during redemption") + + del, found = stakingKeeper.GetDelegation(ctx, bob, validatorBAddress) + require.True(t, found, "delegation should have been found") + require.Equal(t, del.Shares, redelegateAmount) + + // Bob attempts to redelegate the same shares to validatorC + _, err = msgServer.BeginRedelegate(sdk.WrapSDKContext(ctx), &types.MsgBeginRedelegate{ + DelegatorAddress: bob.String(), + ValidatorSrcAddress: validatorBAddress.String(), + ValidatorDstAddress: validatorCAddress.String(), + Amount: redelegateCoin, + }) + require.Error(t, err, "error expected during forbidden redelegation") +} diff --git a/x/staking/keeper/msg_server.go b/x/staking/keeper/msg_server.go index a88af57ec750..8ecde3633098 100644 --- a/x/staking/keeper/msg_server.go +++ b/x/staking/keeper/msg_server.go @@ -740,6 +740,15 @@ func (k msgServer) TokenizeShares(goCtx context.Context, msg *types.MsgTokenizeS Validator: msg.ValidatorAddress, } + // Check if the delegator has redelegated some shares to the validator, in this case, + // update the redelegations for which the shares are tokenized. + if k.HasReceivingRedelegation(ctx, delegatorAddress, valAddr) { + err := k.TransferRedelegationsOfTokenizedShares(ctx, delegation, shares, delegatorAddress, record.GetModuleAddress()) + if err != nil { + return nil, err + } + } + // note: this returnAmount can be slightly off from the original delegation amount if there // is a decimal to int precision error returnAmount, err := k.Unbond(ctx, delegatorAddress, valAddr, shares) @@ -782,6 +791,7 @@ func (k msgServer) TokenizeShares(goCtx context.Context, msg *types.MsgTokenizeS if err != nil { return nil, err } + // send coins to module account err = k.bankKeeper.SendCoins(ctx, delegatorAddress, record.GetModuleAddress(), sdk.Coins{returnCoin}) if err != nil { @@ -852,11 +862,21 @@ func (k msgServer) RedeemTokensForShares(goCtx context.Context, msg *types.MsgRe return nil, types.ErrNoUnbondingDelegation } + // Normalize the amount of share tokens due to potential discrepancies between + // the total delegation shares and the share token supply ratio, + // which may not always be 1:1 due to slashing. + + // get total shares and token supply + shareTokenSupply := k.bankKeeper.GetSupply(ctx, shareToken.Denom) + shareTokenFraction := delegation.Shares.QuoInt(shareTokenSupply.Amount) + // normalize share tokens amount to redeem + shareTokenAmount := shareTokenFraction.MulInt(shareToken.Amount) + // Similar to undelegations, if the account is attempting to tokenize the full delegation, // but there's a precision error due to the decimal to int conversion, round up to the // full decimal amount before modifying the delegation - shares := sdk.NewDecFromInt(shareToken.Amount) - if shareToken.Amount.Equal(delegation.Shares.TruncateInt()) { + shares := shareTokenAmount + if shareTokenAmount.TruncateInt().Equal(delegation.Shares.TruncateInt()) { shares = delegation.Shares } tokens := validator.TokensFromShares(shares).TruncateInt() @@ -866,6 +886,15 @@ func (k msgServer) RedeemTokensForShares(goCtx context.Context, msg *types.MsgRe return nil, types.ErrTinyRedemptionAmount } + // check if the shares are from redelegations, in this case, + // update the redelegations for which the shares are redeemed + if k.HasReceivingRedelegation(ctx, record.GetModuleAddress(), valAddr) { + err := k.TransferRedelegationsOfTokenizedShares(ctx, delegation, shares, record.GetModuleAddress(), delegatorAddress) + if err != nil { + return nil, err + } + } + // If this redemption is NOT from a liquid staking provider, decrement the total liquid staked // If the redemption was from a liquid staking provider, the shares are still considered // liquid, even in their non-tokenized form (since they are owned by a liquid staking provider) diff --git a/x/staking/keeper/tokenize_share_record.go b/x/staking/keeper/tokenize_share_record.go index ecde86be0c2f..9a1f8840e10b 100644 --- a/x/staking/keeper/tokenize_share_record.go +++ b/x/staking/keeper/tokenize_share_record.go @@ -2,6 +2,8 @@ package keeper import ( "fmt" + "sort" + "time" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" @@ -149,3 +151,331 @@ func (k Keeper) setTokenizeShareRecordWithDenom(ctx sdk.Context, denom string, i store.Set(types.GetTokenizeShareRecordIDByDenomKey(denom), bz) } + +// TransferRedelegationsOfTokenizedShares transfers +// redelegations whose underlying shares are involved in a given tokenization/redemption +// by changing the delegator address to the destination address. +// The +// In the case of tokenization, the redelegations should be transferred from a delegator to the share record module account. +// In the case of redemption, the redelegations should be transferred from the share record module account to the delegator. +func (k Keeper) TransferRedelegationsOfTokenizedShares(ctx sdk.Context, delegation types.Delegation, amount sdk.Dec, srcAddr, dstAddr sdk.AccAddress) error { + // Iterate over the delegator's redelegations and store the one + // for which the shares are part of tokenization + reds := []types.Redelegation{} + k.IterateDelegatorRedelegations(ctx, srcAddr, func(red types.Redelegation) (stop bool) { + // check only redelegations to the validator of the delegation + if red.ValidatorDstAddress == delegation.ValidatorAddress { + reds = append(reds, red) + } + + return false + }) + return k.updateRedelegationsWithTokenizedShares( + ctx, + delegation, + amount, + reds, + dstAddr.String(), + ) +} + +// updateTokenizedSharesRedelegation defines the amount of shares from the given +// redelegations are involved in the process of tokenizing or redeeming a given amount shares of a delegation. +// If the result is positive, it transfers redelegations for the same amount of shares +// to the given destination delegator address. +func (k Keeper) updateRedelegationsWithTokenizedShares( + ctx sdk.Context, + delegation types.Delegation, + amount sdk.Dec, + redelegations []types.Redelegation, + dstDelegatorAddress string, +) error { + // compute the amount of shares from redelegations that are still bonded + redsShares, err := k.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + delegation.GetDelegatorAddr(), + redelegations, + delegation.GetValidatorAddr(), + ) + if err != nil { + return err + } + + // compute the shares left, e.g. not coming from redelegations + sharesLeft := delegation.Shares.Sub(redsShares) + + // if the amount of shares left is negative, this means there are some redelegations + // tracking shares that do not longer exist + if sharesLeft.IsNegative() { + return fmt.Errorf("delegator address %s has more redelegated shares %s than delegation shares %s in validator %s", + delegation.DelegatorAddress, redsShares, sharesLeft, delegation.ValidatorAddress) + } + + // if the shares left is GTE to the amount, + // no redelegations need to be transferred + if sharesLeft.GTE(amount) { + return nil + } + + // compute how many redelegated shares are required + // to be transferred + amountToTransfer := amount.Sub(sharesLeft) + // get the minimum subset of the redelegations for which the total shares + // GTE to the amount redelegated shares to transfer + redelegationsToTransfer, err := GetMinimumRedelegationsSubsetByShares(amountToTransfer, redelegations) + if err != nil { + return err + } + + // transfer redelegations + transferredReds, remainingReds, ok := TransferRedelegations( + amountToTransfer, + dstDelegatorAddress, + redelegationsToTransfer, + ) + if !ok { + return fmt.Errorf("fail to transfer %s shares from redelegations due to insufficient delegation shares %s", + amount, redsShares) + } + + // check that we get the expected returned length of redelegations + // note that it should never happen + if len(redelegationsToTransfer) != len(transferredReds) || len(remainingReds) > 1 { + return fmt.Errorf("fail to tokenize redelegation shares: length of redelegations to transfer is not ok") + } + + // update redelegations in store + for i := 0; i < len(redelegationsToTransfer); i++ { + k.SetRedelegation(ctx, transferredReds[i]) + k.RemoveRedelegation(ctx, redelegationsToTransfer[i]) + // insert the redelegation into the queue + // its ok to not update the old queue entry because erroring ones are ignored + for _, entry := range transferredReds[i].Entries { + k.InsertRedelegationQueue(ctx, transferredReds[i], entry.CompletionTime) + } + } + + // this is the case where a redelegation is split into two because + // the amount of shares to transfer does not match exactly with the shares of the redelegations + if len(remainingReds) == 1 { + k.SetRedelegation(ctx, remainingReds[0]) + // insert the redelegation into the queue + // its ok to not update the old queue entry because erroring ones are ignored + for _, entry := range remainingReds[0].Entries { + k.InsertRedelegationQueue(ctx, remainingReds[0], entry.CompletionTime) + } + } + + return nil +} + +// TransferRedelegations iterates through the redelegations and updates their delegator address to the dstDelegatorAddr +// until redelegations with *amount* shares have had their delegator address changed. +// Note that the last redelegation that is transferred may have too many shares. In that case, +// we split that redelegation into two, one with the correct amount of shares and one with the remaining shares. +// The function returns the transferred redelegations, the remaining redelegations, and a boolean indicating +// whether the transfer was successful or not. +func TransferRedelegations( + amount sdk.Dec, + dstDelegatorAddr string, + redelegations []types.Redelegation, +) (transferredReds []types.Redelegation, remainingReds []types.Redelegation, ok bool) { + // iterate over all the redelegations until + // their combined shares is equal to the given amount + aLeft := amount + for idx, red := range redelegations { + var entriesLeft, entriesTransferred []types.RedelegationEntry + aLeft, entriesLeft, entriesTransferred = updateRedelegationEntriesByAmount(aLeft, red.Entries) + + // append transferred redelegations + transferredReds = append(transferredReds, types.Redelegation{ + DelegatorAddress: dstDelegatorAddr, + ValidatorSrcAddress: red.ValidatorSrcAddress, + ValidatorDstAddress: red.ValidatorDstAddress, + Entries: entriesTransferred, + }) + + // check if enough shares are collected + if aLeft.IsZero() { + if len(entriesLeft) > 0 { + // update remaining redelegations + remainingReds = append(remainingReds, types.Redelegation{ + DelegatorAddress: red.DelegatorAddress, + ValidatorSrcAddress: red.ValidatorSrcAddress, + ValidatorDstAddress: red.ValidatorDstAddress, + Entries: entriesLeft, + }) + + remainingReds = append(remainingReds, redelegations[idx+1:]...) + } + + break + } + } + + // check that the total amount is transferred + if aLeft.IsPositive() { + return nil, nil, false + } + + return transferredReds, remainingReds, true +} + +// updateRedelegationEntriesByAmount splits the given redelegation entries into two slices: +// the second slice contains entries with up to the given amount of shares, and the first slice contains the remaining entries. +func updateRedelegationEntriesByAmount(amount sdk.Dec, entries []types.RedelegationEntry) (sdk.Dec, []types.RedelegationEntry, []types.RedelegationEntry) { + // res1 will be the slice that contains the remaining relegedations + res1 := make([]types.RedelegationEntry, 0) + // res2 will be the slice that contains the transferred entries + res2 := make([]types.RedelegationEntry, 0) + + // iterate over all the entries and add them to the first slice + // until their combined shares is equal to the given amount + aLeft := amount + for _, entry := range entries { + if aLeft.IsZero() { + res1 = append(res1, entry) + } else { + // check if the entry shares are less than the remaining shares + if entry.SharesDst.LTE(aLeft) { + res2 = append(res2, entry) + aLeft = aLeft.Sub(entry.SharesDst) + } else { + // split the entry into two entries + // one with the given amount of shares and one with the remaining shares + entry1 := entry + entry1.SharesDst = entry.SharesDst.Sub(aLeft) // collect the remaining shares + res1 = append(res1, entry1) + + entry2 := entry + entry2.SharesDst = aLeft // finish filling the given amount of shares + res2 = append(res2, entry2) + + aLeft = sdk.ZeroDec() + } + } + } + + return aLeft, res1, res2 +} + +// ComputeRemainingRedelegatedShares takes a delegator address, validator address, and a list of redelegations +// that should all be BY the delegator TO the given validator (we do not care about the source validators here). +// It computes the shares of redelegations that are *not* matched by a subsequent unbonding. +// For example, consider this scenario for delegator D and validator V, assuming that no redelegations or unbondings +// complete during this time: +// - First, D redelegates 10 shares to V - D has 10 shares from redelegations to V +// - Then, D unbonds 5 shares from V - we assume that these unbonding shares are the ones that were just redelegated, so D has 5 shares from redelegations to V left +// - Finally, D unbonds 10 more shares to V - now D has 0 shares from redelegations to V left (and the other unbonding shares must come from a native delegation) +// This function returns the amount of shares that are still in the redelegations after the unbondings are taken into account in this manner, +// so in this example the outcome would be 0. +// See docs/architecture/adr-061-liquid-staking.md for more information. +func (k Keeper) ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx sdk.Context, + delAddr sdk.AccAddress, + reds []types.Redelegation, + valAddr sdk.ValAddress, +) (sdk.Dec, error) { + // delegationEntry defines an general entry representing either + // the addition or withdrawal of delegation shares at completion time. + type delegationEntry struct { + completionTime time.Time + shares sdk.Dec + } + + delegationEntries := []delegationEntry{} + validator, found := k.GetValidator(ctx, valAddr) + if !found { + return sdk.ZeroDec(), types.ErrNoValidatorFound + } + + for _, red := range reds { + // sanity check + // check that the redelegation has the given validator destination address + if valAddr.String() != red.ValidatorDstAddress { + return sdk.ZeroDec(), types.ErrBadRedelegationDst + } + // sanity check + // check that the redelegation has the given delegator address + if delAddr.String() != red.DelegatorAddress { + return sdk.ZeroDec(), types.ErrBadDelegatorAddr + } + + // store each redelegation entry as a delegation entry + // adding shares at completion time + for _, redEntry := range red.Entries { + delegationEntries = append(delegationEntries, delegationEntry{ + redEntry.CompletionTime, + // care about the destination shares because that is the current + // amount of shares represented by this entry, and + // this is how many shares this currently represents + redEntry.SharesDst, + }) + } + } + + // go through all unbonding delegations + ubd, found := k.GetUnbondingDelegation(ctx, delAddr, valAddr) + if found { + for _, ubdEntry := range ubd.Entries { + // get the tokens this unbonding delegation entry represents right now + // we care about the *current balance* beacuse that is ultimately the + // shares that will be removed at the completion time + ubdEntryShares, err := validator.SharesFromTokens(ubdEntry.Balance) + if err != nil { + return sdk.ZeroDec(), err + } + // store each unbonding delegation entry as a delegation entry + // withdrawing shares at completion time, by using it's negative amount of shares + delegationEntries = append(delegationEntries, + delegationEntry{ + ubdEntry.CompletionTime, + ubdEntryShares.Neg(), + }) + } + } + + // sort delegation entries by completion time in ascending order + // This is because we need to go through the delegation entries in chronological order + // to match redelegations with unbondings + sort.Slice(delegationEntries, func(i, j int) bool { + return delegationEntries[i].completionTime.Before(delegationEntries[j].completionTime) + }) + + // Sum the shares of delegation entries, flooring negative values to zero. + // This assumes that negative shares must have been taken from the initial delegation shares initially, + // otherwise the withdrawing operation should have failed. + remainingShares := sdk.ZeroDec() + for _, entry := range delegationEntries { + if remainingShares.Add(entry.shares).IsNegative() { + remainingShares = sdk.ZeroDec() + continue + } + + remainingShares = remainingShares.Add(entry.shares) + } + + return remainingShares, nil +} + +// GetMinimumRedelegationsSubsetByShares takes a list of redelegations and +// returns a subset of it where the combined shares are greater than or equal to a given amount. +// It returns an error if the given amount is greater than the total shares in the given redelegations. +func GetMinimumRedelegationsSubsetByShares(amount sdk.Dec, redelegations []types.Redelegation) (out []types.Redelegation, err error) { + redsShares := sdk.ZeroDec() + for _, red := range redelegations { + for _, entry := range red.Entries { + redsShares = redsShares.Add(entry.SharesDst) + } + out = append(out, red) + if redsShares.GTE(amount) { + break + } + } + + if redsShares.LT(amount) { + return nil, fmt.Errorf("shares from redelegations is less than the given amount: %s, %s", redsShares, amount) + } + + return out, err +} diff --git a/x/staking/keeper/tokenize_share_record_test.go b/x/staking/keeper/tokenize_share_record_test.go index fd9dd76a6765..5a819348e3f0 100644 --- a/x/staking/keeper/tokenize_share_record_test.go +++ b/x/staking/keeper/tokenize_share_record_test.go @@ -1,8 +1,17 @@ package keeper_test import ( + "strconv" + "testing" + "time" + + "cosmossdk.io/math" simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/staking/keeper" "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" ) func (suite *KeeperTestSuite) TestGetLastTokenizeShareRecordId() { @@ -61,3 +70,675 @@ func (suite *KeeperTestSuite) TestGetTokenizeShareRecord() { tokenizeShareRecords = keeper.GetTokenizeShareRecordsByOwner(ctx, owner2) suite.Equal(len(tokenizeShareRecords), 1) } + +func (suite *KeeperTestSuite) TestTokenizeRedelegationShares() { + srcDelAddr := sdk.AccAddress([]byte("SrcDelAddr")) + lsmModuleAddr := sdk.AccAddress([]byte("lsmAddr")) + validatorDstAddress := sdk.ValAddress([]byte("ValDstAddr")) + + reds := []types.Redelegation{ + { + DelegatorAddress: srcDelAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + }, + }, + { + DelegatorAddress: srcDelAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr1")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + }, + }, + { + DelegatorAddress: srcDelAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr2")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + SharesDst: sdk.NewDecFromInt(math.NewInt(2)), + }, + }, + }, + { + DelegatorAddress: srcDelAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr3")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + { + SharesDst: sdk.NewDecFromInt(math.NewInt(1)), + }, + { + SharesDst: sdk.NewDecFromInt(math.NewInt(2)), + }, + }, + }, + } + + testCases := []struct { + name string + amountTokenized int64 + delegatorShares int64 + redelegations []types.Redelegation + expDelegatorRedsShares sdk.Dec + expTokenizedRedsShares sdk.Dec + expErr bool + }{ + { + name: "expect to fail when the tokenized amount is greater than the total delegator shares", + amountTokenized: 20, + delegatorShares: 10, + redelegations: reds[0:2], // total of 10 shares + expDelegatorRedsShares: sdk.NewDecFromInt(math.NewInt(10)), + expTokenizedRedsShares: sdk.ZeroDec(), + expErr: true, + }, + { + name: "expect to fail when the redelegated shares are greater than the total delegator shares", + amountTokenized: 10, + delegatorShares: 10, + redelegations: reds[0:3], // total of 12 shares + expDelegatorRedsShares: sdk.NewDecFromInt(math.NewInt(12)), + expTokenizedRedsShares: sdk.ZeroDec(), + expErr: true, + }, + { + name: "expect to tokenize all the shares from the redelegations", + amountTokenized: 20, + delegatorShares: 20, + redelegations: reds, // total of 20 shares + expDelegatorRedsShares: sdk.ZeroDec(), + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(20)), + expErr: false, + }, + { + name: "expect to tokenize half of the shares from the redelegations", + amountTokenized: 30, + delegatorShares: 40, + redelegations: reds, // total of 20 shares + expDelegatorRedsShares: sdk.NewDecFromInt(math.NewInt(10)), + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(10)), + expErr: false, + }, + { + name: "expect to tokenize 3/5 of the shares from the redelegations", + amountTokenized: 6, + delegatorShares: 10, + redelegations: reds[0:2], // total of 10 shares + expDelegatorRedsShares: sdk.NewDecFromInt(math.NewInt(4)), + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(6)), + expErr: false, + }, + { + name: "expect to tokenize 5/12 of the shares from the redelegations", + amountTokenized: 10, + delegatorShares: 17, + redelegations: reds[0:3], // total of 12 shares + expDelegatorRedsShares: sdk.NewDecFromInt(math.NewInt(7)), + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(5)), + expErr: false, + }, + } + + ctx, stakingKeeper := suite.ctx, suite.stakingKeeper + stakingKeeper.SetValidator(ctx, types.Validator{ + OperatorAddress: validatorDstAddress.String(), + }) + + for _, tc := range testCases { + suite.Run(tc.name, func() { + cCtx, _ := ctx.CacheContext() + for _, red := range tc.redelegations { + stakingKeeper.SetRedelegation(cCtx, red) + } + + amount := sdk.NewDecFromInt(math.NewInt(tc.delegatorShares)) + err := stakingKeeper.TransferRedelegationsOfTokenizedShares( + cCtx, + types.Delegation{ + DelegatorAddress: srcDelAddr.String(), + ValidatorAddress: validatorDstAddress.String(), + Shares: amount, + ValidatorBond: false, + }, + sdk.NewDecFromInt(math.NewInt(tc.amountTokenized)), + srcDelAddr, + lsmModuleAddr, + ) + + if !tc.expErr { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + + delegatorRedsTotalShares, _ := getRedelegationsTotalSharesAndEntriesNum(stakingKeeper.GetRedelegations(cCtx, srcDelAddr, uint16(10))) + tokenizedRedsShares, _ := getRedelegationsTotalSharesAndEntriesNum(stakingKeeper.GetRedelegations(cCtx, lsmModuleAddr, uint16(10))) + + suite.Require().Equal(tc.expDelegatorRedsShares, delegatorRedsTotalShares) + suite.Require().Equal(tc.expTokenizedRedsShares, tokenizedRedsShares) + }) + } +} + +func (suite *KeeperTestSuite) TestRedeemTokensForRedelegationShares() { + srcDelAddr := sdk.AccAddress([]byte("SrcDelAddr")) + lsmModuleAddr := sdk.AccAddress([]byte("lsmAddr")) + validatorDstAddress := sdk.ValAddress([]byte("ValDstAddr")) + + reds := []types.Redelegation{ + { + DelegatorAddress: lsmModuleAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + { + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + }, + }, + { + DelegatorAddress: lsmModuleAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr1")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + SharesDst: sdk.NewDecFromInt(math.NewInt(2)), + }, + }, + }, + { + DelegatorAddress: lsmModuleAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr0")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + { + SharesDst: sdk.NewDecFromInt(math.NewInt(1)), + }, + { + SharesDst: sdk.NewDecFromInt(math.NewInt(2)), + }, + }, + }, + } + + testCases := []struct { + name string + amountRedeemed int64 + tokenizedShares int64 + redelegations []types.Redelegation + expTokenizedRedsShares sdk.Dec + expRedeemRedsShares sdk.Dec + expErr bool + }{ + { + name: "expect to fail when the redemption amount is greater than the total delegator shares", + amountRedeemed: 10, + tokenizedShares: 5, + redelegations: reds[0:1], // total of 10 shares + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(10)), + expRedeemRedsShares: sdk.ZeroDec(), // no shares transferred + expErr: true, + }, + { + name: "expect to redeem all redelegations", + amountRedeemed: 10, + tokenizedShares: 10, + redelegations: reds, // total of 20 shares + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(20)), + expRedeemRedsShares: sdk.ZeroDec(), + expErr: true, + }, + { + name: "expect to redeem half of the redelegations", + amountRedeemed: 10, + tokenizedShares: 20, + redelegations: reds, // total of 20 shares + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(10)), + expRedeemRedsShares: sdk.NewDecFromInt(math.NewInt(10)), + expErr: false, + }, + { + name: "expect to redeem 3/5 of the redelegations", + amountRedeemed: 6, + tokenizedShares: 10, + redelegations: reds[0:1], // total of 10 shares + expRedeemRedsShares: sdk.NewDecFromInt(math.NewInt(6)), + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(4)), + expErr: false, + }, + { + name: "expect to redeem 3/8 of the redelegations", + amountRedeemed: 5, + tokenizedShares: 10, + redelegations: reds[2:], // total of 8 shares + expRedeemRedsShares: sdk.NewDecFromInt(math.NewInt(3)), + expTokenizedRedsShares: sdk.NewDecFromInt(math.NewInt(5)), + expErr: false, + }, + } + + ctx, stakingKeeper := suite.ctx, suite.stakingKeeper + stakingKeeper.SetValidator(ctx, types.Validator{ + OperatorAddress: validatorDstAddress.String(), + }) + + for _, tc := range testCases { + suite.Run(tc.name, func() { + cCtx, _ := ctx.CacheContext() + for _, red := range tc.redelegations { + stakingKeeper.SetRedelegation(cCtx, red) + } + + err := stakingKeeper.TransferRedelegationsOfTokenizedShares( + cCtx, + types.Delegation{ + ValidatorAddress: validatorDstAddress.String(), + DelegatorAddress: lsmModuleAddr.String(), + Shares: sdk.NewDecFromInt(math.NewInt(tc.tokenizedShares)), + }, + sdk.NewDecFromInt(math.NewInt(tc.amountRedeemed)), + lsmModuleAddr, + srcDelAddr, + ) + + if !tc.expErr { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + + redeemRedsShares, _ := getRedelegationsTotalSharesAndEntriesNum(stakingKeeper.GetRedelegations(cCtx, srcDelAddr, uint16(10))) + tokenizedRedsShares, _ := getRedelegationsTotalSharesAndEntriesNum(stakingKeeper.GetRedelegations(cCtx, lsmModuleAddr, uint16(10))) + + suite.Require().Equal(tc.expRedeemRedsShares, redeemRedsShares) + suite.Require().Equal(tc.expTokenizedRedsShares, tokenizedRedsShares) + }) + } +} + +func (suite *KeeperTestSuite) TestComputeRemainingRedelegatedSharesAfterUnbondings() { + delAddr := sdk.AccAddress([]byte("delAddr")) + validatorDstAddress := sdk.ValAddress([]byte("ValDstAddr")) + timeNow := time.Now() + + reds := []types.Redelegation{ + { + DelegatorAddress: delAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + CompletionTime: timeNow, + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + { + CompletionTime: timeNow.Add(5 * time.Hour), + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + }, + }, + { + DelegatorAddress: delAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr1")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + CompletionTime: timeNow.Add(10 * time.Hour), + SharesDst: sdk.NewDecFromInt(math.NewInt(2)), + }, + }, + }, + { + DelegatorAddress: delAddr.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr0")).String(), + ValidatorDstAddress: validatorDstAddress.String(), + Entries: []types.RedelegationEntry{ + { + CompletionTime: timeNow.Add(15 * time.Hour), + SharesDst: sdk.NewDecFromInt(math.NewInt(5)), + }, + { + CompletionTime: timeNow.Add(20 * time.Hour), + SharesDst: sdk.NewDecFromInt(math.NewInt(1)), + }, + { + CompletionTime: timeNow.Add(30 * time.Hour), + SharesDst: sdk.NewDecFromInt(math.NewInt(2)), + }, + }, + }, + } + + ubd := types.UnbondingDelegation{ + DelegatorAddress: delAddr.String(), + ValidatorAddress: validatorDstAddress.String(), + Entries: []types.UnbondingDelegationEntry{ + { + CompletionTime: timeNow.Add(3 * time.Hour), + InitialBalance: math.NewInt(5), // 5 - 5 => 0 + }, + { + CompletionTime: timeNow.Add(8 * time.Hour), + InitialBalance: math.NewInt(1), // 5 - 1 => 4 + }, + { + CompletionTime: timeNow.Add(12 * time.Hour), + InitialBalance: math.NewInt(10), // 4 + 2 - 10 => 0 + }, + { + CompletionTime: timeNow.Add(25 * time.Hour), + InitialBalance: math.NewInt(5), // 5 + 1 - 5 + 2 => 3 + }, + }, + } + + ctx, stakingKeeper := suite.ctx, suite.stakingKeeper + + // expect an error when validator isn't set + _, err := stakingKeeper.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + delAddr, + reds, + validatorDstAddress, + ) + suite.Require().Error(err) + + // set validator + stakingKeeper.SetValidator(ctx, types.Validator{ + OperatorAddress: validatorDstAddress.String(), + DelegatorShares: sdk.NewDecFromInt(sdk.NewInt(100)), + Tokens: sdk.NewInt(100), + }) + + // expect an error when the passed delegator address doesn't match the one in the redelegations + _, err = stakingKeeper.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + sdk.AccAddress([]byte("wrongDelAddr")), + reds, + validatorDstAddress, + ) + + // expect an error when the passed validator address doesn't match the one in the redelegations + _, err = stakingKeeper.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + delAddr, + reds, + sdk.ValAddress([]byte("wrongValDstAddr")), + ) + + // expect no error when no redelegations is passed + res, err := stakingKeeper.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + delAddr, + []types.Redelegation{}, + validatorDstAddress, + ) + suite.Require().NoError(err) + suite.Require().Equal(sdk.ZeroDec(), res) + + // expect no error when no unbonding delegations exist + res, err = stakingKeeper.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + delAddr, + reds, + validatorDstAddress, + ) + suite.Require().NoError(err) + suite.Require().Equal(sdk.NewDecFromInt(sdk.NewInt(20)), res) + + stakingKeeper.SetUnbondingDelegation(ctx, ubd) + + // expect no error when no redelegations is passed + res, err = stakingKeeper.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + delAddr, + []types.Redelegation{}, + validatorDstAddress, + ) + suite.Require().NoError(err) + suite.Require().Equal(sdk.ZeroDec(), res) + + // expect no error + res, err = stakingKeeper.ComputeRemainingRedelegatedSharesAfterUnbondings( + ctx, + delAddr, + reds, + validatorDstAddress, + ) + suite.Require().NoError(err) + suite.Require().Equal(sdk.NewDecFromInt(sdk.NewInt(3)), res) +} + +func TestTransferRedelegationsProperties(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + // Generate a random redelegations input and an amount of shares to transfer + action := getRedelegationsTransferActionGen().Draw(t, "test1") + + // compute the amount of shares from redelegations + // that required to be tokenized + delSharesLeft := action.TotalShares.Sub(action.RedelegationsShares) + if delSharesLeft.GTE(action.Amount) { + // no redelegations require to be tokenized + return + } + + // + amountToTransfer := action.Amount.Sub(delSharesLeft) + redelegationsToTransfer, err := keeper.GetMinimumRedelegationsSubsetByShares(amountToTransfer, action.Redelegations) + if err != nil { + t.Fatalf(err.Error()) + } + + if err != nil { + t.Fatalf("do no expect transfer redelegation to fail with action %v\n%s", action, err) + } + + inputRedsShares, inputRedsEntriesNum := getRedelegationsTotalSharesAndEntriesNum(redelegationsToTransfer) + + transferredRedelegations, remainingRedelegations, ok := keeper.TransferRedelegations( + amountToTransfer, + sdk.AccAddress([]byte("lsmModuleAccount")).String(), + redelegationsToTransfer, + ) + + if !ok { + t.Fatalf("do no expect transfer redelegation to fail with action %v", action) + } + + out1TotalShares, ou1tEntriesNum := getRedelegationsTotalSharesAndEntriesNum(remainingRedelegations) + out2TotalShares, out2EntriesNum := getRedelegationsTotalSharesAndEntriesNum(transferredRedelegations) + + outTotalShares := out1TotalShares.Add(out2TotalShares) + outEntriesNum := ou1tEntriesNum + out2EntriesNum + + // Properties + // + // - test assumption: + // For a list of redelegations Reds of length N, where an amount A of shares is to be transferred, + // it is assumed that the total number of shares from the first redelegation to the second-to-last redelegation in Reds is less than A. + // Therefore, it is anticipated that all redelegations in Reds will have their shares transferred, + // except for the last one, which is expected to have at least some of its shares transferred. + // + // + // #1 the total shares of redelegations input must be equal + // to the the total shares of redelegations output, i.e. the remaining and transferred redelegations + require.Equal( + t, + inputRedsShares, + outTotalShares, + ) + // #2 The number of entries in the redelegations output + // is either equal or one greater than the number entries in the redelegations input + if inputRedsEntriesNum != outEntriesNum && inputRedsEntriesNum+1 != outEntriesNum { + t.Fatalf("invalid number of entries in transferred redelegagtions output: have %d expected %d or %d", outEntriesNum, action.EntriesNum+1, action.EntriesNum+1) + } + // #3 + // a) The number of redelegations transferred is equal to the number of redelegations of the input + require.Equal(t, len(redelegationsToTransfer), len(transferredRedelegations)) + + // b) The number of remaining redelegations is either zero or 1. + require.LessOrEqual(t, len(remainingRedelegations), 1) + + // Check that the redelegations output data are updated as expected + verifyRedelegationsOutput(t, redelegationsToTransfer, remainingRedelegations, transferredRedelegations, sdk.AccAddress([]byte("dstAddr")).String()) + }) +} + +type redelegationsTransferAction struct { + Amount sdk.Dec + Redelegations []types.Redelegation + RedelegationsShares sdk.Dec + TotalShares sdk.Dec + EntriesNum int +} + +func getRedelegationsTransferActionGen() *rapid.Generator[redelegationsTransferAction] { + return rapid.Custom(func(t *rapid.T) redelegationsTransferAction { + // generate total shares + totalShares := math.NewInt(rapid.Int64Range(1, 1_000_000).Draw(t, "amountToTransfer")) + // generate upper bound of redelegated shares + redelegatedSharesUB := math.NewInt(rapid.Int64Range(1, totalShares.Int64()).Draw(t, "amountToTransfer")) + // generate amount to transfer + amount := math.NewInt(rapid.Int64Range(1, totalShares.Int64()).Draw(t, "amountToTransfer")) + + action := redelegationsTransferAction{} + delegatorAddr := sdk.AccAddress([]byte("addr1")) + validatorDstAddress := sdk.ValAddress([]byte("ValDstAddr")) + + i, totalEntriesNum, redsGenShares := 0, 0, sdk.ZeroDec() + for { + red := types.Redelegation{ + DelegatorAddress: delegatorAddr.String(), + ValidatorDstAddress: validatorDstAddress.String(), + ValidatorSrcAddress: sdk.ValAddress([]byte("ValSrcAddr" + strconv.Itoa(i))).String(), + Entries: []types.RedelegationEntry{}, + } + entriesNum := rapid.Int16Range(1, 7).Draw(t, "entriesNum") + redShares := sdk.ZeroDec() + for j := 0; j < int(entriesNum); j++ { + tokensAmount := math.NewInt(rapid.Int64Range(1, redelegatedSharesUB.Int64()).Draw(t, "tokenAmount")) + sharesAmount := sdk.NewDecFromIntWithPrec(tokensAmount, rapid.Int64Range(0, 6).Draw(t, "sharesAmount")) + + red.Entries = append(red.Entries, types.RedelegationEntry{ + CreationHeight: rapid.Int64Range(1, 1_000_000).Draw(t, ""), + CompletionTime: getTimeGen().Draw(t, ""), + InitialBalance: tokensAmount, + SharesDst: sharesAmount, + UnbondingId: uint64(i), + UnbondingOnHoldRefCount: int64(j), + }) + redShares = redShares.Add(sharesAmount) + } + + if redelegatedSharesUB.ToLegacyDec().LT(redsGenShares.Add(redShares)) { + break + } + redsGenShares = redsGenShares.Add(redShares) + action.Redelegations = append(action.Redelegations, red) + totalEntriesNum += int(entriesNum) + i++ + } + action.RedelegationsShares = redsGenShares + action.Amount = amount.ToLegacyDec() + action.EntriesNum = totalEntriesNum + action.TotalShares = totalShares.ToLegacyDec() + + return action + }) +} + +func getTimeGen() *rapid.Generator[time.Time] { + return rapid.Custom(func(t *rapid.T) time.Time { + return time.Unix(rapid.Int64Range(-5.9959e+10, 1.5779e+11).Draw(t, "unix time"), 0).UTC() + }) +} + +func getRedelegationsTotalSharesAndEntriesNum(redelegations []types.Redelegation) (sdk.Dec, int) { + totalShares := sdk.ZeroDec() + entriesNum := 0 + for _, red := range redelegations { + for _, entry := range red.Entries { + totalShares = totalShares.Add(entry.SharesDst) + entriesNum++ + } + } + return totalShares, entriesNum +} + +// verify the two redelegations slice output against the redelegations input by ensuring that: +// 1) all redelegations input, except the last one, must have been copied into +// the second redelegations list output with an updated delegator address +// 2) in the last redelegation of the input, at most one entry can be split into two, each of which is distributed +// to the to each of the output lists of the redelegations +func verifyRedelegationsOutput(t *rapid.T, redsIn, redsOut1, redsOut2 []types.Redelegation, delAddr string) bool { + t.Helper() + + // assume that redsIn and redsOut2 are the same length from propriety #3 + for i := 0; i < len(redsIn); i++ { + // All redelegations, except the last one, must have identical + // entries and delegate address + if i < len(redsIn)-1 { + if redsOut2[i].DelegatorAddress != delAddr { + return false + } + require.Equal(t, redsIn[i].Entries, redsOut2[i].Entries) + require.Equal(t, redsIn[i].ValidatorSrcAddress, redsOut2[i].ValidatorSrcAddress) + require.Equal(t, redsIn[i].ValidatorDstAddress, redsOut2[i].ValidatorDstAddress) + // If the last redelegation has a split entry, + // check that the shares amount correctly distributed + } else { + split := false + for j := 0; j < len(redsOut2[i].Entries); j++ { + // Check if entries have different shares amount + if entryIn, entryOut2 := redsIn[i].Entries[j], redsOut2[i].Entries[j]; !entryIn.SharesDst.Equal(entryOut2.SharesDst) { + // ensure that only one entry should is split + if split { + return false + } + split = true + + // verify that entry is split in two between the redelegations output pair + require.Len(t, redsOut1, 1) + require.GreaterOrEqual(t, len(redsOut1[0].Entries), 1) + entryOut1 := redsOut1[0].Entries[0] + // verify that their shares sum is equal to the input + entryIn.SharesDst.Equal(entryOut2.SharesDst.Add(entryOut1.SharesDst)) + + // Compare entries + require.Equal(t, entryIn.CompletionTime, entryOut2.CompletionTime) + require.Equal(t, entryIn.CreationHeight, entryOut2.CreationHeight) + require.Equal(t, entryIn.InitialBalance, entryOut2.InitialBalance) + require.Equal(t, entryIn.UnbondingId, entryOut2.UnbondingId) + require.Equal(t, entryIn.UnbondingOnHoldRefCount, entryOut2.UnbondingOnHoldRefCount) + + require.Equal(t, entryIn.CompletionTime, entryOut1.CompletionTime) + require.Equal(t, entryIn.CreationHeight, entryOut1.CreationHeight) + require.Equal(t, entryIn.InitialBalance, entryOut1.InitialBalance) + require.Equal(t, entryIn.UnbondingId, entryOut1.UnbondingId) + require.Equal(t, entryIn.UnbondingOnHoldRefCount, entryOut1.UnbondingOnHoldRefCount) + } else { + require.Equal(t, entryIn, entryOut2) + } + } + } + } + + return true +}