diff --git a/CHANGELOG.md b/CHANGELOG.md index 56353fb0e624..3d7c94b1d64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ Ref: https://keepachangelog.com/en/1.0.0/ # Changelog +## [Unreleased] + +### Improvements + +* (staking) [#20444](https://github.com/cosmos/cosmos-sdk/pull/20444) Disable tokenization of shares from redelegations. + ## v0.47.13-ics-lsm This is a special cosmos-sdk release with support for both ICS and LSM. @@ -49,7 +55,6 @@ This is a special cosmos-sdk release with support for both ICS and LSM. * (crypto) [#20073](https://github.com/cosmos/cosmos-sdk/pull/20073) Add secp256r1 parsing support (backport from main) - ## v0.47.11-ics-lsm This is a special cosmos-sdk release with support for both ICS and LSM. diff --git a/tests/integration/staking/keeper/msg_server_test.go b/tests/integration/staking/keeper/msg_server_test.go index 4c7336c2f52c..5a622ab1b749 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,93 @@ func createICAAccount(ctx sdk.Context, ak accountkeeper.AccountKeeper) sdk.AccAd return icaAddress } + +func TestRedelegationTokenization(t *testing.T) { + // Test that a delegator with ongoing redelegation cannot + // tokenize any shares until the redelegation is complete. + _, 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] + + 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 attempts to tokenize the redelegation, but this fails because the redelegation is ongoing + 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.Error(t, err) + require.Equal(t, types.ErrRedelegationInProgress, err) + + // Check that the redelegation is still present + 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") + + // 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 the redelegation is removed + redelegation = stakingKeeper.GetRedelegations(ctx, alice, uint16(10)) + 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) +} diff --git a/x/staking/keeper/msg_server.go b/x/staking/keeper/msg_server.go index a88af57ec750..244ca25c8d85 100644 --- a/x/staking/keeper/msg_server.go +++ b/x/staking/keeper/msg_server.go @@ -716,6 +716,11 @@ func (k msgServer) TokenizeShares(goCtx context.Context, msg *types.MsgTokenizeS return nil, err } + // Check that the delegator has no ongoing redelegations to the validator + if k.HasReceivingRedelegation(ctx, delegatorAddress, valAddr) { + 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 6522b70450cf..febaa8962643 100644 --- a/x/staking/simulation/operations.go +++ b/x/staking/simulation/operations.go @@ -773,6 +773,11 @@ func SimulateMsgTokenizeShares(ak types.AccountKeeper, bk types.BankKeeper, k *k return simtypes.NoOpMsg(types.ModuleName, types.TypeMsgTokenizeShares, "tokenize shares disabled"), nil, nil } + // Make sure that the delegator has no ongoing redelegations to the validator + if k.HasReceivingRedelegation(ctx, delAddr, srcAddr) { + return simtypes.NoOpMsg(types.ModuleName, types.TypeMsgTokenizeShares, "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 a684f5898af3..7cb6d7c2777b 100644 --- a/x/staking/types/errors.go +++ b/x/staking/types/errors.go @@ -71,4 +71,5 @@ var ( ErrValidatorLiquidSharesUnderflow = sdkerrors.Register(ModuleName, 117, "validator liquid shares underflow") ErrTotalLiquidStakedUnderflow = sdkerrors.Register(ModuleName, 118, "total liquid staked underflow") ErrTinyRedemptionAmount = sdkerrors.Register(ModuleName, 119, "too few tokens to redeem (truncates to zero tokens)") + ErrRedelegationInProgress = sdkerrors.Register(ModuleName, 120, "delegator is not allowed to tokenize shares from validator with a redelegation in progress") )