diff --git a/CHANGELOG.md b/CHANGELOG.md index 56353fb0e624..4c10b90c64d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,14 @@ Ref: https://keepachangelog.com/en/1.0.0/ # Changelog +## v0.47.14-ics-lsm + +This is a special cosmos-sdk release with support for both ICS and LSM. + +### State Machine Breaking + +* (x/staking) [#20435](https://github.com/cosmos/cosmos-sdk/pull/20435) Fix LSM vulnerability. + ## v0.47.13-ics-lsm This is a special cosmos-sdk release with support for both ICS and LSM. diff --git a/docs/architecture/adr-061-liquid-staking.md b/docs/architecture/adr-061-liquid-staking.md index 556b05c80d3d..0fd8e363ca1f 100644 --- a/docs/architecture/adr-061-liquid-staking.md +++ b/docs/architecture/adr-061-liquid-staking.md @@ -404,6 +404,36 @@ func QueueTokenizeSharesAuthorization(address sdk.AccAddress) time.Time func RemoveExpiredTokenizeShareLocks(blockTime time.Time) (unlockedAddresses []string) ``` +### Tokenizing & Redelegations + +When a delegation is tokenized, the shares are transferred to a module account. This means that the shares are no longer owned by the delegator, and thus if these shares are from a redelegation, that redelegation needs to be updated to point to the module account; +otherwise, the redelegation would still point to the delegator account, which doesn't own the shares anymore. + +On a high-level, this is simple. +When a delegator D tokenizes some amount of shares delegated to validator V, we: +* Identify how many of the shares that are tokenized belong to redelegations +* Rewrite the redelegations to have the module account as delegator address (this is originally D's address) + +When a user redeems shares, we do the reverse operation: +* Identify again which of the shares being redeemed belong to redelegations +* Rewrite those redelegations to have the users' account as delegator address (this is originally the module account) + +In practice, there are several logistical challenges with this. First, it is nontrivial to figure out how many shares being tokenized come from redelegations. Note that if a user redelegates, their shares get immediately transferred to the new validator, but a redelegation entry is created to still keep them accountable for infractions that the old validator committed. + +Since the shares are transferred immediately, users may also undelegate the shares *that still have a redelegation ongoing underneath*. +These shares are removed immediately, and we obviously cannot tokenize these shares. +Thus, we must figure out how many shares that were transferred via redelegations *do not* have a subsequent undelegation, to know how many shares of the users correspond to redelegations and how many correspond to native delegations that don't have an ongoing redelegation. +See this figure for a brief overview of the algorithm: +![A rough example of the algorithm to identify how many redelegations are not matched by subsequent undelegations.](image/redelegations.png) + +The algorithm goes through existing redelegations (by D from any validator to V) and undelegations (by D from V) +in order of their completion time. We simply match redelegations with following undelegations, and we assume that undelegations always first affect the incoming tokens from redelegations before touching native stake. This is simply an ordering imposed by our algorithm, and reflects the fact that we can essentially choose which redelegations to rewrite/transfer with tokenizations (and in this case, we choose to not rewrite redelegations that are matched by a subsequent unbonding operation). (See the `ComputeRemainingRedelegatedSharesAfterUnbondings` function [here](../../x/staking/keeper/tokenize_share_record.go#373)) + +After identifying how many redelegations must be transferred to tokenize or redeem the desired amount of shares, +we simply get redelegations for that specified amount of shares and rewrite them. +One final challenge here is that redelegations are always for specific amounts, and this might be different than the amount we actually need to rewrite. For example, imagine we have a redelegation for 4 shares, but need to tokenize 3 shares that are backed by redelegations. Then we need to split the redelegation: we create one redelegation for 3 shares that we give the *rewritten* delegator address, and we create one redelegation for 1 share (which is otherwise identical to the original redelegation, i.e. it still points to the old delegator address). (See the `updateRedelegationEntriesByAmount` function [here](../../x/staking/keeper/tokenize_share_record.go#327)) + + ## References Please see this document for a technical spec for the LSM: https://docs.google.com/document/d/1WYPUHmQii4o-q2225D_XyqE6-1bvM7Q128Y9amqRwqY/edit#heading=h.zcpx47mn67kl \ No newline at end of file diff --git a/docs/architecture/image/redelegations.png b/docs/architecture/image/redelegations.png new file mode 100644 index 000000000000..fe29a1d0f26c Binary files /dev/null and b/docs/architecture/image/redelegations.png differ 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..b1c8683034cb 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,330 @@ 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. +// 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 +}