diff --git a/app/app.go b/app/app.go index 5f148535b..93076adc9 100644 --- a/app/app.go +++ b/app/app.go @@ -656,6 +656,7 @@ func NewStrideApp( app.AccountKeeper, app.BankKeeper, app.TransferKeeper, + app.RatelimitKeeper, ) stakeTiaModule := staketia.NewAppModule(appCodec, app.StaketiaKeeper) diff --git a/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds.go b/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds.go index aacaca508..04f3c8ab7 100644 --- a/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds.go +++ b/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds.go @@ -13,7 +13,7 @@ import ( func (k msgServer) UpdateInnerRedemptionRateBounds(goCtx context.Context, msg *types.MsgUpdateInnerRedemptionRateBounds) (*types.MsgUpdateInnerRedemptionRateBoundsResponse, error) { ctx := sdk.UnwrapSDKContext(goCtx) - // Confirm host zone exists + // Note: we're intentionally not checking the zone is halted zone, found := k.GetHostZone(ctx, msg.ChainId) if !found { k.Logger(ctx).Error(fmt.Sprintf("Host Zone not found: %s", msg.ChainId)) diff --git a/x/staketia/keeper/abci.go b/x/staketia/keeper/abci.go index a1b817e17..6e55a665c 100644 --- a/x/staketia/keeper/abci.go +++ b/x/staketia/keeper/abci.go @@ -1,5 +1,16 @@ package keeper -import sdk "github.com/cosmos/cosmos-sdk/types" +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) -func (k Keeper) BeginBlocker(ctx sdk.Context) {} +func (k Keeper) BeginBlocker(ctx sdk.Context) { + // Check invariants + + // Check redemption rate is within safety bounds + if err := k.CheckRedemptionRateExceedsBounds(ctx); err != nil { + k.Logger(ctx).Error(err.Error()) + // If not, halt the zone + k.HaltZone(ctx) + } +} diff --git a/x/staketia/keeper/delegation_test.go b/x/staketia/keeper/delegation_test.go index a9a7daaca..41db8f846 100644 --- a/x/staketia/keeper/delegation_test.go +++ b/x/staketia/keeper/delegation_test.go @@ -307,6 +307,11 @@ func (s *KeeperTestSuite) TestPrepareDelegation() { // It should not create a new record since there is nothing to delegate delegationRecords = s.App.StaketiaKeeper.GetAllActiveDelegationRecords(s.Ctx) s.Require().Equal(0, len(delegationRecords), "there should be no delegation records") + + // Halt zone + s.App.StaketiaKeeper.HaltZone(s.Ctx) + err = s.App.StaketiaKeeper.PrepareDelegation(s.Ctx, epochNumber, epochDuration) + s.Require().ErrorContains(err, "host zone is halted") } // ---------------------------------------------------- @@ -418,6 +423,9 @@ func (s *KeeperTestSuite) VerifyDelegationRecords(verifyIdentical bool, archiveI func (s *KeeperTestSuite) TestConfirmDelegation_Successful() { s.SetupDelegationRecords() + // we're halting the zone to test that the tx works even when the host zone is halted + s.App.StaketiaKeeper.HaltZone(s.Ctx) + // try setting valid delegation queue err := s.App.StaketiaKeeper.ConfirmDelegation(s.Ctx, 6, ValidTxHashNew, ValidOperator) s.Require().NoError(err) diff --git a/x/staketia/keeper/events.go b/x/staketia/keeper/events.go index 2159dca17..4173f2097 100644 --- a/x/staketia/keeper/events.go +++ b/x/staketia/keeper/events.go @@ -83,3 +83,14 @@ func EmitSuccessfulConfirmUnbondedTokenSweepEvent(ctx sdk.Context, recordId uint ), ) } + +// Emits an event indicating a zone was halted +func EmitHaltZoneEvent(ctx sdk.Context, hostZone types.HostZone) { + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeHostZoneHalt, + sdk.NewAttribute(types.AttributeKeyHostZone, hostZone.ChainId), + sdk.NewAttribute(types.AttributeKeyRedemptionRate, hostZone.RedemptionRate.String()), + ), + ) +} diff --git a/x/staketia/keeper/invariants.go b/x/staketia/keeper/invariants.go new file mode 100644 index 000000000..33eca7f9d --- /dev/null +++ b/x/staketia/keeper/invariants.go @@ -0,0 +1,30 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/Stride-Labs/stride/v17/utils" +) + +func (k Keeper) HaltZone(ctx sdk.Context) { + // Set the halted flag on the zone + hostZone, err := k.GetHostZone(ctx) + if err != nil { + // No panic - we don't want to halt the chain! Just the zone. + // log the error + k.Logger(ctx).Error(fmt.Sprintf("Unable to get host zone: %s", err.Error())) + return + } + hostZone.Halted = true + k.SetHostZone(ctx, hostZone) + + // set rate limit on stAsset + stDenom := utils.StAssetDenomFromHostZoneDenom(hostZone.NativeTokenDenom) + k.ratelimitKeeper.AddDenomToBlacklist(ctx, stDenom) + + k.Logger(ctx).Error(fmt.Sprintf("[INVARIANT BROKEN!!!] %s's RR is %s.", hostZone.GetChainId(), hostZone.RedemptionRate.String())) + + EmitHaltZoneEvent(ctx, hostZone) +} diff --git a/x/staketia/keeper/invariants_test.go b/x/staketia/keeper/invariants_test.go new file mode 100644 index 000000000..897bb832d --- /dev/null +++ b/x/staketia/keeper/invariants_test.go @@ -0,0 +1,24 @@ +package keeper_test + +import ( + "github.com/Stride-Labs/stride/v17/x/staketia/types" +) + +func (s *KeeperTestSuite) TestHaltZone() { + // Set a non-halted host zone + s.App.StaketiaKeeper.SetHostZone(s.Ctx, types.HostZone{ + NativeTokenDenom: HostNativeDenom, + Halted: false, + }) + + // Halt the zone + s.App.StaketiaKeeper.HaltZone(s.Ctx) + + // Confirm it's halted + hostZone := s.MustGetHostZone() + s.Require().True(hostZone.Halted, "host zone should be halted") + + // Confirm denom is blacklisted + isBlacklisted := s.App.RatelimitKeeper.IsDenomBlacklisted(s.Ctx, StDenom) + s.Require().True(isBlacklisted, "halt zone should blacklist the stAsset denom") +} diff --git a/x/staketia/keeper/keeper.go b/x/staketia/keeper/keeper.go index 6e698c14a..d41acd6a1 100644 --- a/x/staketia/keeper/keeper.go +++ b/x/staketia/keeper/keeper.go @@ -12,11 +12,12 @@ import ( ) type Keeper struct { - cdc codec.BinaryCodec - storeKey storetypes.StoreKey - accountKeeper types.AccountKeeper - bankKeeper types.BankKeeper - transferKeeper types.TransferKeeper + cdc codec.BinaryCodec + storeKey storetypes.StoreKey + accountKeeper types.AccountKeeper + bankKeeper types.BankKeeper + transferKeeper types.TransferKeeper + ratelimitKeeper types.RatelimitKeeper } func NewKeeper( @@ -25,13 +26,15 @@ func NewKeeper( accountKeeper types.AccountKeeper, bankKeeper types.BankKeeper, transferKeeper types.TransferKeeper, + ratelimitKeeper types.RatelimitKeeper, ) *Keeper { return &Keeper{ - cdc: cdc, - storeKey: storeKey, - accountKeeper: accountKeeper, - bankKeeper: bankKeeper, - transferKeeper: transferKeeper, + cdc: cdc, + storeKey: storeKey, + accountKeeper: accountKeeper, + bankKeeper: bankKeeper, + transferKeeper: transferKeeper, + ratelimitKeeper: ratelimitKeeper, } } diff --git a/x/staketia/keeper/msg_server.go b/x/staketia/keeper/msg_server.go index c92701e64..5489a1d72 100644 --- a/x/staketia/keeper/msg_server.go +++ b/x/staketia/keeper/msg_server.go @@ -106,6 +106,7 @@ func (k msgServer) AdjustDelegatedBalance(goCtx context.Context, msg *types.MsgA } // add offset to the delegated balance and write to host zone + // Note: we're intentionally not checking the zone is halted hostZone, err := k.GetHostZone(ctx) if err != nil { return nil, err @@ -180,7 +181,7 @@ func (k msgServer) ResumeHostZone(goCtx context.Context, msg *types.MsgResumeHos return nil, types.ErrInvalidAdmin } - // Get Host Zone + // Note: of course we don't want to fail this if the zone is halted! zone, err := k.GetHostZone(ctx) if err != nil { return nil, err @@ -191,9 +192,8 @@ func (k msgServer) ResumeHostZone(goCtx context.Context, msg *types.MsgResumeHos return nil, errorsmod.Wrapf(types.ErrHostZoneNotHalted, "zone is not halted") } - // TODO [sttia]: remove from blacklist - // stDenom := types.StAssetDenomFromHostZoneDenom(hostZone.HostDenom) - // k.RatelimitKeeper.RemoveDenomFromBlacklist(ctx, stDenom) + stDenom := utils.StAssetDenomFromHostZoneDenom(zone.NativeTokenDenom) + k.ratelimitKeeper.RemoveDenomFromBlacklist(ctx, stDenom) // Resume zone zone.Halted = false @@ -269,6 +269,7 @@ func (k msgServer) SetOperatorAddress(goCtx context.Context, msg *types.MsgSetOp } // Fetch the zone + // Note: we're intentionally not checking the zone is halted zone, err := k.GetHostZone(ctx) if err != nil { return nil, err diff --git a/x/staketia/keeper/msg_server_test.go b/x/staketia/keeper/msg_server_test.go index 6262a0501..ab6e3cb01 100644 --- a/x/staketia/keeper/msg_server_test.go +++ b/x/staketia/keeper/msg_server_test.go @@ -179,6 +179,7 @@ func (s *KeeperTestSuite) TestConfirmUnbondingTokenSweep() { // ---------------------------------------------- func (s *KeeperTestSuite) TestAdjustDelegatedBalance() { + safeAddress := "safe" // Create the host zone @@ -187,6 +188,9 @@ func (s *KeeperTestSuite) TestAdjustDelegatedBalance() { DelegatedBalance: sdk.NewInt(0), }) + // we're halting the zone to test that the tx works even when the host zone is halted + s.App.StaketiaKeeper.HaltZone(s.Ctx) + // Call adjust for each test case and confirm the ending delegation testCases := []struct { address string @@ -229,6 +233,7 @@ func (s *KeeperTestSuite) TestAdjustDelegatedBalance() { s.App.StaketiaKeeper.RemoveHostZone(s.Ctx) _, err = s.GetMsgServer().AdjustDelegatedBalance(s.Ctx, &types.MsgAdjustDelegatedBalance{}) s.Require().ErrorContains(err, "host zone not found") + } // ---------------------------------------------- @@ -249,6 +254,8 @@ func (s *KeeperTestSuite) TestUpdateInnerRedemptionRateBounds() { } s.App.StaketiaKeeper.SetHostZone(s.Ctx, zone) + // we're halting the zone to test that the tx works even when the host zone is halted + s.App.StaketiaKeeper.HaltZone(s.Ctx) initialMsg := types.MsgUpdateInnerRedemptionRateBounds{ Creator: adminAddress, @@ -315,9 +322,13 @@ func (s *KeeperTestSuite) TestResumeHostZone() { s.Require().True(ok) zone := types.HostZone{ - Halted: false, + ChainId: HostChainId, + RedemptionRate: sdk.NewDec(1), + Halted: false, + NativeTokenDenom: HostNativeDenom, } s.App.StaketiaKeeper.SetHostZone(s.Ctx, zone) + msg := types.MsgResumeHostZone{ Creator: adminAddress, } @@ -327,6 +338,10 @@ func (s *KeeperTestSuite) TestResumeHostZone() { _, err := s.GetMsgServer().ResumeHostZone(s.Ctx, &msg) s.Require().ErrorContains(err, "zone is not halted") + // Verify the denom is not in the blacklist + blacklist := s.App.RatelimitKeeper.GetAllBlacklistedDenoms(s.Ctx) + s.Require().NotContains(blacklist, StDenom, "denom should not be blacklisted") + // Confirm the zone is not halted zone, err = s.App.StaketiaKeeper.GetHostZone(s.Ctx) s.Require().NoError(err, "should not throw an error") @@ -334,8 +349,11 @@ func (s *KeeperTestSuite) TestResumeHostZone() { // TEST 2: Zone is halted // Halt the zone - zone.Halted = true - s.App.StaketiaKeeper.SetHostZone(s.Ctx, zone) + s.App.StaketiaKeeper.HaltZone(s.Ctx) + + // Verify the denom is in the blacklist + blacklist = s.App.RatelimitKeeper.GetAllBlacklistedDenoms(s.Ctx) + s.Require().Contains(blacklist, StDenom, "denom should be blacklisted") // Try to unhalt the halted zone _, err = s.GetMsgServer().ResumeHostZone(s.Ctx, &msg) @@ -346,6 +364,10 @@ func (s *KeeperTestSuite) TestResumeHostZone() { s.Require().NoError(err, "should not throw an error") s.Require().False(zone.Halted, "zone should not be halted") + // Verify the denom is not in the blacklist + blacklist = s.App.RatelimitKeeper.GetAllBlacklistedDenoms(s.Ctx) + s.Require().NotContains(blacklist, StDenom, "denom should not be blacklisted") + // Attempt to resume with a non-admin address, it should fail _, err = s.GetMsgServer().ResumeHostZone(s.Ctx, &types.MsgResumeHostZone{ Creator: "non-admin", diff --git a/x/staketia/keeper/redemption_rate.go b/x/staketia/keeper/redemption_rate.go index e279ef3a9..afd7ccb2f 100644 --- a/x/staketia/keeper/redemption_rate.go +++ b/x/staketia/keeper/redemption_rate.go @@ -74,6 +74,7 @@ func (k Keeper) UpdateRedemptionRate(ctx sdk.Context) error { } // Checks whether the redemption rate has exceeded the inner or outer safety bounds +// and returns an error if so func (k Keeper) CheckRedemptionRateExceedsBounds(ctx sdk.Context) error { hostZone, err := k.GetHostZone(ctx) if err != nil { diff --git a/x/staketia/keeper/unbonding.go b/x/staketia/keeper/unbonding.go index a4079db32..ae24d8da5 100644 --- a/x/staketia/keeper/unbonding.go +++ b/x/staketia/keeper/unbonding.go @@ -173,6 +173,7 @@ func (k Keeper) ConfirmUndelegation(ctx sdk.Context, recordId uint64, txHash str return errorsmod.Wrapf(types.ErrInvalidUnbondingRecord, "unbonding record with id: %d has no tokens to unbond (or negative)", recordId) } + // Note: we're intentionally not checking that the host zone is halted, because we still want to process this tx in that case hostZone, err := k.GetHostZone(ctx) if err != nil { return err diff --git a/x/staketia/keeper/unbonding_test.go b/x/staketia/keeper/unbonding_test.go index 29f21dde2..c7a5f2dd0 100644 --- a/x/staketia/keeper/unbonding_test.go +++ b/x/staketia/keeper/unbonding_test.go @@ -493,11 +493,13 @@ func (s *KeeperTestSuite) SetupTestConfirmUndelegation(amountToUndelegate sdkmat return tc } -// unit test ConfirmUndelegation func (s *KeeperTestSuite) TestConfirmUndelegation_Success() { amountToUndelegate := sdkmath.NewInt(100) tc := s.SetupTestConfirmUndelegation(amountToUndelegate) + // we're halting the zone to test that the tx works even when the host zone is halted + s.App.StaketiaKeeper.HaltZone(s.Ctx) + // confirm the tx was successful err := s.App.StaketiaKeeper.ConfirmUndelegation(s.Ctx, tc.unbondingRecord.Id, ValidTxHashDefault, tc.operatorAddress) s.Require().NoError(err) @@ -868,6 +870,9 @@ func (s *KeeperTestSuite) VerifyUnbondingRecordsAfterConfirmSweep(verifyUpdatedF func (s *KeeperTestSuite) TestConfirmUnbondingTokenSweep_Successful() { s.SetupTestConfirmUnbondingTokens(DefaultClaimFundingAmount) + // we're halting the zone to test that the tx works even when the host zone is halted + s.App.StaketiaKeeper.HaltZone(s.Ctx) + // process record 6 err := s.App.StaketiaKeeper.ConfirmUnbondedTokenSweep(s.Ctx, 6, ValidTxHashNew, ValidOperator) s.Require().NoError(err) @@ -889,6 +894,7 @@ func (s *KeeperTestSuite) TestConfirmUnbondingTokenSweep_Successful() { s.Require().True(found) s.Require().Equal(types.CLAIMABLE, loadedUnbondingRecord.Status, "unbonding record should be updated to status CLAIMABLE") s.Require().Equal(ValidTxHashNew, loadedUnbondingRecord.UnbondedTokenSweepTxHash, "unbonding record should be updated with token sweep txHash") + } func (s *KeeperTestSuite) TestConfirmUnbondingTokenSweep_NotFunded() { diff --git a/x/staketia/types/events.go b/x/staketia/types/events.go index 52a0e357e..d48ea87c8 100644 --- a/x/staketia/types/events.go +++ b/x/staketia/types/events.go @@ -9,6 +9,7 @@ const ( EventTypeConfirmUndelegation = "confirm_undelegation" AttributeKeyHostZone = "host_zone" + AttributeKeyRedemptionRate = "redemption_rate" AttributeKeyLiquidStaker = "liquid_staker" AttributeKeyRedeemer = "redeemer" AttributeKeyNativeBaseDenom = "native_base_denom" @@ -20,4 +21,5 @@ const ( AttributeUndelegationNativeAmount = "undelegation_native_amount" AttributeTxHash = "tx_hash" AttributeSender = "sender" + EventTypeHostZoneHalt = "host_zone_halt" ) diff --git a/x/staketia/types/expected_keepers.go b/x/staketia/types/expected_keepers.go index 2dec994bb..3947b2bc2 100644 --- a/x/staketia/types/expected_keepers.go +++ b/x/staketia/types/expected_keepers.go @@ -8,6 +8,13 @@ import ( transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" ) +// Required AccountKeeper functions +type AccountKeeper interface { + NewAccount(sdk.Context, authtypes.AccountI) authtypes.AccountI + GetAccount(ctx sdk.Context, addr sdk.AccAddress) authtypes.AccountI + SetAccount(ctx sdk.Context, acc authtypes.AccountI) +} + // Required BankKeeper functions type BankKeeper interface { MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error @@ -24,9 +31,8 @@ type TransferKeeper interface { Transfer(goCtx context.Context, msg *transfertypes.MsgTransfer) (*transfertypes.MsgTransferResponse, error) } -// Required AccountKeeper functions -type AccountKeeper interface { - NewAccount(sdk.Context, authtypes.AccountI) authtypes.AccountI - GetAccount(ctx sdk.Context, addr sdk.AccAddress) authtypes.AccountI - SetAccount(ctx sdk.Context, acc authtypes.AccountI) +// Required RatelimitKeeper functions +type RatelimitKeeper interface { + AddDenomToBlacklist(ctx sdk.Context, denom string) + RemoveDenomFromBlacklist(ctx sdk.Context, denom string) }