diff --git a/CHANGELOG.md b/CHANGELOG.md index 9907b5bfa497..cab85e6a1d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Improvements +* (staking) [#20444](https://github.com/cosmos/cosmos-sdk/pull/20444) Disable tokenization of shares from redelegations. * (x/bank) [#18956](https://github.com/cosmos/cosmos-sdk/pull/18956) Introduced a new `DenomOwnersByQuery` query method for `DenomOwners`, which accepts the denom value as a query string parameter, resolving issues with denoms containing slashes. * (x/gov) [#18707](https://github.com/cosmos/cosmos-sdk/pull/18707) Improve genesis validation. * (x/auth/tx) [#18772](https://github.com/cosmos/cosmos-sdk/pull/18772) Remove misleading gas wanted from tx simulation failure log. diff --git a/tests/integration/staking/keeper/msg_server_test.go b/tests/integration/staking/keeper/msg_server_test.go index 10282d8f0f3e..1609ae0b7989 100644 --- a/tests/integration/staking/keeper/msg_server_test.go +++ b/tests/integration/staking/keeper/msg_server_test.go @@ -775,6 +775,105 @@ func TestTokenizeSharesAndRedeemTokens(t *testing.T) { } } +func TestRedelegationTokenization(t *testing.T) { + // Test that a delegator with ongoing redelegation cannot + // tokenize any shares until the redelegation is complete. + f := initFixture(t) + + ctx := f.sdkCtx + var ( + stakingKeeper = f.stakingKeeper + bankKeeper = f.bankKeeper + ) + msgServer := keeper.NewMsgServerImpl(stakingKeeper) + validators, err := stakingKeeper.GetAllValidators(ctx) + require.NoError(t, err) + validatorA := validators[0] + validatorAAddress := validatorA.GetOperator() + _, validatorBAddress := setupTestTokenizeAndRedeemConversion(t, *stakingKeeper, bankKeeper, ctx) + + addrs := simtestutil.AddTestAddrs(bankKeeper, stakingKeeper, ctx, 2, stakingKeeper.TokensFromConsensusPower(ctx, 10000)) + alice := addrs[0] + + delegateAmount := sdk.TokensFromConsensusPower(10, sdk.DefaultPowerReduction) + bondedDenom, err := stakingKeeper.BondDenom(ctx) + require.NoError(t, err) + delegateCoin := sdk.NewCoin(bondedDenom, delegateAmount) + + // Alice delegates to validatorA + _, err = msgServer.Delegate(sdk.WrapSDKContext(ctx), &types.MsgDelegate{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorAAddress, + Amount: delegateCoin, + }) + + // Alice redelegates to validatorB + redelegateAmount := sdk.TokensFromConsensusPower(5, sdk.DefaultPowerReduction) + redelegateCoin := sdk.NewCoin(bondedDenom, redelegateAmount) + _, err = msgServer.BeginRedelegate(sdk.WrapSDKContext(ctx), &types.MsgBeginRedelegate{ + DelegatorAddress: alice.String(), + ValidatorSrcAddress: validatorAAddress, + ValidatorDstAddress: validatorBAddress.String(), + Amount: redelegateCoin, + }) + require.NoError(t, err) + + redelegation, err := stakingKeeper.GetRedelegations(ctx, alice, uint16(10)) + require.NoError(t, err) + require.Len(t, redelegation, 1, "expect one redelegation") + require.Len(t, redelegation[0].Entries, 1, "expect one redelegation entry") + + // Alice attempts to tokenize the redelegation, but this fails because the redelegation is ongoing + tokenizedAmount := sdk.TokensFromConsensusPower(5, sdk.DefaultPowerReduction) + tokenizedCoin := sdk.NewCoin(bondedDenom, tokenizedAmount) + _, err = msgServer.TokenizeShares(sdk.WrapSDKContext(ctx), &types.MsgTokenizeShares{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorBAddress.String(), + Amount: tokenizedCoin, + TokenizedShareOwner: alice.String(), + }) + require.Error(t, err) + require.Equal(t, types.ErrRedelegationInProgress, err) + + // Check that the redelegation is still present + redelegation, err = stakingKeeper.GetRedelegations(ctx, alice, uint16(10)) + require.NoError(t, err) + 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 + f.stakingKeeper.EndBlocker(f.sdkCtx) + ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 1) + // advance by 22 days + ctx = ctx.WithBlockTime(ctx.BlockTime().Add(22 * 24 * time.Hour)) + // begin block + f.stakingKeeper.BeginBlocker(f.sdkCtx) + // end block + f.stakingKeeper.EndBlocker(f.sdkCtx) + + // check that the redelegation is removed + redelegation, err = stakingKeeper.GetRedelegations(ctx, alice, uint16(10)) + require.NoError(t, err) + require.Len(t, redelegation, 0, "expect no redelegations") + + // Alice attempts to tokenize the redelegation again, and this time it should succeed + // because there is no ongoing redelegation + _, err = msgServer.TokenizeShares(sdk.WrapSDKContext(ctx), &types.MsgTokenizeShares{ + DelegatorAddress: alice.String(), + ValidatorAddress: validatorBAddress.String(), + Amount: tokenizedCoin, + TokenizedShareOwner: alice.String(), + }) + require.NoError(t, err) + + // Check that the tokenization was successful + shareRecord, err := stakingKeeper.GetTokenizeShareRecord(ctx, stakingKeeper.GetLastTokenizeShareRecordID(ctx)) + require.NoError(t, err, "expect to find token share record") + require.Equal(t, alice.String(), shareRecord.Owner) + require.Equal(t, validatorBAddress.String(), shareRecord.Validator) +} + // Helper function to setup a delegator and validator for the Tokenize/Redeem conversion tests func setupTestTokenizeAndRedeemConversion( t *testing.T, diff --git a/x/staking/keeper/msg_server.go b/x/staking/keeper/msg_server.go index d1c547c60fbf..99bc2dc47175 100644 --- a/x/staking/keeper/msg_server.go +++ b/x/staking/keeper/msg_server.go @@ -825,6 +825,15 @@ func (k msgServer) TokenizeShares(goCtx context.Context, msg *types.MsgTokenizeS return nil, err } + // Check that the delegator has no ongoing redelegations to the validator + found, err := k.HasReceivingRedelegation(ctx, delegatorAddress, valAddr) + if err != nil { + return nil, err + } + if found { + return nil, types.ErrRedelegationInProgress + } + // If this tokenization is NOT from a liquid staking provider, // confirm it does not exceed the global and validator liquid staking cap // If the tokenization is from a liquid staking provider, diff --git a/x/staking/simulation/operations.go b/x/staking/simulation/operations.go index c17d6931d33c..8b23ee7bfe03 100644 --- a/x/staking/simulation/operations.go +++ b/x/staking/simulation/operations.go @@ -951,6 +951,15 @@ func SimulateMsgTokenizeShares(txGen client.TxConfig, ak types.AccountKeeper, bk return simtypes.NoOpMsg(types.ModuleName, msgType, "tokenize shares disabled"), nil, nil } + // Make sure that the delegator has no ongoing redelegations to the validator + found, err := k.HasReceivingRedelegation(ctx, delAddrBz, valAddr) + if err != nil { + return simtypes.NoOpMsg(types.ModuleName, msgType, "error checking receiving redelegation"), nil, err + } + if found { + return simtypes.NoOpMsg(types.ModuleName, msgType, "delegator has redelegations in progress"), nil, nil + } + // get random destination validator totalBond := validator.TokensFromShares(delegation.GetShares()).TruncateInt() if !totalBond.IsPositive() { diff --git a/x/staking/types/errors.go b/x/staking/types/errors.go index df69e07447f9..649aec33ecfe 100644 --- a/x/staking/types/errors.go +++ b/x/staking/types/errors.go @@ -67,4 +67,5 @@ var ( ErrValidatorLiquidSharesUnderflow = errors.Register(ModuleName, 117, "validator liquid shares underflow") ErrTotalLiquidStakedUnderflow = errors.Register(ModuleName, 118, "total liquid staked underflow") ErrTinyRedemptionAmount = errors.Register(ModuleName, 119, "too few tokens to redeem (truncates to zero tokens)") + ErrRedelegationInProgress = errors.Register(ModuleName, 120, "delegator is not allowed to tokenize shares from validator with a redelegation in progress") )