diff --git a/x/stakeibc/keeper/interchainaccounts.go b/x/stakeibc/keeper/interchainaccounts.go index 36ebfdcfef..76aa22e3a9 100644 --- a/x/stakeibc/keeper/interchainaccounts.go +++ b/x/stakeibc/keeper/interchainaccounts.go @@ -4,22 +4,19 @@ import ( "fmt" errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + distributiontypes "github.com/cosmos/cosmos-sdk/x/distribution/types" "github.com/cosmos/gogoproto/proto" + icacontrollerkeeper "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/controller/keeper" + icacontrollertypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/controller/types" + icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" + connectiontypes "github.com/cosmos/ibc-go/v7/modules/core/03-connection/types" "github.com/Stride-Labs/stride/v18/utils" + epochstypes "github.com/Stride-Labs/stride/v18/x/epochs/types" icacallbackstypes "github.com/Stride-Labs/stride/v18/x/icacallbacks/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - distributiontypes "github.com/cosmos/cosmos-sdk/x/distribution/types" - - epochstypes "github.com/Stride-Labs/stride/v18/x/epochs/types" - - sdk "github.com/cosmos/cosmos-sdk/types" - icacontrollerkeeper "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/controller/keeper" - icacontrollertypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/controller/types" - icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" ) const ( @@ -262,3 +259,66 @@ func (k Keeper) SubmitICATxWithoutCallback( return nil } + +// Registers a new TradeRoute ICAAccount, given the type +// Stores down the connection and chainId now, and the address upon callback +func (k Keeper) RegisterTradeRouteICAAccount( + ctx sdk.Context, + tradeRouteId string, + connectionId string, + icaAccountType types.ICAAccountType, +) (account types.ICAAccount, err error) { + // Get the chain ID and counterparty connection-id from the connection ID on Stride + chainId, err := k.GetChainIdFromConnectionId(ctx, connectionId) + if err != nil { + return account, err + } + connection, found := k.IBCKeeper.ConnectionKeeper.GetConnection(ctx, connectionId) + if !found { + return account, errorsmod.Wrap(connectiontypes.ErrConnectionNotFound, connectionId) + } + counterpartyConnectionId := connection.Counterparty.ConnectionId + + // Build the appVersion, owner, and portId needed for registration + appVersion := string(icatypes.ModuleCdc.MustMarshalJSON(&icatypes.Metadata{ + Version: icatypes.Version, + ControllerConnectionId: connectionId, + HostConnectionId: counterpartyConnectionId, + Encoding: icatypes.EncodingProtobuf, + TxType: icatypes.TxTypeSDKMultiMsg, + })) + owner := types.FormatTradeRouteICAOwnerFromRouteId(chainId, tradeRouteId, icaAccountType) + portID, err := icatypes.NewControllerPortID(owner) + if err != nil { + return account, err + } + + // Create the associate ICAAccount object + account = types.ICAAccount{ + ChainId: chainId, + Type: icaAccountType, + ConnectionId: connectionId, + } + + // Check if an ICA account has already been created + // (in the event that this trade route was removed and then added back) + // If so, there's no need to register a new ICA + _, channelFound := k.ICAControllerKeeper.GetOpenActiveChannel(ctx, connectionId, portID) + icaAddress, icaFound := k.ICAControllerKeeper.GetInterchainAccountAddress(ctx, connectionId, portID) + if channelFound && icaFound { + account = types.ICAAccount{ + ChainId: chainId, + Type: icaAccountType, + ConnectionId: connectionId, + Address: icaAddress, + } + return account, nil + } + + // Otherwise, if there's no account already, register a new one + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, connectionId, owner, appVersion); err != nil { + return account, err + } + + return account, nil +} diff --git a/x/stakeibc/keeper/msg_server_claim_accrued_staking_rewards_on_host_test.go b/x/stakeibc/keeper/interchainaccounts_test.go similarity index 100% rename from x/stakeibc/keeper/msg_server_claim_accrued_staking_rewards_on_host_test.go rename to x/stakeibc/keeper/interchainaccounts_test.go diff --git a/x/stakeibc/keeper/lsm.go b/x/stakeibc/keeper/lsm.go index 855404fcd2..e62ddc941d 100644 --- a/x/stakeibc/keeper/lsm.go +++ b/x/stakeibc/keeper/lsm.go @@ -16,6 +16,8 @@ import ( transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types" + icqtypes "github.com/Stride-Labs/stride/v18/x/interchainquery/types" + recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" "github.com/Stride-Labs/stride/v18/x/stakeibc/types" ) @@ -238,6 +240,138 @@ func (k Keeper) ShouldCheckIfValidatorWasSlashed( return oldInterval.LT(newInterval) } +// StartLSMLiquidStake runs the transactional logic that occurs before the optional query +// This includes validation on the LSM Token and the stToken amount calculation +func (k Keeper) StartLSMLiquidStake(ctx sdk.Context, msg types.MsgLSMLiquidStake) (types.LSMLiquidStake, error) { + // Validate the provided message parameters - including the denom and staker balance + lsmLiquidStake, err := k.ValidateLSMLiquidStake(ctx, msg) + if err != nil { + return types.LSMLiquidStake{}, err + } + hostZone := lsmLiquidStake.HostZone + + if hostZone.Halted { + return types.LSMLiquidStake{}, errorsmod.Wrapf(types.ErrHaltedHostZone, "host zone %s is halted", hostZone.ChainId) + } + + // Check if we already have tokens with this denom in records + _, found := k.RecordsKeeper.GetLSMTokenDeposit(ctx, hostZone.ChainId, lsmLiquidStake.Deposit.Denom) + if found { + return types.LSMLiquidStake{}, errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, + "there is already a previous record with this denom being processed: %s", lsmLiquidStake.Deposit.Denom) + } + + // Determine the amount of stTokens to mint using the redemption rate and the validator's sharesToTokens rate + // StTokens = LSMTokenShares * Validator SharesToTokens Rate / Redemption Rate + // Note: in the event of a slash query, these tokens will be minted only if the + // validator's sharesToTokens rate did not change + stCoin := k.CalculateLSMStToken(msg.Amount, lsmLiquidStake) + if stCoin.Amount.IsZero() { + return types.LSMLiquidStake{}, errorsmod.Wrapf(types.ErrInsufficientLiquidStake, + "Liquid stake of %s%s would return 0 stTokens", msg.Amount.String(), hostZone.HostDenom) + } + + // Add the stToken to this deposit record + lsmLiquidStake.Deposit.StToken = stCoin + k.RecordsKeeper.SetLSMTokenDeposit(ctx, *lsmLiquidStake.Deposit) + + return lsmLiquidStake, nil +} + +// SubmitValidatorSlashQuery submits an interchain query for the validator's sharesToTokens rate +// This is done periodically at checkpoints denominated in native tokens +// (e.g. every 100k ATOM that's LSM liquid staked with validator X) +func (k Keeper) SubmitValidatorSlashQuery(ctx sdk.Context, lsmLiquidStake types.LSMLiquidStake) error { + chainId := lsmLiquidStake.HostZone.ChainId + validatorAddress := lsmLiquidStake.Validator.Address + timeoutDuration := LSMSlashQueryTimeout + timeoutPolicy := icqtypes.TimeoutPolicy_EXECUTE_QUERY_CALLBACK + + // Build and serialize the callback data required to complete the LSM Liquid stake upon query callback + callbackData := types.ValidatorSharesToTokensQueryCallback{ + LsmLiquidStake: &lsmLiquidStake, + } + callbackDataBz, err := proto.Marshal(&callbackData) + if err != nil { + return errorsmod.Wrapf(err, "unable to serialize LSMLiquidStake struct for validator sharesToTokens rate query callback") + } + + return k.SubmitValidatorSharesToTokensRateICQ(ctx, chainId, validatorAddress, callbackDataBz, timeoutDuration, timeoutPolicy) +} + +// FinishLSMLiquidStake finishes the liquid staking flow by escrowing the LSM token, +// sending a user their stToken, and then IBC transfering the LSM Token to the host zone +// +// If the slash query interrupted the transaction, this function is called +// asynchronously after the query callback +// +// If no slash query was needed, this is called synchronously after StartLSMLiquidStake +// If this is run asynchronously, we need to re-validate the transaction info (e.g. staker's balance) +func (k Keeper) FinishLSMLiquidStake(ctx sdk.Context, lsmLiquidStake types.LSMLiquidStake, async bool) error { + hostZone := lsmLiquidStake.HostZone + lsmTokenDeposit := *lsmLiquidStake.Deposit + + // If the transaction was interrupted by the slash query, + // validate the LSM Liquid stake message parameters again + // The most significant check here is that the user still has sufficient balance for this LSM liquid stake + if async { + lsmLiquidStakeMsg := types.MsgLSMLiquidStake{ + Creator: lsmTokenDeposit.StakerAddress, + LsmTokenIbcDenom: lsmTokenDeposit.IbcDenom, + Amount: lsmTokenDeposit.Amount, + } + if _, err := k.ValidateLSMLiquidStake(ctx, lsmLiquidStakeMsg); err != nil { + return err + } + } + + // Get the staker's address and the host zone's deposit account address (which will custody the tokens) + liquidStakerAddress := sdk.MustAccAddressFromBech32(lsmTokenDeposit.StakerAddress) + hostZoneDepositAddress, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) + if err != nil { + return errorsmod.Wrapf(err, "host zone address is invalid") + } + + // Transfer the LSM token to the deposit account + lsmIBCToken := sdk.NewCoin(lsmTokenDeposit.IbcDenom, lsmTokenDeposit.Amount) + if err := k.bankKeeper.SendCoins(ctx, liquidStakerAddress, hostZoneDepositAddress, sdk.NewCoins(lsmIBCToken)); err != nil { + return errorsmod.Wrap(err, "failed to send tokens from Account to Module") + } + + // Mint stToken and send to the user + stToken := sdk.NewCoins(lsmTokenDeposit.StToken) + if err := k.bankKeeper.MintCoins(ctx, types.ModuleName, stToken); err != nil { + return errorsmod.Wrapf(err, "Failed to mint stTokens") + } + if err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, liquidStakerAddress, stToken); err != nil { + return errorsmod.Wrapf(err, "Failed to send %s from module to account", lsmTokenDeposit.StToken.String()) + } + + // Get delegation account address as the destination for the LSM Token + if hostZone.DelegationIcaAddress == "" { + return errorsmod.Wrapf(types.ErrICAAccountNotFound, "no delegation address found for %s", hostZone.ChainId) + } + + // Update the deposit status + k.RecordsKeeper.UpdateLSMTokenDepositStatus(ctx, lsmTokenDeposit, recordstypes.LSMTokenDeposit_TRANSFER_QUEUE) + + // Update the slash query progress on the validator + if err := k.IncrementValidatorSlashQueryProgress( + ctx, + hostZone.ChainId, + lsmTokenDeposit.ValidatorAddress, + lsmTokenDeposit.Amount, + ); err != nil { + return err + } + + // Emit an LSM liquid stake event + EmitSuccessfulLSMLiquidStakeEvent(ctx, *hostZone, lsmTokenDeposit) + + k.hooks.AfterLiquidStake(ctx, liquidStakerAddress) + return nil +} + // Loops through all active host zones, grabs queued LSMTokenDeposits for that host // that are in status TRANSFER_QUEUE, and submits the IBC Transfer to the host func (k Keeper) TransferAllLSMDeposits(ctx sdk.Context) { diff --git a/x/stakeibc/keeper/msg_server.go b/x/stakeibc/keeper/msg_server.go index 7a7a489d99..8694f302ab 100644 --- a/x/stakeibc/keeper/msg_server.go +++ b/x/stakeibc/keeper/msg_server.go @@ -1,9 +1,35 @@ package keeper import ( + "context" + "fmt" + + errorsmod "cosmossdk.io/errors" + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + proto "github.com/cosmos/gogoproto/proto" + icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" + ibctransfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" + connectiontypes "github.com/cosmos/ibc-go/v7/modules/core/03-connection/types" + "github.com/spf13/cast" + + "github.com/Stride-Labs/stride/v18/utils" + epochtypes "github.com/Stride-Labs/stride/v18/x/epochs/types" + recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" + recordtypes "github.com/Stride-Labs/stride/v18/x/records/types" "github.com/Stride-Labs/stride/v18/x/stakeibc/types" ) +var ( + CommunityPoolStakeHoldingAddressKey = "community-pool-stake" + CommunityPoolRedeemHoldingAddressKey = "community-pool-redeem" + + DefaultMaxAllowedSwapLossRate = "0.05" + DefaultMaxSwapAmount = sdkmath.NewIntWithDecimal(10, 24) // 10e24 +) + type msgServer struct { Keeper } @@ -15,3 +41,1065 @@ func NewMsgServerImpl(keeper Keeper) types.MsgServer { } var _ types.MsgServer = msgServer{} + +func (k msgServer) RegisterHostZone(goCtx context.Context, msg *types.MsgRegisterHostZone) (*types.MsgRegisterHostZoneResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + // Get ConnectionEnd (for counterparty connection) + connectionEnd, found := k.IBCKeeper.ConnectionKeeper.GetConnection(ctx, msg.ConnectionId) + if !found { + errMsg := fmt.Sprintf("invalid connection id, %s not found", msg.ConnectionId) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + counterpartyConnection := connectionEnd.Counterparty + + // Get chain id from connection + chainId, err := k.GetChainIdFromConnectionId(ctx, msg.ConnectionId) + if err != nil { + errMsg := fmt.Sprintf("unable to obtain chain id from connection %s, err: %s", msg.ConnectionId, err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + + // get zone + _, found = k.GetHostZone(ctx, chainId) + if found { + errMsg := fmt.Sprintf("invalid chain id, zone for %s already registered", chainId) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + + // check the denom is not already registered + hostZones := k.GetAllHostZone(ctx) + for _, hostZone := range hostZones { + if hostZone.HostDenom == msg.HostDenom { + errMsg := fmt.Sprintf("host denom %s already registered", msg.HostDenom) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + if hostZone.ConnectionId == msg.ConnectionId { + errMsg := fmt.Sprintf("connectionId %s already registered", msg.ConnectionId) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + if hostZone.TransferChannelId == msg.TransferChannelId { + errMsg := fmt.Sprintf("transfer channel %s already registered", msg.TransferChannelId) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + if hostZone.Bech32Prefix == msg.Bech32Prefix { + errMsg := fmt.Sprintf("bech32prefix %s already registered", msg.Bech32Prefix) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + } + + // create and save the zones's module account + depositAddress := types.NewHostZoneDepositAddress(chainId) + if err := utils.CreateModuleAccount(ctx, k.AccountKeeper, depositAddress); err != nil { + return nil, errorsmod.Wrapf(err, "unable to create deposit account for host zone %s", chainId) + } + + // Create the host zone's community pool holding accounts + communityPoolStakeAddress := types.NewHostZoneModuleAddress(chainId, CommunityPoolStakeHoldingAddressKey) + communityPoolRedeemAddress := types.NewHostZoneModuleAddress(chainId, CommunityPoolRedeemHoldingAddressKey) + if err := utils.CreateModuleAccount(ctx, k.AccountKeeper, communityPoolStakeAddress); err != nil { + return nil, errorsmod.Wrapf(err, "unable to create community pool stake account for host zone %s", chainId) + } + if err := utils.CreateModuleAccount(ctx, k.AccountKeeper, communityPoolRedeemAddress); err != nil { + return nil, errorsmod.Wrapf(err, "unable to create community pool redeem account for host zone %s", chainId) + } + + params := k.GetParams(ctx) + if msg.MinRedemptionRate.IsNil() || msg.MinRedemptionRate.IsZero() { + msg.MinRedemptionRate = sdk.NewDecWithPrec(int64(params.DefaultMinRedemptionRateThreshold), 2) + } + if msg.MaxRedemptionRate.IsNil() || msg.MaxRedemptionRate.IsZero() { + msg.MaxRedemptionRate = sdk.NewDecWithPrec(int64(params.DefaultMaxRedemptionRateThreshold), 2) + } + + // set the zone + zone := types.HostZone{ + ChainId: chainId, + ConnectionId: msg.ConnectionId, + Bech32Prefix: msg.Bech32Prefix, + IbcDenom: msg.IbcDenom, + HostDenom: msg.HostDenom, + TransferChannelId: msg.TransferChannelId, + // Start sharesToTokens rate at 1 upon registration + RedemptionRate: sdk.NewDec(1), + LastRedemptionRate: sdk.NewDec(1), + UnbondingPeriod: msg.UnbondingPeriod, + DepositAddress: depositAddress.String(), + CommunityPoolStakeHoldingAddress: communityPoolStakeAddress.String(), + CommunityPoolRedeemHoldingAddress: communityPoolRedeemAddress.String(), + MinRedemptionRate: msg.MinRedemptionRate, + MaxRedemptionRate: msg.MaxRedemptionRate, + // Default the inner bounds to the outer bounds + MinInnerRedemptionRate: msg.MinRedemptionRate, + MaxInnerRedemptionRate: msg.MaxRedemptionRate, + LsmLiquidStakeEnabled: msg.LsmLiquidStakeEnabled, + } + // write the zone back to the store + k.SetHostZone(ctx, zone) + + appVersion := string(icatypes.ModuleCdc.MustMarshalJSON(&icatypes.Metadata{ + Version: icatypes.Version, + ControllerConnectionId: zone.ConnectionId, + HostConnectionId: counterpartyConnection.ConnectionId, + Encoding: icatypes.EncodingProtobuf, + TxType: icatypes.TxTypeSDKMultiMsg, + })) + + // generate delegate account + // NOTE: in the future, if we implement proxy governance, we'll need many more delegate accounts + delegateAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_DELEGATION) + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, delegateAccount, appVersion); err != nil { + errMsg := fmt.Sprintf("unable to register delegation account, err: %s", err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + + // generate fee account + feeAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_FEE) + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, feeAccount, appVersion); err != nil { + errMsg := fmt.Sprintf("unable to register fee account, err: %s", err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + + // generate withdrawal account + withdrawalAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_WITHDRAWAL) + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, withdrawalAccount, appVersion); err != nil { + errMsg := fmt.Sprintf("unable to register withdrawal account, err: %s", err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + + // generate redemption account + redemptionAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_REDEMPTION) + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, redemptionAccount, appVersion); err != nil { + errMsg := fmt.Sprintf("unable to register redemption account, err: %s", err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + + // create community pool deposit account + communityPoolDepositAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_COMMUNITY_POOL_DEPOSIT) + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, communityPoolDepositAccount, appVersion); err != nil { + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, "failed to register community pool deposit ICA") + } + + // create community pool return account + communityPoolReturnAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_COMMUNITY_POOL_RETURN) + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, communityPoolReturnAccount, appVersion); err != nil { + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, "failed to register community pool return ICA") + } + + // add this host zone to unbonding hostZones, otherwise users won't be able to unbond + // for this host zone until the following day + dayEpochTracker, found := k.GetEpochTracker(ctx, epochtypes.DAY_EPOCH) + if !found { + return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "epoch tracker (%s) not found", epochtypes.DAY_EPOCH) + } + epochUnbondingRecord, found := k.RecordsKeeper.GetEpochUnbondingRecord(ctx, dayEpochTracker.EpochNumber) + if !found { + errMsg := "unable to find latest epoch unbonding record" + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(recordstypes.ErrEpochUnbondingRecordNotFound, errMsg) + } + hostZoneUnbonding := &recordstypes.HostZoneUnbonding{ + NativeTokenAmount: sdkmath.ZeroInt(), + StTokenAmount: sdkmath.ZeroInt(), + Denom: zone.HostDenom, + HostZoneId: zone.ChainId, + Status: recordstypes.HostZoneUnbonding_UNBONDING_QUEUE, + } + updatedEpochUnbondingRecord, success := k.RecordsKeeper.AddHostZoneToEpochUnbondingRecord(ctx, epochUnbondingRecord.EpochNumber, chainId, hostZoneUnbonding) + if !success { + errMsg := fmt.Sprintf("Failed to set host zone epoch unbonding record: epochNumber %d, chainId %s, hostZoneUnbonding %v. Err: %s", + epochUnbondingRecord.EpochNumber, chainId, hostZoneUnbonding, err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrEpochNotFound, errMsg) + } + k.RecordsKeeper.SetEpochUnbondingRecord(ctx, *updatedEpochUnbondingRecord) + + // create an empty deposit record for the host zone + strideEpochTracker, found := k.GetEpochTracker(ctx, epochtypes.STRIDE_EPOCH) + if !found { + return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "epoch tracker (%s) not found", epochtypes.STRIDE_EPOCH) + } + depositRecord := recordstypes.DepositRecord{ + Id: 0, + Amount: sdkmath.ZeroInt(), + Denom: zone.HostDenom, + HostZoneId: zone.ChainId, + Status: recordstypes.DepositRecord_TRANSFER_QUEUE, + DepositEpochNumber: strideEpochTracker.EpochNumber, + } + k.RecordsKeeper.AppendDepositRecord(ctx, depositRecord) + + // register stToken to consumer reward denom whitelist so that + // stToken rewards can be distributed to provider validators + err = k.RegisterStTokenDenomsToWhitelist(ctx, []string{types.StAssetDenomFromHostZoneDenom(zone.HostDenom)}) + if err != nil { + errMsg := fmt.Sprintf("unable to register reward denom, err: %s", err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) + } + + // emit events + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + ), + ) + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeRegisterZone, + sdk.NewAttribute(types.AttributeKeyConnectionId, msg.ConnectionId), + sdk.NewAttribute(types.AttributeKeyRecipientChain, chainId), + ), + ) + + return &types.MsgRegisterHostZoneResponse{}, nil +} + +func (k msgServer) AddValidators(goCtx context.Context, msg *types.MsgAddValidators) (*types.MsgAddValidatorsResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + for _, validator := range msg.Validators { + if err := k.AddValidatorToHostZone(ctx, msg.HostZone, *validator, false); err != nil { + return nil, err + } + + // Query and store the validator's sharesToTokens rate + if err := k.QueryValidatorSharesToTokensRate(ctx, msg.HostZone, validator.Address); err != nil { + return nil, err + } + } + + return &types.MsgAddValidatorsResponse{}, nil +} + +func (k msgServer) DeleteValidator(goCtx context.Context, msg *types.MsgDeleteValidator) (*types.MsgDeleteValidatorResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + err := k.RemoveValidatorFromHostZone(ctx, msg.HostZone, msg.ValAddr) + if err != nil { + errMsg := fmt.Sprintf("Validator (%s) not removed from host zone (%s) | err: %s", msg.ValAddr, msg.HostZone, err.Error()) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrValidatorNotRemoved, errMsg) + } + + return &types.MsgDeleteValidatorResponse{}, nil +} + +func (k msgServer) ChangeValidatorWeight(goCtx context.Context, msg *types.MsgChangeValidatorWeights) (*types.MsgChangeValidatorWeightsResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + hostZone, found := k.GetHostZone(ctx, msg.HostZone) + if !found { + return nil, types.ErrInvalidHostZone + } + + for _, weightChange := range msg.ValidatorWeights { + + validatorFound := false + for _, validator := range hostZone.Validators { + if validator.Address == weightChange.Address { + validator.Weight = weightChange.Weight + k.SetHostZone(ctx, hostZone) + + validatorFound = true + break + } + } + + if !validatorFound { + return nil, types.ErrValidatorNotFound + } + } + + // Confirm the new weights wouldn't cause any validator to exceed the weight cap + if err := k.CheckValidatorWeightsBelowCap(ctx, hostZone.Validators); err != nil { + return nil, errorsmod.Wrapf(err, "unable to change validator weight") + } + + return &types.MsgChangeValidatorWeightsResponse{}, nil +} + +func (k msgServer) RebalanceValidators(goCtx context.Context, msg *types.MsgRebalanceValidators) (*types.MsgRebalanceValidatorsResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + k.Logger(ctx).Info(fmt.Sprintf("RebalanceValidators executing %v", msg)) + + if err := k.RebalanceDelegationsForHostZone(ctx, msg.HostZone); err != nil { + return nil, err + } + return &types.MsgRebalanceValidatorsResponse{}, nil +} + +func (k msgServer) ClearBalance(goCtx context.Context, msg *types.MsgClearBalance) (*types.MsgClearBalanceResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + zone, found := k.GetHostZone(ctx, msg.ChainId) + if !found { + return nil, errorsmod.Wrapf(types.ErrInvalidHostZone, "chainId: %s", msg.ChainId) + } + if zone.FeeIcaAddress == "" { + return nil, errorsmod.Wrapf(types.ErrICAAccountNotFound, "fee acount not found for chainId: %s", msg.ChainId) + } + + sourcePort := ibctransfertypes.PortID + // Should this be a param? + // I think as long as we have a timeout on this, it should be hard to attack (even if someone send a tx on a bad channel, it would be reverted relatively quickly) + sourceChannel := msg.Channel + coinString := cast.ToString(msg.Amount) + zone.GetHostDenom() + tokens, err := sdk.ParseCoinNormalized(coinString) + if err != nil { + k.Logger(ctx).Error(fmt.Sprintf("failed to parse coin (%s)", coinString)) + return nil, errorsmod.Wrapf(err, "failed to parse coin (%s)", coinString) + } + // KeyICATimeoutNanos are for our Stride ICA calls, KeyFeeTransferTimeoutNanos is for the IBC transfer + feeTransferTimeoutNanos := k.GetParam(ctx, types.KeyFeeTransferTimeoutNanos) + timeoutTimestamp := cast.ToUint64(ctx.BlockTime().UnixNano()) + feeTransferTimeoutNanos + msgs := []proto.Message{ + &ibctransfertypes.MsgTransfer{ + SourcePort: sourcePort, + SourceChannel: sourceChannel, + Token: tokens, + Sender: zone.FeeIcaAddress, // fee account on the host zone + Receiver: types.FeeAccount, // fee account on stride + TimeoutTimestamp: timeoutTimestamp, + }, + } + + connectionId := zone.GetConnectionId() + + icaTimeoutNanos := k.GetParam(ctx, types.KeyICATimeoutNanos) + icaTimeoutNanos = cast.ToUint64(ctx.BlockTime().UnixNano()) + icaTimeoutNanos + + _, err = k.SubmitTxs(ctx, connectionId, msgs, types.ICAAccountType_FEE, icaTimeoutNanos, "", nil) + if err != nil { + return nil, errorsmod.Wrapf(err, "failed to submit txs") + } + return &types.MsgClearBalanceResponse{}, nil +} + +// Exchanges a user's native tokens for stTokens using the current redemption rate +// The native tokens must live on Stride with an IBC denomination before this function is called +// The typical flow consists, first, of a transfer of native tokens from the host zone to Stride, +// +// and then the invocation of this LiquidStake function +// +// WARNING: This function is invoked from the begin/end blocker in a way that does not revert partial state when +// +// an error is thrown (i.e. the execution is non-atomic). +// As a result, it is important that the validation steps are positioned at the top of the function, +// and logic that creates state changes (e.g. bank sends, mint) appear towards the end of the function +func (k msgServer) LiquidStake(goCtx context.Context, msg *types.MsgLiquidStake) (*types.MsgLiquidStakeResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + // Get the host zone from the base denom in the message (e.g. uatom) + hostZone, err := k.GetHostZoneFromHostDenom(ctx, msg.HostDenom) + if err != nil { + return nil, errorsmod.Wrapf(types.ErrInvalidToken, "no host zone found for denom (%s)", msg.HostDenom) + } + + // Error immediately if the host zone is halted + if hostZone.Halted { + return nil, errorsmod.Wrapf(types.ErrHaltedHostZone, "halted host zone found for denom (%s)", msg.HostDenom) + } + + // Get user and module account addresses + liquidStakerAddress, err := sdk.AccAddressFromBech32(msg.Creator) + if err != nil { + return nil, errorsmod.Wrapf(err, "user's address is invalid") + } + hostZoneDepositAddress, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) + if err != nil { + return nil, errorsmod.Wrapf(err, "host zone address is invalid") + } + + // Safety check: redemption rate must be within safety bounds + rateIsSafe, err := k.IsRedemptionRateWithinSafetyBounds(ctx, *hostZone) + if !rateIsSafe || (err != nil) { + return nil, errorsmod.Wrapf(types.ErrRedemptionRateOutsideSafetyBounds, "HostZone: %s, err: %s", hostZone.ChainId, err.Error()) + } + + // Grab the deposit record that will be used for record keeping + strideEpochTracker, found := k.GetEpochTracker(ctx, epochtypes.STRIDE_EPOCH) + if !found { + return nil, errorsmod.Wrapf(sdkerrors.ErrNotFound, "no epoch number for epoch (%s)", epochtypes.STRIDE_EPOCH) + } + depositRecord, found := k.RecordsKeeper.GetTransferDepositRecordByEpochAndChain(ctx, strideEpochTracker.EpochNumber, hostZone.ChainId) + if !found { + return nil, errorsmod.Wrapf(sdkerrors.ErrNotFound, "no deposit record for epoch (%d)", strideEpochTracker.EpochNumber) + } + + // The tokens that are sent to the protocol are denominated in the ibc hash of the native token on stride (e.g. ibc/xxx) + nativeDenom := hostZone.IbcDenom + nativeCoin := sdk.NewCoin(nativeDenom, msg.Amount) + if !types.IsIBCToken(nativeDenom) { + return nil, errorsmod.Wrapf(types.ErrInvalidToken, "denom is not an IBC token (%s)", nativeDenom) + } + + // Confirm the user has a sufficient balance to execute the liquid stake + balance := k.bankKeeper.GetBalance(ctx, liquidStakerAddress, nativeDenom) + if balance.IsLT(nativeCoin) { + return nil, errorsmod.Wrapf(sdkerrors.ErrInsufficientFunds, "balance is lower than staking amount. staking amount: %v, balance: %v", msg.Amount, balance.Amount) + } + + // Determine the amount of stTokens to mint using the redemption rate + stAmount := (sdk.NewDecFromInt(msg.Amount).Quo(hostZone.RedemptionRate)).TruncateInt() + if stAmount.IsZero() { + return nil, errorsmod.Wrapf(types.ErrInsufficientLiquidStake, + "Liquid stake of %s%s would return 0 stTokens", msg.Amount.String(), hostZone.HostDenom) + } + + // Transfer the native tokens from the user to module account + if err := k.bankKeeper.SendCoins(ctx, liquidStakerAddress, hostZoneDepositAddress, sdk.NewCoins(nativeCoin)); err != nil { + return nil, errorsmod.Wrap(err, "failed to send tokens from Account to Module") + } + + // Mint the stTokens and transfer them to the user + stDenom := types.StAssetDenomFromHostZoneDenom(msg.HostDenom) + stCoin := sdk.NewCoin(stDenom, stAmount) + if err := k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(stCoin)); err != nil { + return nil, errorsmod.Wrapf(err, "Failed to mint coins") + } + if err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, liquidStakerAddress, sdk.NewCoins(stCoin)); err != nil { + return nil, errorsmod.Wrapf(err, "Failed to send %s from module to account", stCoin.String()) + } + + // Update the liquid staked amount on the deposit record + depositRecord.Amount = depositRecord.Amount.Add(msg.Amount) + k.RecordsKeeper.SetDepositRecord(ctx, *depositRecord) + + // Emit liquid stake event + EmitSuccessfulLiquidStakeEvent(ctx, msg, *hostZone, stAmount) + + k.hooks.AfterLiquidStake(ctx, liquidStakerAddress) + return &types.MsgLiquidStakeResponse{StToken: stCoin}, nil +} + +func (k msgServer) RedeemStake(goCtx context.Context, msg *types.MsgRedeemStake) (*types.MsgRedeemStakeResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + k.Logger(ctx).Info(fmt.Sprintf("redeem stake: %s", msg.String())) + + // ----------------- PRELIMINARY CHECKS ----------------- + // get our addresses, make sure they're valid + sender, err := sdk.AccAddressFromBech32(msg.Creator) + if err != nil { + return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, "creator address is invalid: %s. err: %s", msg.Creator, err.Error()) + } + // then make sure host zone is valid + hostZone, found := k.GetHostZone(ctx, msg.HostZone) + if !found { + return nil, errorsmod.Wrapf(types.ErrInvalidHostZone, "host zone is invalid: %s", msg.HostZone) + } + + if hostZone.Halted { + k.Logger(ctx).Error(fmt.Sprintf("Host Zone halted for zone (%s)", msg.HostZone)) + return nil, errorsmod.Wrapf(types.ErrHaltedHostZone, "halted host zone found for zone (%s)", msg.HostZone) + } + + // first construct a user redemption record + epochTracker, found := k.GetEpochTracker(ctx, "day") + if !found { + return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "epoch tracker found: %s", "day") + } + + // ensure the recipient address is a valid bech32 address on the hostZone + _, err = utils.AccAddressFromBech32(msg.Receiver, hostZone.Bech32Prefix) + if err != nil { + return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, "invalid receiver address (%s)", err) + } + + // construct desired unstaking amount from host zone + // TODO [cleanup]: Consider changing to truncate int + stDenom := types.StAssetDenomFromHostZoneDenom(hostZone.HostDenom) + nativeAmount := sdk.NewDecFromInt(msg.Amount).Mul(hostZone.RedemptionRate).RoundInt() + + if nativeAmount.GT(hostZone.TotalDelegations) { + return nil, errorsmod.Wrapf(types.ErrInvalidAmount, "cannot unstake an amount g.t. staked balance on host zone: %v", msg.Amount) + } + + // safety check: redemption rate must be within safety bounds + rateIsSafe, err := k.IsRedemptionRateWithinSafetyBounds(ctx, hostZone) + if !rateIsSafe || (err != nil) { + errMsg := fmt.Sprintf("IsRedemptionRateWithinSafetyBounds check failed. hostZone: %s, err: %s", hostZone.String(), err.Error()) + return nil, errorsmod.Wrapf(types.ErrRedemptionRateOutsideSafetyBounds, errMsg) + } + + // safety checks on the coin + // - Redemption amount must be positive + if !nativeAmount.IsPositive() { + return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidCoins, "amount must be greater than 0. found: %v", msg.Amount) + } + // - Creator owns at least "amount" stAssets + balance := k.bankKeeper.GetBalance(ctx, sender, stDenom) + if balance.Amount.LT(msg.Amount) { + return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidCoins, "balance is lower than redemption amount. redemption amount: %v, balance %v: ", msg.Amount, balance.Amount) + } + + // ----------------- UNBONDING RECORD KEEPING ----------------- + // Fetch the record + redemptionId := recordstypes.UserRedemptionRecordKeyFormatter(hostZone.ChainId, epochTracker.EpochNumber, msg.Receiver) + userRedemptionRecord, userHasRedeemedThisEpoch := k.RecordsKeeper.GetUserRedemptionRecord(ctx, redemptionId) + if userHasRedeemedThisEpoch { + k.Logger(ctx).Info(fmt.Sprintf("UserRedemptionRecord found for %s", redemptionId)) + // Add the unbonded amount to the UserRedemptionRecord + // The record is set below + userRedemptionRecord.StTokenAmount = userRedemptionRecord.StTokenAmount.Add(msg.Amount) + userRedemptionRecord.NativeTokenAmount = userRedemptionRecord.NativeTokenAmount.Add(nativeAmount) + } else { + // First time a user is redeeming this epoch + userRedemptionRecord = recordstypes.UserRedemptionRecord{ + Id: redemptionId, + Receiver: msg.Receiver, + NativeTokenAmount: nativeAmount, + Denom: hostZone.HostDenom, + HostZoneId: hostZone.ChainId, + EpochNumber: epochTracker.EpochNumber, + StTokenAmount: msg.Amount, + // claimIsPending represents whether a redemption is currently being claimed, + // contingent on the host zone unbonding having status CLAIMABLE + ClaimIsPending: false, + } + k.Logger(ctx).Info(fmt.Sprintf("UserRedemptionRecord not found - creating for %s", redemptionId)) + } + + // then add undelegation amount to epoch unbonding records + epochUnbondingRecord, found := k.RecordsKeeper.GetEpochUnbondingRecord(ctx, epochTracker.EpochNumber) + if !found { + k.Logger(ctx).Error("latest epoch unbonding record not found") + return nil, errorsmod.Wrapf(recordstypes.ErrEpochUnbondingRecordNotFound, "latest epoch unbonding record not found") + } + // get relevant host zone on this epoch unbonding record + hostZoneUnbonding, found := k.RecordsKeeper.GetHostZoneUnbondingByChainId(ctx, epochUnbondingRecord.EpochNumber, hostZone.ChainId) + if !found { + return nil, errorsmod.Wrapf(types.ErrInvalidHostZone, "host zone not found in unbondings: %s", hostZone.ChainId) + } + hostZoneUnbonding.NativeTokenAmount = hostZoneUnbonding.NativeTokenAmount.Add(nativeAmount) + if !userHasRedeemedThisEpoch { + // Only append a UserRedemptionRecord to the HZU if it wasn't previously appended + hostZoneUnbonding.UserRedemptionRecords = append(hostZoneUnbonding.UserRedemptionRecords, userRedemptionRecord.Id) + } + + // Escrow user's balance + redeemCoin := sdk.NewCoins(sdk.NewCoin(stDenom, msg.Amount)) + depositAddress, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) + if err != nil { + return nil, fmt.Errorf("could not bech32 decode address %s of zone with id: %s", hostZone.DepositAddress, hostZone.ChainId) + } + err = k.bankKeeper.SendCoins(ctx, sender, depositAddress, redeemCoin) + if err != nil { + k.Logger(ctx).Error("Failed to send sdk.NewCoins(inCoins) from account to module") + return nil, errorsmod.Wrapf(types.ErrInsufficientFunds, "couldn't send %v derivative %s tokens to module account. err: %s", msg.Amount, hostZone.HostDenom, err.Error()) + } + + // record the number of stAssets that should be burned after unbonding + hostZoneUnbonding.StTokenAmount = hostZoneUnbonding.StTokenAmount.Add(msg.Amount) + + // Actually set the records, we wait until now to prevent any errors + k.RecordsKeeper.SetUserRedemptionRecord(ctx, userRedemptionRecord) + + // Set the UserUnbondingRecords on the proper HostZoneUnbondingRecord + hostZoneUnbondings := epochUnbondingRecord.GetHostZoneUnbondings() + if hostZoneUnbondings == nil { + hostZoneUnbondings = []*recordstypes.HostZoneUnbonding{} + epochUnbondingRecord.HostZoneUnbondings = hostZoneUnbondings + } + updatedEpochUnbondingRecord, success := k.RecordsKeeper.AddHostZoneToEpochUnbondingRecord(ctx, epochUnbondingRecord.EpochNumber, hostZone.ChainId, hostZoneUnbonding) + if !success { + k.Logger(ctx).Error(fmt.Sprintf("Failed to set host zone epoch unbonding record: epochNumber %d, chainId %s, hostZoneUnbonding %v", epochUnbondingRecord.EpochNumber, hostZone.ChainId, hostZoneUnbonding)) + return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "couldn't set host zone epoch unbonding record. err: %s", err.Error()) + } + k.RecordsKeeper.SetEpochUnbondingRecord(ctx, *updatedEpochUnbondingRecord) + + k.Logger(ctx).Info(fmt.Sprintf("executed redeem stake: %s", msg.String())) + return &types.MsgRedeemStakeResponse{}, nil +} + +// Exchanges a user's LSM tokenized shares for stTokens using the current redemption rate +// The LSM tokens must live on Stride as an IBC voucher (whose denomtrace we recognize) +// before this function is called +// +// The typical flow: +// - A staker tokenizes their delegation on the host zone +// - The staker IBC transfers their tokenized shares to Stride +// - They then call LSMLiquidStake +// - - The staker's LSM Tokens are sent to the Stride module account +// - - The staker recieves stTokens +// +// As a safety measure, at period checkpoints, the validator's sharesToTokens rate is queried and the transaction +// is not settled until the query returns +// As a result, this transaction has been split up into a (1) Start and (2) Finish function +// - If no query is needed, (2) is called immediately after (1) +// - If a query is needed, (2) is called in the query callback +// +// The transaction response indicates if the query occurred by returning an attribute `TransactionComplete` set to false +func (k msgServer) LSMLiquidStake(goCtx context.Context, msg *types.MsgLSMLiquidStake) (*types.MsgLSMLiquidStakeResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + lsmLiquidStake, err := k.StartLSMLiquidStake(ctx, *msg) + if err != nil { + return nil, err + } + + if k.ShouldCheckIfValidatorWasSlashed(ctx, *lsmLiquidStake.Validator, msg.Amount) { + if err := k.SubmitValidatorSlashQuery(ctx, lsmLiquidStake); err != nil { + return nil, err + } + + EmitPendingLSMLiquidStakeEvent(ctx, *lsmLiquidStake.HostZone, *lsmLiquidStake.Deposit) + + return &types.MsgLSMLiquidStakeResponse{TransactionComplete: false}, nil + } + + async := false + if err := k.FinishLSMLiquidStake(ctx, lsmLiquidStake, async); err != nil { + return nil, err + } + + return &types.MsgLSMLiquidStakeResponse{TransactionComplete: true}, nil +} + +// Gov tx to register a trade route that swaps reward tokens for a different denom +// +// Example proposal: +// +// { +// "title": "Create a new trade route for host chain X", +// "metadata": "Create a new trade route for host chain X", +// "summary": "Create a new trade route for host chain X", +// "messages":[ +// { +// "@type": "/stride.stakeibc.MsgCreateTradeRoute", +// "authority": "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl", +// +// "stride_to_host_connection_id": "connection-0", +// "stride_to_reward_connection_id": "connection-1", +// "stride_to_trade_connection_id": "connection-2", +// +// "host_to_reward_transfer_channel_id": "channel-0", +// "reward_to_trade_transfer_channel_id": "channel-1", +// "trade_to_host_transfer_channel_id": "channel-2", +// +// "reward_denom_on_host": "ibc/rewardTokenXXX", +// "reward_denom_on_reward": "rewardToken", +// "reward_denom_on_trade": "ibc/rewardTokenYYY", +// "host_denom_on_trade": "ibc/hostTokenZZZ", +// "host_denom_on_host": "hostToken", +// +// "pool_id": 1, +// "max_allowed_swap_loss_rate": "0.05" +// "min_swap_amount": "10000000", +// "max_swap_amount": "1000000000" +// } +// ], +// "deposit": "2000000000ustrd" +// } +// +// >>> strided tx gov submit-proposal {proposal_file.json} --from wallet +func (ms msgServer) CreateTradeRoute(goCtx context.Context, msg *types.MsgCreateTradeRoute) (*types.MsgCreateTradeRouteResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + if ms.authority != msg.Authority { + return nil, errorsmod.Wrapf(govtypes.ErrInvalidSigner, "invalid authority; expected %s, got %s", ms.authority, msg.Authority) + } + + // Validate trade route does not already exist for this denom + _, found := ms.Keeper.GetTradeRoute(ctx, msg.RewardDenomOnReward, msg.HostDenomOnHost) + if found { + return nil, errorsmod.Wrapf(types.ErrTradeRouteAlreadyExists, + "trade route already exists for rewardDenom %s, hostDenom %s", msg.RewardDenomOnReward, msg.HostDenomOnHost) + } + + // Confirm the host chain exists and the withdrawal address has been initialized + hostZone, err := ms.Keeper.GetActiveHostZone(ctx, msg.HostChainId) + if err != nil { + return nil, err + } + if hostZone.WithdrawalIcaAddress == "" { + return nil, errorsmod.Wrapf(types.ErrICAAccountNotFound, "withdrawal account not initialized on host zone") + } + + // Register the new ICA accounts + tradeRouteId := types.GetTradeRouteId(msg.RewardDenomOnReward, msg.HostDenomOnHost) + hostICA := types.ICAAccount{ + ChainId: msg.HostChainId, + Type: types.ICAAccountType_WITHDRAWAL, + ConnectionId: hostZone.ConnectionId, + Address: hostZone.WithdrawalIcaAddress, + } + + unwindConnectionId := msg.StrideToRewardConnectionId + unwindICAType := types.ICAAccountType_CONVERTER_UNWIND + unwindICA, err := ms.Keeper.RegisterTradeRouteICAAccount(ctx, tradeRouteId, unwindConnectionId, unwindICAType) + if err != nil { + return nil, errorsmod.Wrapf(err, "unable to register the unwind ICA account") + } + + tradeConnectionId := msg.StrideToTradeConnectionId + tradeICAType := types.ICAAccountType_CONVERTER_TRADE + tradeICA, err := ms.Keeper.RegisterTradeRouteICAAccount(ctx, tradeRouteId, tradeConnectionId, tradeICAType) + if err != nil { + return nil, errorsmod.Wrapf(err, "unable to register the trade ICA account") + } + + // If a max allowed swap loss is not provided, use the default + maxAllowedSwapLossRate := msg.MaxAllowedSwapLossRate + if maxAllowedSwapLossRate == "" { + maxAllowedSwapLossRate = DefaultMaxAllowedSwapLossRate + } + maxSwapAmount := msg.MaxSwapAmount + if maxSwapAmount.IsZero() { + maxSwapAmount = DefaultMaxSwapAmount + } + + // Create the trade config to specify parameters needed for the swap + tradeConfig := types.TradeConfig{ + PoolId: msg.PoolId, + SwapPrice: sdk.ZeroDec(), // this should only ever be set by ICQ so initialize to blank + PriceUpdateTimestamp: 0, + + MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), + MinSwapAmount: msg.MinSwapAmount, + MaxSwapAmount: maxSwapAmount, + } + + // Finally build and store the main trade route + tradeRoute := types.TradeRoute{ + RewardDenomOnHostZone: msg.RewardDenomOnHost, + RewardDenomOnRewardZone: msg.RewardDenomOnReward, + RewardDenomOnTradeZone: msg.RewardDenomOnTrade, + HostDenomOnTradeZone: msg.HostDenomOnTrade, + HostDenomOnHostZone: msg.HostDenomOnHost, + + HostAccount: hostICA, + RewardAccount: unwindICA, + TradeAccount: tradeICA, + + HostToRewardChannelId: msg.HostToRewardTransferChannelId, + RewardToTradeChannelId: msg.RewardToTradeTransferChannelId, + TradeToHostChannelId: msg.TradeToHostTransferChannelId, + + TradeConfig: tradeConfig, + } + + ms.Keeper.SetTradeRoute(ctx, tradeRoute) + + return &types.MsgCreateTradeRouteResponse{}, nil +} + +// Gov tx to remove a trade route +// +// Example proposal: +// +// { +// "title": "Remove a new trade route for host chain X", +// "metadata": "Remove a new trade route for host chain X", +// "summary": "Remove a new trade route for host chain X", +// "messages":[ +// { +// "@type": "/stride.stakeibc.MsgDeleteTradeRoute", +// "authority": "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl", +// "reward_denom": "rewardToken", +// "host_denom": "hostToken +// } +// ], +// "deposit": "2000000000ustrd" +// } +// +// >>> strided tx gov submit-proposal {proposal_file.json} --from wallet +func (ms msgServer) DeleteTradeRoute(goCtx context.Context, msg *types.MsgDeleteTradeRoute) (*types.MsgDeleteTradeRouteResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + if ms.authority != msg.Authority { + return nil, errorsmod.Wrapf(govtypes.ErrInvalidSigner, "invalid authority; expected %s, got %s", ms.authority, msg.Authority) + } + + _, found := ms.Keeper.GetTradeRoute(ctx, msg.RewardDenom, msg.HostDenom) + if !found { + return nil, errorsmod.Wrapf(types.ErrTradeRouteNotFound, + "no trade route for rewardDenom %s and hostDenom %s", msg.RewardDenom, msg.HostDenom) + } + + ms.Keeper.RemoveTradeRoute(ctx, msg.RewardDenom, msg.HostDenom) + + return &types.MsgDeleteTradeRouteResponse{}, nil +} + +// Gov tx to update the trade config of a trade route +// +// Example proposal: +// +// { +// "title": "Update a the trade config for host chain X", +// "metadata": "Update a the trade config for host chain X", +// "summary": "Update a the trade config for host chain X", +// "messages":[ +// { +// "@type": "/stride.stakeibc.MsgUpdateTradeRoute", +// "authority": "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl", +// +// "pool_id": 1, +// "max_allowed_swap_loss_rate": "0.05", +// "min_swap_amount": "10000000", +// "max_swap_amount": "1000000000" +// } +// ], +// "deposit": "2000000000ustrd" +// } +// +// >>> strided tx gov submit-proposal {proposal_file.json} --from wallet +func (ms msgServer) UpdateTradeRoute(goCtx context.Context, msg *types.MsgUpdateTradeRoute) (*types.MsgUpdateTradeRouteResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + if ms.authority != msg.Authority { + return nil, errorsmod.Wrapf(govtypes.ErrInvalidSigner, "invalid authority; expected %s, got %s", ms.authority, msg.Authority) + } + + route, found := ms.Keeper.GetTradeRoute(ctx, msg.RewardDenom, msg.HostDenom) + if !found { + return nil, errorsmod.Wrapf(types.ErrTradeRouteNotFound, + "no trade route for rewardDenom %s and hostDenom %s", msg.RewardDenom, msg.HostDenom) + } + + maxAllowedSwapLossRate := msg.MaxAllowedSwapLossRate + if maxAllowedSwapLossRate == "" { + maxAllowedSwapLossRate = DefaultMaxAllowedSwapLossRate + } + maxSwapAmount := msg.MaxSwapAmount + if maxSwapAmount.IsZero() { + maxSwapAmount = DefaultMaxSwapAmount + } + + updatedConfig := types.TradeConfig{ + PoolId: msg.PoolId, + + SwapPrice: sdk.ZeroDec(), + PriceUpdateTimestamp: 0, + + MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), + MinSwapAmount: msg.MinSwapAmount, + MaxSwapAmount: maxSwapAmount, + } + + route.TradeConfig = updatedConfig + ms.Keeper.SetTradeRoute(ctx, route) + + return &types.MsgUpdateTradeRouteResponse{}, nil +} + +func (k msgServer) RestoreInterchainAccount(goCtx context.Context, msg *types.MsgRestoreInterchainAccount) (*types.MsgRestoreInterchainAccountResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + // Get ConnectionEnd (for counterparty connection) + connectionEnd, found := k.IBCKeeper.ConnectionKeeper.GetConnection(ctx, msg.ConnectionId) + if !found { + return nil, errorsmod.Wrapf(connectiontypes.ErrConnectionNotFound, "connection %s not found", msg.ConnectionId) + } + counterpartyConnection := connectionEnd.Counterparty + + // only allow restoring an account if it already exists + portID, err := icatypes.NewControllerPortID(msg.AccountOwner) + if err != nil { + return nil, err + } + _, exists := k.ICAControllerKeeper.GetInterchainAccountAddress(ctx, msg.ConnectionId, portID) + if !exists { + return nil, errorsmod.Wrapf(types.ErrInvalidInterchainAccountAddress, + "ICA controller account address not found: %s", msg.AccountOwner) + } + + appVersion := string(icatypes.ModuleCdc.MustMarshalJSON(&icatypes.Metadata{ + Version: icatypes.Version, + ControllerConnectionId: msg.ConnectionId, + HostConnectionId: counterpartyConnection.ConnectionId, + Encoding: icatypes.EncodingProtobuf, + TxType: icatypes.TxTypeSDKMultiMsg, + })) + + if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, msg.ConnectionId, msg.AccountOwner, appVersion); err != nil { + return nil, errorsmod.Wrapf(err, "unable to register account for owner %s", msg.AccountOwner) + } + + // If we're restoring a delegation account, we also have to reset record state + if msg.AccountOwner == types.FormatHostZoneICAOwner(msg.ChainId, types.ICAAccountType_DELEGATION) { + hostZone, found := k.GetHostZone(ctx, msg.ChainId) + if !found { + return nil, types.ErrHostZoneNotFound.Wrapf("delegation ICA supplied, but no associated host zone") + } + + // Since any ICAs along the original channel will never get relayed, + // we have to reset the delegation_changes_in_progress field on each validator + for _, validator := range hostZone.Validators { + validator.DelegationChangesInProgress = 0 + } + k.SetHostZone(ctx, hostZone) + + // revert DELEGATION_IN_PROGRESS records for the closed ICA channel (so that they can be staked) + depositRecords := k.RecordsKeeper.GetAllDepositRecord(ctx) + for _, depositRecord := range depositRecords { + // only revert records for the select host zone + if depositRecord.HostZoneId == hostZone.ChainId && depositRecord.Status == recordtypes.DepositRecord_DELEGATION_IN_PROGRESS { + depositRecord.Status = recordtypes.DepositRecord_DELEGATION_QUEUE + k.Logger(ctx).Info(fmt.Sprintf("Setting DepositRecord %d to status DepositRecord_DELEGATION_IN_PROGRESS", depositRecord.Id)) + k.RecordsKeeper.SetDepositRecord(ctx, depositRecord) + } + } + + // revert epoch unbonding records for the closed ICA channel + epochUnbondingRecords := k.RecordsKeeper.GetAllEpochUnbondingRecord(ctx) + epochNumberForPendingUnbondingRecords := []uint64{} + epochNumberForPendingTransferRecords := []uint64{} + for _, epochUnbondingRecord := range epochUnbondingRecords { + // only revert records for the select host zone + hostZoneUnbonding, found := k.RecordsKeeper.GetHostZoneUnbondingByChainId(ctx, epochUnbondingRecord.EpochNumber, hostZone.ChainId) + if !found { + k.Logger(ctx).Info(fmt.Sprintf("No HostZoneUnbonding found for chainId: %s, epoch: %d", hostZone.ChainId, epochUnbondingRecord.EpochNumber)) + continue + } + + // Revert UNBONDING_IN_PROGRESS and EXIT_TRANSFER_IN_PROGRESS records + if hostZoneUnbonding.Status == recordtypes.HostZoneUnbonding_UNBONDING_IN_PROGRESS { + k.Logger(ctx).Info(fmt.Sprintf("HostZoneUnbonding for %s at EpochNumber %d is stuck in status %s", + hostZone.ChainId, epochUnbondingRecord.EpochNumber, recordtypes.HostZoneUnbonding_UNBONDING_IN_PROGRESS.String(), + )) + epochNumberForPendingUnbondingRecords = append(epochNumberForPendingUnbondingRecords, epochUnbondingRecord.EpochNumber) + + } else if hostZoneUnbonding.Status == recordtypes.HostZoneUnbonding_EXIT_TRANSFER_IN_PROGRESS { + k.Logger(ctx).Info(fmt.Sprintf("HostZoneUnbonding for %s at EpochNumber %d to in status %s", + hostZone.ChainId, epochUnbondingRecord.EpochNumber, recordtypes.HostZoneUnbonding_EXIT_TRANSFER_IN_PROGRESS.String(), + )) + epochNumberForPendingTransferRecords = append(epochNumberForPendingTransferRecords, epochUnbondingRecord.EpochNumber) + } + } + // Revert UNBONDING_IN_PROGRESS records to UNBONDING_QUEUE + err := k.RecordsKeeper.SetHostZoneUnbondingStatus(ctx, hostZone.ChainId, epochNumberForPendingUnbondingRecords, recordtypes.HostZoneUnbonding_UNBONDING_QUEUE) + if err != nil { + errMsg := fmt.Sprintf("unable to update host zone unbonding record status to %s for chainId: %s and epochUnbondingRecordIds: %v, err: %s", + recordtypes.HostZoneUnbonding_UNBONDING_QUEUE.String(), hostZone.ChainId, epochNumberForPendingUnbondingRecords, err) + k.Logger(ctx).Error(errMsg) + return nil, err + } + + // Revert EXIT_TRANSFER_IN_PROGRESS records to EXIT_TRANSFER_QUEUE + err = k.RecordsKeeper.SetHostZoneUnbondingStatus(ctx, hostZone.ChainId, epochNumberForPendingTransferRecords, recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE) + if err != nil { + errMsg := fmt.Sprintf("unable to update host zone unbonding record status to %s for chainId: %s and epochUnbondingRecordIds: %v, err: %s", + recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE.String(), hostZone.ChainId, epochNumberForPendingTransferRecords, err) + k.Logger(ctx).Error(errMsg) + return nil, err + } + + // Revert all pending LSM Detokenizations from status DETOKENIZATION_IN_PROGRESS to status DETOKENIZATION_QUEUE + pendingDeposits := k.RecordsKeeper.GetLSMDepositsForHostZoneWithStatus(ctx, hostZone.ChainId, recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS) + for _, lsmDeposit := range pendingDeposits { + k.Logger(ctx).Info(fmt.Sprintf("Setting LSMTokenDeposit %s to status DETOKENIZATION_QUEUE", lsmDeposit.Denom)) + k.RecordsKeeper.UpdateLSMTokenDepositStatus(ctx, lsmDeposit, recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE) + } + } + + return &types.MsgRestoreInterchainAccountResponse{}, nil +} + +// This kicks off two ICQs, each with a callback, that will update the number of tokens on a validator +// after being slashed. The flow is: +// 1. QueryValidatorSharesToTokensRate (ICQ) +// 2. ValidatorSharesToTokensRate (CALLBACK) +// 3. SubmitDelegationICQ (ICQ) +// 4. DelegatorSharesCallback (CALLBACK) +func (k msgServer) UpdateValidatorSharesExchRate(goCtx context.Context, msg *types.MsgUpdateValidatorSharesExchRate) (*types.MsgUpdateValidatorSharesExchRateResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + if err := k.QueryValidatorSharesToTokensRate(ctx, msg.ChainId, msg.Valoper); err != nil { + return nil, err + } + return &types.MsgUpdateValidatorSharesExchRateResponse{}, nil +} + +// Submits an ICQ to get the validator's delegated shares +func (k msgServer) CalibrateDelegation(goCtx context.Context, msg *types.MsgCalibrateDelegation) (*types.MsgCalibrateDelegationResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + hostZone, found := k.GetHostZone(ctx, msg.ChainId) + if !found { + return nil, types.ErrHostZoneNotFound + } + + if err := k.SubmitCalibrationICQ(ctx, hostZone, msg.Valoper); err != nil { + k.Logger(ctx).Error(fmt.Sprintf("Error submitting ICQ for delegation, error : %s", err.Error())) + return nil, err + } + + return &types.MsgCalibrateDelegationResponse{}, nil +} + +func (k msgServer) UpdateInnerRedemptionRateBounds(goCtx context.Context, msg *types.MsgUpdateInnerRedemptionRateBounds) (*types.MsgUpdateInnerRedemptionRateBoundsResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + // 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)) + return nil, types.ErrInvalidHostZone + } + + // Get the wide bounds + outerMinSafetyThreshold, outerMaxSafetyThreshold := k.GetOuterSafetyBounds(ctx, zone) + + innerMinSafetyThreshold := msg.MinInnerRedemptionRate + innerMaxSafetyThreshold := msg.MaxInnerRedemptionRate + + // Confirm the inner bounds are within the outer bounds + if innerMinSafetyThreshold.LT(outerMinSafetyThreshold) { + errMsg := fmt.Sprintf("inner min safety threshold (%s) is less than outer min safety threshold (%s)", innerMinSafetyThreshold, outerMinSafetyThreshold) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrInvalidBounds, errMsg) + } + + if innerMaxSafetyThreshold.GT(outerMaxSafetyThreshold) { + errMsg := fmt.Sprintf("inner max safety threshold (%s) is greater than outer max safety threshold (%s)", innerMaxSafetyThreshold, outerMaxSafetyThreshold) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrInvalidBounds, errMsg) + } + + // Set the inner bounds on the host zone + zone.MinInnerRedemptionRate = innerMinSafetyThreshold + zone.MaxInnerRedemptionRate = innerMaxSafetyThreshold + + k.SetHostZone(ctx, zone) + + return &types.MsgUpdateInnerRedemptionRateBoundsResponse{}, nil +} + +func (k msgServer) ResumeHostZone(goCtx context.Context, msg *types.MsgResumeHostZone) (*types.MsgResumeHostZoneResponse, error) { + ctx := sdk.UnwrapSDKContext(goCtx) + + // Get Host Zone + hostZone, found := k.GetHostZone(ctx, msg.ChainId) + if !found { + errMsg := fmt.Sprintf("invalid chain id, zone for %s not found", msg.ChainId) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrHostZoneNotFound, errMsg) + } + + // Check the zone is halted + if !hostZone.Halted { + errMsg := fmt.Sprintf("invalid chain id, zone for %s not halted", msg.ChainId) + k.Logger(ctx).Error(errMsg) + return nil, errorsmod.Wrapf(types.ErrHostZoneNotHalted, errMsg) + } + + // remove from blacklist + stDenom := types.StAssetDenomFromHostZoneDenom(hostZone.HostDenom) + k.RatelimitKeeper.RemoveDenomFromBlacklist(ctx, stDenom) + + // Resume zone + hostZone.Halted = false + k.SetHostZone(ctx, hostZone) + + return &types.MsgResumeHostZoneResponse{}, nil +} diff --git a/x/stakeibc/keeper/msg_server_add_validators.go b/x/stakeibc/keeper/msg_server_add_validators.go deleted file mode 100644 index 23f5e5e5d8..0000000000 --- a/x/stakeibc/keeper/msg_server_add_validators.go +++ /dev/null @@ -1,26 +0,0 @@ -package keeper - -import ( - "context" - - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (k msgServer) AddValidators(goCtx context.Context, msg *types.MsgAddValidators) (*types.MsgAddValidatorsResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - for _, validator := range msg.Validators { - if err := k.AddValidatorToHostZone(ctx, msg.HostZone, *validator, false); err != nil { - return nil, err - } - - // Query and store the validator's sharesToTokens rate - if err := k.QueryValidatorSharesToTokensRate(ctx, msg.HostZone, validator.Address); err != nil { - return nil, err - } - } - - return &types.MsgAddValidatorsResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_add_validators_test.go b/x/stakeibc/keeper/msg_server_add_validators_test.go deleted file mode 100644 index 836cb831d9..0000000000 --- a/x/stakeibc/keeper/msg_server_add_validators_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package keeper_test - -import ( - "fmt" - - sdkmath "cosmossdk.io/math" - _ "github.com/stretchr/testify/suite" - - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/cosmos/cosmos-sdk/types/bech32" - stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - ibctesting "github.com/cosmos/ibc-go/v7/testing" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type AddValidatorsTestCase struct { - hostZone types.HostZone - validMsg types.MsgAddValidators - expectedValidators []*types.Validator - validatorQueryDataToName map[string]string -} - -// Helper function to determine the validator's key in the staking store -// which is used as the request data in the ICQ -func (s *KeeperTestSuite) getSharesToTokensRateQueryData(validatorAddress string) []byte { - _, validatorAddressBz, err := bech32.DecodeAndConvert(validatorAddress) - s.Require().NoError(err, "no error expected when decoding validator address") - return stakingtypes.GetValidatorKey(validatorAddressBz) -} - -func (s *KeeperTestSuite) SetupAddValidators() AddValidatorsTestCase { - slashThreshold := uint64(10) - params := types.DefaultParams() - params.ValidatorSlashQueryThreshold = slashThreshold - s.App.StakeibcKeeper.SetParams(s.Ctx, params) - - totalDelegations := sdkmath.NewInt(100_000) - expectedSlashCheckpoint := sdkmath.NewInt(10_000) - - hostZone := types.HostZone{ - ChainId: "GAIA", - ConnectionId: ibctesting.FirstConnectionID, - Validators: []*types.Validator{}, - TotalDelegations: totalDelegations, - } - - validatorAddresses := map[string]string{ - "val1": "stridevaloper1uk4ze0x4nvh4fk0xm4jdud58eqn4yxhrgpwsqm", - "val2": "stridevaloper17kht2x2ped6qytr2kklevtvmxpw7wq9rcfud5c", - "val3": "stridevaloper1nnurja9zt97huqvsfuartetyjx63tc5zrj5x9f", - } - - // mapping of query request data to validator name - // serves as a reverse lookup to map sharesToTokens rate queries to validators - validatorQueryDataToName := map[string]string{} - for name, address := range validatorAddresses { - queryData := s.getSharesToTokensRateQueryData(address) - validatorQueryDataToName[string(queryData)] = name - } - - validMsg := types.MsgAddValidators{ - Creator: "stride_ADMIN", - HostZone: HostChainId, - Validators: []*types.Validator{ - {Name: "val1", Address: validatorAddresses["val1"], Weight: 1}, - {Name: "val2", Address: validatorAddresses["val2"], Weight: 2}, - {Name: "val3", Address: validatorAddresses["val3"], Weight: 3}, - }, - } - - expectedValidators := []*types.Validator{ - {Name: "val1", Address: validatorAddresses["val1"], Weight: 1}, - {Name: "val2", Address: validatorAddresses["val2"], Weight: 2}, - {Name: "val3", Address: validatorAddresses["val3"], Weight: 3}, - } - for _, validator := range expectedValidators { - validator.Delegation = sdkmath.ZeroInt() - validator.SlashQueryProgressTracker = sdkmath.ZeroInt() - validator.SharesToTokensRate = sdk.ZeroDec() - validator.SlashQueryCheckpoint = expectedSlashCheckpoint - } - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - // Mock the latest client height for the ICQ submission - s.MockClientLatestHeight(1) - - return AddValidatorsTestCase{ - hostZone: hostZone, - validMsg: validMsg, - expectedValidators: expectedValidators, - validatorQueryDataToName: validatorQueryDataToName, - } -} - -func (s *KeeperTestSuite) TestAddValidators_Successful() { - tc := s.SetupAddValidators() - - // Add validators - _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().NoError(err) - - hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, "GAIA") - s.Require().True(found, "host zone found") - s.Require().Equal(3, len(hostZone.Validators), "number of validators") - - for i := 0; i < 3; i++ { - s.Require().Equal(*tc.expectedValidators[i], *hostZone.Validators[i], "validators %d", i) - } - - // Confirm ICQs were submitted - queries := s.App.InterchainqueryKeeper.AllQueries(s.Ctx) - s.Require().Len(queries, 3) - - // Map the query responses to the validator names to get the names of the validators that - // were queried - queriedValidators := []string{} - for i, query := range queries { - validator, ok := tc.validatorQueryDataToName[string(query.RequestData)] - s.Require().True(ok, "query from response %d does not match any expected requests", i) - queriedValidators = append(queriedValidators, validator) - } - - // Confirm the list of queried validators matches the full list of validators - allValidatorNames := []string{} - for _, expected := range tc.expectedValidators { - allValidatorNames = append(allValidatorNames, expected.Name) - } - s.Require().ElementsMatch(allValidatorNames, queriedValidators, "queried validators") -} - -func (s *KeeperTestSuite) TestAddValidators_HostZoneNotFound() { - tc := s.SetupAddValidators() - - // Replace hostzone in msg to a host zone that doesn't exist - badHostZoneMsg := tc.validMsg - badHostZoneMsg.HostZone = "gaia" - _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &badHostZoneMsg) - s.Require().EqualError(err, "Host Zone (gaia) not found: host zone not found") -} - -func (s *KeeperTestSuite) TestAddValidators_AddressAlreadyExists() { - tc := s.SetupAddValidators() - - // Update host zone so that the name val1 already exists - hostZone := tc.hostZone - duplicateAddress := tc.expectedValidators[0].Address - duplicateVal := types.Validator{Name: "new_val", Address: duplicateAddress} - hostZone.Validators = []*types.Validator{&duplicateVal} - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - // Change the validator address to val1 so that the message errors - expectedError := fmt.Sprintf("Validator address (%s) already exists on Host Zone (GAIA)", duplicateAddress) - _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().ErrorContains(err, expectedError) -} - -func (s *KeeperTestSuite) TestAddValidators_NameAlreadyExists() { - tc := s.SetupAddValidators() - - // Update host zone so that val1's address already exists - hostZone := tc.hostZone - duplicateName := tc.expectedValidators[0].Name - duplicateVal := types.Validator{Name: duplicateName, Address: "new_address"} - hostZone.Validators = []*types.Validator{&duplicateVal} - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - // Change the validator name to val1 so that the message errors - expectedError := fmt.Sprintf("Validator name (%s) already exists on Host Zone (GAIA)", duplicateName) - _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().ErrorContains(err, expectedError) -} diff --git a/x/stakeibc/keeper/msg_server_calibrate_delegation.go b/x/stakeibc/keeper/msg_server_calibrate_delegation.go deleted file mode 100644 index 27323aa8a5..0000000000 --- a/x/stakeibc/keeper/msg_server_calibrate_delegation.go +++ /dev/null @@ -1,27 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -// Submits an ICQ to get the validator's delegated shares -func (k msgServer) CalibrateDelegation(goCtx context.Context, msg *types.MsgCalibrateDelegation) (*types.MsgCalibrateDelegationResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - hostZone, found := k.GetHostZone(ctx, msg.ChainId) - if !found { - return nil, types.ErrHostZoneNotFound - } - - if err := k.SubmitCalibrationICQ(ctx, hostZone, msg.Valoper); err != nil { - k.Logger(ctx).Error(fmt.Sprintf("Error submitting ICQ for delegation, error : %s", err.Error())) - return nil, err - } - - return &types.MsgCalibrateDelegationResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_change_validator_weight.go b/x/stakeibc/keeper/msg_server_change_validator_weight.go deleted file mode 100644 index 2e8be6fde7..0000000000 --- a/x/stakeibc/keeper/msg_server_change_validator_weight.go +++ /dev/null @@ -1,44 +0,0 @@ -package keeper - -import ( - "context" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (k msgServer) ChangeValidatorWeight(goCtx context.Context, msg *types.MsgChangeValidatorWeights) (*types.MsgChangeValidatorWeightsResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - hostZone, found := k.GetHostZone(ctx, msg.HostZone) - if !found { - return nil, types.ErrInvalidHostZone - } - - for _, weightChange := range msg.ValidatorWeights { - - validatorFound := false - for _, validator := range hostZone.Validators { - if validator.Address == weightChange.Address { - validator.Weight = weightChange.Weight - k.SetHostZone(ctx, hostZone) - - validatorFound = true - break - } - } - - if !validatorFound { - return nil, types.ErrValidatorNotFound - } - } - - // Confirm the new weights wouldn't cause any validator to exceed the weight cap - if err := k.CheckValidatorWeightsBelowCap(ctx, hostZone.Validators); err != nil { - return nil, errorsmod.Wrapf(err, "unable to change validator weight") - } - - return &types.MsgChangeValidatorWeightsResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_clear_balance.go b/x/stakeibc/keeper/msg_server_clear_balance.go deleted file mode 100644 index ee17ba6bf2..0000000000 --- a/x/stakeibc/keeper/msg_server_clear_balance.go +++ /dev/null @@ -1,61 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - proto "github.com/cosmos/gogoproto/proto" - ibctransfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" - "github.com/spf13/cast" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (k msgServer) ClearBalance(goCtx context.Context, msg *types.MsgClearBalance) (*types.MsgClearBalanceResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - zone, found := k.GetHostZone(ctx, msg.ChainId) - if !found { - return nil, errorsmod.Wrapf(types.ErrInvalidHostZone, "chainId: %s", msg.ChainId) - } - if zone.FeeIcaAddress == "" { - return nil, errorsmod.Wrapf(types.ErrICAAccountNotFound, "fee acount not found for chainId: %s", msg.ChainId) - } - - sourcePort := ibctransfertypes.PortID - // Should this be a param? - // I think as long as we have a timeout on this, it should be hard to attack (even if someone send a tx on a bad channel, it would be reverted relatively quickly) - sourceChannel := msg.Channel - coinString := cast.ToString(msg.Amount) + zone.GetHostDenom() - tokens, err := sdk.ParseCoinNormalized(coinString) - if err != nil { - k.Logger(ctx).Error(fmt.Sprintf("failed to parse coin (%s)", coinString)) - return nil, errorsmod.Wrapf(err, "failed to parse coin (%s)", coinString) - } - // KeyICATimeoutNanos are for our Stride ICA calls, KeyFeeTransferTimeoutNanos is for the IBC transfer - feeTransferTimeoutNanos := k.GetParam(ctx, types.KeyFeeTransferTimeoutNanos) - timeoutTimestamp := cast.ToUint64(ctx.BlockTime().UnixNano()) + feeTransferTimeoutNanos - msgs := []proto.Message{ - &ibctransfertypes.MsgTransfer{ - SourcePort: sourcePort, - SourceChannel: sourceChannel, - Token: tokens, - Sender: zone.FeeIcaAddress, // fee account on the host zone - Receiver: types.FeeAccount, // fee account on stride - TimeoutTimestamp: timeoutTimestamp, - }, - } - - connectionId := zone.GetConnectionId() - - icaTimeoutNanos := k.GetParam(ctx, types.KeyICATimeoutNanos) - icaTimeoutNanos = cast.ToUint64(ctx.BlockTime().UnixNano()) + icaTimeoutNanos - - _, err = k.SubmitTxs(ctx, connectionId, msgs, types.ICAAccountType_FEE, icaTimeoutNanos, "", nil) - if err != nil { - return nil, errorsmod.Wrapf(err, "failed to submit txs") - } - return &types.MsgClearBalanceResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_clear_balance_test.go b/x/stakeibc/keeper/msg_server_clear_balance_test.go deleted file mode 100644 index a7e55432cb..0000000000 --- a/x/stakeibc/keeper/msg_server_clear_balance_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package keeper_test - -import ( - "fmt" - - sdkmath "cosmossdk.io/math" - sdk "github.com/cosmos/cosmos-sdk/types" - icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" - ibctesting "github.com/cosmos/ibc-go/v7/testing" - _ "github.com/stretchr/testify/suite" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type ClearBalanceState struct { - feeChannel Channel - hz stakeibctypes.HostZone -} - -type ClearBalanceTestCase struct { - initialState ClearBalanceState - validMsg stakeibctypes.MsgClearBalance -} - -func (s *KeeperTestSuite) SetupClearBalance() ClearBalanceTestCase { - // fee account - feeAccountOwner := fmt.Sprintf("%s.%s", HostChainId, "FEE") - feeChannelID, _ := s.CreateICAChannel(feeAccountOwner) - feeAddress := s.IcaAddresses[feeAccountOwner] - // hz - depositAddress := types.NewHostZoneDepositAddress(HostChainId) - hostZone := stakeibctypes.HostZone{ - ChainId: HostChainId, - ConnectionId: ibctesting.FirstConnectionID, - HostDenom: Atom, - IbcDenom: IbcAtom, - RedemptionRate: sdk.NewDec(1.0), - DepositAddress: depositAddress.String(), - FeeIcaAddress: feeAddress, - } - - amount := sdkmath.NewInt(1_000_000) - - user := Account{ - acc: s.TestAccs[0], - } - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - return ClearBalanceTestCase{ - initialState: ClearBalanceState{ - hz: hostZone, - feeChannel: Channel{ - PortID: icatypes.ControllerPortPrefix + feeAccountOwner, - ChannelID: feeChannelID, - }, - }, - validMsg: stakeibctypes.MsgClearBalance{ - Creator: user.acc.String(), - ChainId: HostChainId, - Amount: amount, - Channel: feeChannelID, - }, - } -} - -func (s *KeeperTestSuite) TestClearBalance_Successful() { - tc := s.SetupClearBalance() - - // Get the sequence number before the ICA is submitted to confirm it incremented - feeChannel := tc.initialState.feeChannel - feePortId := feeChannel.PortID - feeChannelId := feeChannel.ChannelID - - startSequence, found := s.App.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, feePortId, feeChannelId) - s.Require().True(found, "sequence number not found before clear balance") - - _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().NoError(err, "balance clears") - - // Confirm the sequence number was incremented - endSequence, found := s.App.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, feePortId, feeChannelId) - s.Require().True(found, "sequence number not found after clear balance") - s.Require().Equal(endSequence, startSequence+1, "sequence number after clear balance") -} - -func (s *KeeperTestSuite) TestClearBalance_HostChainMissing() { - tc := s.SetupClearBalance() - // remove the host zone - s.App.StakeibcKeeper.RemoveHostZone(s.Ctx, HostChainId) - _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "chainId: GAIA: host zone not registered") -} - -func (s *KeeperTestSuite) TestClearBalance_FeeAccountMissing() { - tc := s.SetupClearBalance() - // no fee account - tc.initialState.hz.FeeIcaAddress = "" - s.App.StakeibcKeeper.SetHostZone(s.Ctx, tc.initialState.hz) - _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "fee acount not found for chainId: GAIA: ICA acccount not found on host zone") -} - -func (s *KeeperTestSuite) TestClearBalance_ParseCoinError() { - tc := s.SetupClearBalance() - // invalid denom - tc.initialState.hz.HostDenom = ":" - s.App.StakeibcKeeper.SetHostZone(s.Ctx, tc.initialState.hz) - _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "failed to parse coin (1000000:): invalid decimal coin expression: 1000000:") -} diff --git a/x/stakeibc/keeper/msg_server_create_trade_route.go b/x/stakeibc/keeper/msg_server_create_trade_route.go deleted file mode 100644 index 15fc7cd65f..0000000000 --- a/x/stakeibc/keeper/msg_server_create_trade_route.go +++ /dev/null @@ -1,210 +0,0 @@ -package keeper - -import ( - "context" - - sdkmath "cosmossdk.io/math" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" - icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" - connectiontypes "github.com/cosmos/ibc-go/v7/modules/core/03-connection/types" -) - -var ( - DefaultMaxAllowedSwapLossRate = "0.05" - DefaultMaxSwapAmount = sdkmath.NewIntWithDecimal(10, 24) // 10e24 -) - -// Gov tx to register a trade route that swaps reward tokens for a different denom -// -// Example proposal: -// -// { -// "title": "Create a new trade route for host chain X", -// "metadata": "Create a new trade route for host chain X", -// "summary": "Create a new trade route for host chain X", -// "messages":[ -// { -// "@type": "/stride.stakeibc.MsgCreateTradeRoute", -// "authority": "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl", -// -// "stride_to_host_connection_id": "connection-0", -// "stride_to_reward_connection_id": "connection-1", -// "stride_to_trade_connection_id": "connection-2", -// -// "host_to_reward_transfer_channel_id": "channel-0", -// "reward_to_trade_transfer_channel_id": "channel-1", -// "trade_to_host_transfer_channel_id": "channel-2", -// -// "reward_denom_on_host": "ibc/rewardTokenXXX", -// "reward_denom_on_reward": "rewardToken", -// "reward_denom_on_trade": "ibc/rewardTokenYYY", -// "host_denom_on_trade": "ibc/hostTokenZZZ", -// "host_denom_on_host": "hostToken", -// -// "pool_id": 1, -// "max_allowed_swap_loss_rate": "0.05" -// "min_swap_amount": "10000000", -// "max_swap_amount": "1000000000" -// } -// ], -// "deposit": "2000000000ustrd" -// } -// -// >>> strided tx gov submit-proposal {proposal_file.json} --from wallet -func (ms msgServer) CreateTradeRoute(goCtx context.Context, msg *types.MsgCreateTradeRoute) (*types.MsgCreateTradeRouteResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - if ms.authority != msg.Authority { - return nil, errorsmod.Wrapf(govtypes.ErrInvalidSigner, "invalid authority; expected %s, got %s", ms.authority, msg.Authority) - } - - // Validate trade route does not already exist for this denom - _, found := ms.Keeper.GetTradeRoute(ctx, msg.RewardDenomOnReward, msg.HostDenomOnHost) - if found { - return nil, errorsmod.Wrapf(types.ErrTradeRouteAlreadyExists, - "trade route already exists for rewardDenom %s, hostDenom %s", msg.RewardDenomOnReward, msg.HostDenomOnHost) - } - - // Confirm the host chain exists and the withdrawal address has been initialized - hostZone, err := ms.Keeper.GetActiveHostZone(ctx, msg.HostChainId) - if err != nil { - return nil, err - } - if hostZone.WithdrawalIcaAddress == "" { - return nil, errorsmod.Wrapf(types.ErrICAAccountNotFound, "withdrawal account not initialized on host zone") - } - - // Register the new ICA accounts - tradeRouteId := types.GetTradeRouteId(msg.RewardDenomOnReward, msg.HostDenomOnHost) - hostICA := types.ICAAccount{ - ChainId: msg.HostChainId, - Type: types.ICAAccountType_WITHDRAWAL, - ConnectionId: hostZone.ConnectionId, - Address: hostZone.WithdrawalIcaAddress, - } - - unwindConnectionId := msg.StrideToRewardConnectionId - unwindICAType := types.ICAAccountType_CONVERTER_UNWIND - unwindICA, err := ms.Keeper.RegisterTradeRouteICAAccount(ctx, tradeRouteId, unwindConnectionId, unwindICAType) - if err != nil { - return nil, errorsmod.Wrapf(err, "unable to register the unwind ICA account") - } - - tradeConnectionId := msg.StrideToTradeConnectionId - tradeICAType := types.ICAAccountType_CONVERTER_TRADE - tradeICA, err := ms.Keeper.RegisterTradeRouteICAAccount(ctx, tradeRouteId, tradeConnectionId, tradeICAType) - if err != nil { - return nil, errorsmod.Wrapf(err, "unable to register the trade ICA account") - } - - // If a max allowed swap loss is not provided, use the default - maxAllowedSwapLossRate := msg.MaxAllowedSwapLossRate - if maxAllowedSwapLossRate == "" { - maxAllowedSwapLossRate = DefaultMaxAllowedSwapLossRate - } - maxSwapAmount := msg.MaxSwapAmount - if maxSwapAmount.IsZero() { - maxSwapAmount = DefaultMaxSwapAmount - } - - // Create the trade config to specify parameters needed for the swap - tradeConfig := types.TradeConfig{ - PoolId: msg.PoolId, - SwapPrice: sdk.ZeroDec(), // this should only ever be set by ICQ so initialize to blank - PriceUpdateTimestamp: 0, - - MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), - MinSwapAmount: msg.MinSwapAmount, - MaxSwapAmount: maxSwapAmount, - } - - // Finally build and store the main trade route - tradeRoute := types.TradeRoute{ - RewardDenomOnHostZone: msg.RewardDenomOnHost, - RewardDenomOnRewardZone: msg.RewardDenomOnReward, - RewardDenomOnTradeZone: msg.RewardDenomOnTrade, - HostDenomOnTradeZone: msg.HostDenomOnTrade, - HostDenomOnHostZone: msg.HostDenomOnHost, - - HostAccount: hostICA, - RewardAccount: unwindICA, - TradeAccount: tradeICA, - - HostToRewardChannelId: msg.HostToRewardTransferChannelId, - RewardToTradeChannelId: msg.RewardToTradeTransferChannelId, - TradeToHostChannelId: msg.TradeToHostTransferChannelId, - - TradeConfig: tradeConfig, - } - - ms.Keeper.SetTradeRoute(ctx, tradeRoute) - - return &types.MsgCreateTradeRouteResponse{}, nil -} - -// Registers a new TradeRoute ICAAccount, given the type -// Stores down the connection and chainId now, and the address upon callback -func (k Keeper) RegisterTradeRouteICAAccount( - ctx sdk.Context, - tradeRouteId string, - connectionId string, - icaAccountType types.ICAAccountType, -) (account types.ICAAccount, err error) { - // Get the chain ID and counterparty connection-id from the connection ID on Stride - chainId, err := k.GetChainIdFromConnectionId(ctx, connectionId) - if err != nil { - return account, err - } - connection, found := k.IBCKeeper.ConnectionKeeper.GetConnection(ctx, connectionId) - if !found { - return account, errorsmod.Wrap(connectiontypes.ErrConnectionNotFound, connectionId) - } - counterpartyConnectionId := connection.Counterparty.ConnectionId - - // Build the appVersion, owner, and portId needed for registration - appVersion := string(icatypes.ModuleCdc.MustMarshalJSON(&icatypes.Metadata{ - Version: icatypes.Version, - ControllerConnectionId: connectionId, - HostConnectionId: counterpartyConnectionId, - Encoding: icatypes.EncodingProtobuf, - TxType: icatypes.TxTypeSDKMultiMsg, - })) - owner := types.FormatTradeRouteICAOwnerFromRouteId(chainId, tradeRouteId, icaAccountType) - portID, err := icatypes.NewControllerPortID(owner) - if err != nil { - return account, err - } - - // Create the associate ICAAccount object - account = types.ICAAccount{ - ChainId: chainId, - Type: icaAccountType, - ConnectionId: connectionId, - } - - // Check if an ICA account has already been created - // (in the event that this trade route was removed and then added back) - // If so, there's no need to register a new ICA - _, channelFound := k.ICAControllerKeeper.GetOpenActiveChannel(ctx, connectionId, portID) - icaAddress, icaFound := k.ICAControllerKeeper.GetInterchainAccountAddress(ctx, connectionId, portID) - if channelFound && icaFound { - account = types.ICAAccount{ - ChainId: chainId, - Type: icaAccountType, - ConnectionId: connectionId, - Address: icaAddress, - } - return account, nil - } - - // Otherwise, if there's no account already, register a new one - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, connectionId, owner, appVersion); err != nil { - return account, err - } - - return account, nil -} diff --git a/x/stakeibc/keeper/msg_server_create_trade_route_test.go b/x/stakeibc/keeper/msg_server_create_trade_route_test.go deleted file mode 100644 index d1f69a098c..0000000000 --- a/x/stakeibc/keeper/msg_server_create_trade_route_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package keeper_test - -import ( - sdkmath "cosmossdk.io/math" - - sdk "github.com/cosmos/cosmos-sdk/types" - icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/keeper" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (s *KeeperTestSuite) SetupTestCreateTradeRoute() (msg types.MsgCreateTradeRoute, expectedTradeRoute types.TradeRoute) { - rewardChainId := "reward-0" - tradeChainId := "trade-0" - - hostConnectionId := "connection-0" - rewardConnectionId := "connection-1" - tradeConnectionId := "connection-2" - - hostToRewardChannelId := "channel-100" - rewardToTradeChannelId := "channel-200" - tradeToHostChannelId := "channel-300" - - rewardDenomOnHost := "ibc/reward-on-host" - rewardDenomOnReward := RewardDenom - rewardDenomOnTrade := "ibc/reward-on-trade" - hostDenomOnTrade := "ibc/host-on-trade" - hostDenomOnHost := HostDenom - - withdrawalAddress := "withdrawal-address" - unwindAddress := "unwind-address" - - poolId := uint64(100) - maxAllowedSwapLossRate := "0.05" - minSwapAmount := sdkmath.NewInt(100) - maxSwapAmount := sdkmath.NewInt(1_000) - - // Mock out connections for the reward an trade chain so that an ICA registration can be submitted - s.MockClientAndConnection(rewardChainId, "07-tendermint-0", rewardConnectionId) - s.MockClientAndConnection(tradeChainId, "07-tendermint-1", tradeConnectionId) - - // Register an exisiting ICA account for the unwind ICA to test that - // existing accounts are re-used - owner := types.FormatTradeRouteICAOwner(rewardChainId, RewardDenom, HostDenom, types.ICAAccountType_CONVERTER_UNWIND) - s.MockICAChannel(rewardConnectionId, "channel-0", owner, unwindAddress) - - // Create a host zone with an exisiting withdrawal address - hostZone := types.HostZone{ - ChainId: HostChainId, - ConnectionId: hostConnectionId, - WithdrawalIcaAddress: withdrawalAddress, - } - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - // Define a valid message given the parameters above - msg = types.MsgCreateTradeRoute{ - Authority: Authority, - HostChainId: HostChainId, - - StrideToRewardConnectionId: rewardConnectionId, - StrideToTradeConnectionId: tradeConnectionId, - - HostToRewardTransferChannelId: hostToRewardChannelId, - RewardToTradeTransferChannelId: rewardToTradeChannelId, - TradeToHostTransferChannelId: tradeToHostChannelId, - - RewardDenomOnHost: rewardDenomOnHost, - RewardDenomOnReward: rewardDenomOnReward, - RewardDenomOnTrade: rewardDenomOnTrade, - HostDenomOnTrade: hostDenomOnTrade, - HostDenomOnHost: hostDenomOnHost, - - PoolId: poolId, - MaxAllowedSwapLossRate: maxAllowedSwapLossRate, - MinSwapAmount: minSwapAmount, - MaxSwapAmount: maxSwapAmount, - } - - // Build out the expected trade route given the above - expectedTradeRoute = types.TradeRoute{ - RewardDenomOnHostZone: rewardDenomOnHost, - RewardDenomOnRewardZone: rewardDenomOnReward, - RewardDenomOnTradeZone: rewardDenomOnTrade, - HostDenomOnTradeZone: hostDenomOnTrade, - HostDenomOnHostZone: hostDenomOnHost, - - HostAccount: types.ICAAccount{ - ChainId: HostChainId, - Type: types.ICAAccountType_WITHDRAWAL, - ConnectionId: hostConnectionId, - Address: withdrawalAddress, - }, - RewardAccount: types.ICAAccount{ - ChainId: rewardChainId, - Type: types.ICAAccountType_CONVERTER_UNWIND, - ConnectionId: rewardConnectionId, - Address: unwindAddress, - }, - TradeAccount: types.ICAAccount{ - ChainId: tradeChainId, - Type: types.ICAAccountType_CONVERTER_TRADE, - ConnectionId: tradeConnectionId, - }, - - HostToRewardChannelId: hostToRewardChannelId, - RewardToTradeChannelId: rewardToTradeChannelId, - TradeToHostChannelId: tradeToHostChannelId, - - TradeConfig: types.TradeConfig{ - PoolId: poolId, - SwapPrice: sdk.ZeroDec(), - PriceUpdateTimestamp: 0, - - MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), - MinSwapAmount: minSwapAmount, - MaxSwapAmount: maxSwapAmount, - }, - } - - return msg, expectedTradeRoute -} - -// Helper function to create a trade route and check the created route matched expectations -func (s *KeeperTestSuite) submitCreateTradeRouteAndValidate(msg types.MsgCreateTradeRoute, expectedRoute types.TradeRoute) { - _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err, "no error expected when creating trade route") - - actualRoute, found := s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, msg.RewardDenomOnReward, msg.HostDenomOnHost) - s.Require().True(found, "trade route should have been created") - s.Require().Equal(expectedRoute, actualRoute, "trade route") -} - -// Tests a successful trade route creation -func (s *KeeperTestSuite) TestCreateTradeRoute_Success() { - msg, expectedRoute := s.SetupTestCreateTradeRoute() - s.submitCreateTradeRouteAndValidate(msg, expectedRoute) -} - -// Tests creating a trade route that uses the default pool config values -func (s *KeeperTestSuite) TestCreateTradeRoute_Success_DefaultPoolConfig() { - msg, expectedRoute := s.SetupTestCreateTradeRoute() - - // Update the message and remove some trade config parameters - // so that the defaults are used - msg.MaxSwapAmount = sdk.ZeroInt() - msg.MaxAllowedSwapLossRate = "" - - expectedRoute.TradeConfig.MaxAllowedSwapLossRate = sdk.MustNewDecFromStr(keeper.DefaultMaxAllowedSwapLossRate) - expectedRoute.TradeConfig.MaxSwapAmount = keeper.DefaultMaxSwapAmount - - s.submitCreateTradeRouteAndValidate(msg, expectedRoute) -} - -// Tests trying to create a route from an invalid authority -func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_Authority() { - msg, _ := s.SetupTestCreateTradeRoute() - - msg.Authority = "not-gov-address" - - _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "invalid authority") -} - -// Tests creating a duplicate trade route -func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_DuplicateTradeRoute() { - msg, _ := s.SetupTestCreateTradeRoute() - - // Store down a trade route so the tx hits a duplicate trade route error - s.App.StakeibcKeeper.SetTradeRoute(s.Ctx, types.TradeRoute{ - RewardDenomOnRewardZone: RewardDenom, - HostDenomOnHostZone: HostDenom, - }) - - _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "Trade route already exists") -} - -// Tests creating a trade route when the host zone or withdrawal address does not exist -func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_HostZoneNotRegistered() { - msg, _ := s.SetupTestCreateTradeRoute() - - // Remove the host zone withdrawal address and confirm it fails - invalidHostZone := s.MustGetHostZone(HostChainId) - invalidHostZone.WithdrawalIcaAddress = "" - s.App.StakeibcKeeper.SetHostZone(s.Ctx, invalidHostZone) - - _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "withdrawal account not initialized on host zone") - - // Remove the host zone completely and check that that also fails - s.App.StakeibcKeeper.RemoveHostZone(s.Ctx, HostChainId) - - _, err = s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "host zone not found") -} - -// Tests creating a trade route where the ICA channels cannot be created -// because the ICA connections do not exist -func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_ConnectionNotFound() { - // Test with non-existent reward connection - msg, _ := s.SetupTestCreateTradeRoute() - msg.StrideToRewardConnectionId = "connection-X" - - // Remove the host zone completely and check that that also fails - _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "unable to register the unwind ICA account: connection connection-X not found") - - // Setup again, but this time use a non-existent trade connection - msg, _ = s.SetupTestCreateTradeRoute() - msg.StrideToTradeConnectionId = "connection-Y" - - _, err = s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "unable to register the trade ICA account: connection connection-Y not found") -} - -// Tests creating a trade route where the ICA registration step fails -func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_UnableToRegisterICA() { - msg, expectedRoute := s.SetupTestCreateTradeRoute() - - // Disable ICA middleware for the trade channel so the ICA fails - tradeAccount := expectedRoute.TradeAccount - tradeOwner := types.FormatTradeRouteICAOwner(tradeAccount.ChainId, RewardDenom, HostDenom, types.ICAAccountType_CONVERTER_TRADE) - tradePortId, _ := icatypes.NewControllerPortID(tradeOwner) - s.App.ICAControllerKeeper.SetMiddlewareDisabled(s.Ctx, tradePortId, tradeAccount.ConnectionId) - - _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "unable to register the trade ICA account") -} diff --git a/x/stakeibc/keeper/msg_server_delete_trade_route.go b/x/stakeibc/keeper/msg_server_delete_trade_route.go deleted file mode 100644 index 7afbbf2f2b..0000000000 --- a/x/stakeibc/keeper/msg_server_delete_trade_route.go +++ /dev/null @@ -1,49 +0,0 @@ -package keeper - -import ( - "context" - - govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" -) - -// Gov tx to remove a trade route -// -// Example proposal: -// -// { -// "title": "Remove a new trade route for host chain X", -// "metadata": "Remove a new trade route for host chain X", -// "summary": "Remove a new trade route for host chain X", -// "messages":[ -// { -// "@type": "/stride.stakeibc.MsgDeleteTradeRoute", -// "authority": "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl", -// "reward_denom": "rewardToken", -// "host_denom": "hostToken -// } -// ], -// "deposit": "2000000000ustrd" -// } -// -// >>> strided tx gov submit-proposal {proposal_file.json} --from wallet -func (ms msgServer) DeleteTradeRoute(goCtx context.Context, msg *types.MsgDeleteTradeRoute) (*types.MsgDeleteTradeRouteResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - if ms.authority != msg.Authority { - return nil, errorsmod.Wrapf(govtypes.ErrInvalidSigner, "invalid authority; expected %s, got %s", ms.authority, msg.Authority) - } - - _, found := ms.Keeper.GetTradeRoute(ctx, msg.RewardDenom, msg.HostDenom) - if !found { - return nil, errorsmod.Wrapf(types.ErrTradeRouteNotFound, - "no trade route for rewardDenom %s and hostDenom %s", msg.RewardDenom, msg.HostDenom) - } - - ms.Keeper.RemoveTradeRoute(ctx, msg.RewardDenom, msg.HostDenom) - - return &types.MsgDeleteTradeRouteResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_delete_trade_route_test.go b/x/stakeibc/keeper/msg_server_delete_trade_route_test.go deleted file mode 100644 index fee41f0e3a..0000000000 --- a/x/stakeibc/keeper/msg_server_delete_trade_route_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package keeper_test - -import ( - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - sdk "github.com/cosmos/cosmos-sdk/types" -) - -func (s *KeeperTestSuite) TestDeleteTradeRoute() { - initialRoute := types.TradeRoute{ - RewardDenomOnRewardZone: RewardDenom, - HostDenomOnHostZone: HostDenom, - } - s.App.StakeibcKeeper.SetTradeRoute(s.Ctx, initialRoute) - - msg := types.MsgDeleteTradeRoute{ - Authority: Authority, - RewardDenom: RewardDenom, - HostDenom: HostDenom, - } - - // Confirm the route is present before attepmting to delete was deleted - _, found := s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, RewardDenom, HostDenom) - s.Require().True(found, "trade route should have been found before delete message") - - // Delete the trade route - _, err := s.GetMsgServer().DeleteTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err, "no error expected when deleting trade route") - - // Confirm it was deleted - _, found = s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, RewardDenom, HostDenom) - s.Require().False(found, "trade route should have been deleted") - - // Attempt to delete it again, it should fail since it doesn't exist - _, err = s.GetMsgServer().DeleteTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "trade route not found") - - // Attempt to delete with the wrong authority - it should fail - invalidMsg := msg - invalidMsg.Authority = "not-gov-address" - - _, err = s.GetMsgServer().DeleteTradeRoute(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().ErrorContains(err, "invalid authority") -} diff --git a/x/stakeibc/keeper/msg_server_delete_validator.go b/x/stakeibc/keeper/msg_server_delete_validator.go deleted file mode 100644 index e095a19dcd..0000000000 --- a/x/stakeibc/keeper/msg_server_delete_validator.go +++ /dev/null @@ -1,24 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (k msgServer) DeleteValidator(goCtx context.Context, msg *types.MsgDeleteValidator) (*types.MsgDeleteValidatorResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - err := k.RemoveValidatorFromHostZone(ctx, msg.HostZone, msg.ValAddr) - if err != nil { - errMsg := fmt.Sprintf("Validator (%s) not removed from host zone (%s) | err: %s", msg.ValAddr, msg.HostZone, err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrValidatorNotRemoved, errMsg) - } - - return &types.MsgDeleteValidatorResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_delete_validator_test.go b/x/stakeibc/keeper/msg_server_delete_validator_test.go deleted file mode 100644 index 9348e0f0c2..0000000000 --- a/x/stakeibc/keeper/msg_server_delete_validator_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package keeper_test - -import ( - sdkmath "cosmossdk.io/math" - _ "github.com/stretchr/testify/suite" - - sdk "github.com/cosmos/cosmos-sdk/types" - - stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type DeleteValidatorTestCase struct { - hostZone stakeibctypes.HostZone - initialValidators []*stakeibctypes.Validator - validMsgs []stakeibctypes.MsgDeleteValidator -} - -func (s *KeeperTestSuite) SetupDeleteValidator() DeleteValidatorTestCase { - initialValidators := []*stakeibctypes.Validator{ - { - Name: "val1", - Address: "stride_VAL1", - Weight: 0, - Delegation: sdkmath.ZeroInt(), - SharesToTokensRate: sdk.OneDec(), - }, - { - Name: "val2", - Address: "stride_VAL2", - Weight: 0, - Delegation: sdkmath.ZeroInt(), - SharesToTokensRate: sdk.OneDec(), - }, - } - - hostZone := stakeibctypes.HostZone{ - ChainId: "GAIA", - Validators: initialValidators, - } - validMsgs := []stakeibctypes.MsgDeleteValidator{ - { - Creator: "stride_ADDRESS", - HostZone: "GAIA", - ValAddr: "stride_VAL1", - }, - { - Creator: "stride_ADDRESS", - HostZone: "GAIA", - ValAddr: "stride_VAL2", - }, - } - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - return DeleteValidatorTestCase{ - hostZone: hostZone, - initialValidators: initialValidators, - validMsgs: validMsgs, - } -} - -func (s *KeeperTestSuite) TestDeleteValidator_Successful() { - tc := s.SetupDeleteValidator() - - // Delete first validator - _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[0]) - s.Require().NoError(err) - - hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, "GAIA") - s.Require().True(found, "host zone found") - s.Require().Equal(1, len(hostZone.Validators), "number of validators should be 1") - s.Require().Equal(tc.initialValidators[1:], hostZone.Validators, "validators list after removing 1 validator") - - // Delete second validator - _, err = s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[1]) - s.Require().NoError(err) - - hostZone, found = s.App.StakeibcKeeper.GetHostZone(s.Ctx, "GAIA") - s.Require().True(found, "host zone found") - s.Require().Equal(0, len(hostZone.Validators), "number of validators should be 0") -} - -func (s *KeeperTestSuite) TestDeleteValidator_HostZoneNotFound() { - tc := s.SetupDeleteValidator() - - // Replace hostzone in msg to a host zone that doesn't exist - badHostZoneMsg := tc.validMsgs[0] - badHostZoneMsg.HostZone = "gaia" - _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &badHostZoneMsg) - errMsg := "Validator (stride_VAL1) not removed from host zone (gaia) " - errMsg += "| err: HostZone (gaia) not found: host zone not found: validator not removed" - s.Require().EqualError(err, errMsg) -} - -func (s *KeeperTestSuite) TestDeleteValidator_AddressNotFound() { - tc := s.SetupDeleteValidator() - - // Build message with a validator address that does not exist - badAddressMsg := tc.validMsgs[0] - badAddressMsg.ValAddr = "stride_VAL5" - _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &badAddressMsg) - - errMsg := "Validator (stride_VAL5) not removed from host zone (GAIA) " - errMsg += "| err: Validator address (stride_VAL5) not found on host zone (GAIA): " - errMsg += "validator not found: validator not removed" - s.Require().EqualError(err, errMsg) -} - -func (s *KeeperTestSuite) TestDeleteValidator_NonZeroDelegation() { - tc := s.SetupDeleteValidator() - - // Update val1 to have a non-zero delegation - hostZone := tc.hostZone - hostZone.Validators[0].Delegation = sdkmath.NewInt(1) - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[0]) - errMsg := "Validator (stride_VAL1) not removed from host zone (GAIA) " - errMsg += "| err: Validator (stride_VAL1) has non-zero delegation (1) or weight (0): " - errMsg += "validator not removed" - s.Require().EqualError(err, errMsg) -} - -func (s *KeeperTestSuite) TestDeleteValidator_NonZeroWeight() { - tc := s.SetupDeleteValidator() - - // Update val1 to have a non-zero weight - hostZone := tc.hostZone - hostZone.Validators[0].Weight = 1 - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[0]) - errMsg := "Validator (stride_VAL1) not removed from host zone (GAIA) " - errMsg += "| err: Validator (stride_VAL1) has non-zero delegation (0) or weight (1): " - errMsg += "validator not removed" - s.Require().EqualError(err, errMsg) -} diff --git a/x/stakeibc/keeper/msg_server_liquid_stake.go b/x/stakeibc/keeper/msg_server_liquid_stake.go deleted file mode 100644 index 169a74059f..0000000000 --- a/x/stakeibc/keeper/msg_server_liquid_stake.go +++ /dev/null @@ -1,110 +0,0 @@ -package keeper - -import ( - "context" - - sdk "github.com/cosmos/cosmos-sdk/types" - - errorsmod "cosmossdk.io/errors" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - - epochtypes "github.com/Stride-Labs/stride/v18/x/epochs/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -// Exchanges a user's native tokens for stTokens using the current redemption rate -// The native tokens must live on Stride with an IBC denomination before this function is called -// The typical flow consists, first, of a transfer of native tokens from the host zone to Stride, -// -// and then the invocation of this LiquidStake function -// -// WARNING: This function is invoked from the begin/end blocker in a way that does not revert partial state when -// -// an error is thrown (i.e. the execution is non-atomic). -// As a result, it is important that the validation steps are positioned at the top of the function, -// and logic that creates state changes (e.g. bank sends, mint) appear towards the end of the function -func (k msgServer) LiquidStake(goCtx context.Context, msg *types.MsgLiquidStake) (*types.MsgLiquidStakeResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - // Get the host zone from the base denom in the message (e.g. uatom) - hostZone, err := k.GetHostZoneFromHostDenom(ctx, msg.HostDenom) - if err != nil { - return nil, errorsmod.Wrapf(types.ErrInvalidToken, "no host zone found for denom (%s)", msg.HostDenom) - } - - // Error immediately if the host zone is halted - if hostZone.Halted { - return nil, errorsmod.Wrapf(types.ErrHaltedHostZone, "halted host zone found for denom (%s)", msg.HostDenom) - } - - // Get user and module account addresses - liquidStakerAddress, err := sdk.AccAddressFromBech32(msg.Creator) - if err != nil { - return nil, errorsmod.Wrapf(err, "user's address is invalid") - } - hostZoneDepositAddress, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) - if err != nil { - return nil, errorsmod.Wrapf(err, "host zone address is invalid") - } - - // Safety check: redemption rate must be within safety bounds - rateIsSafe, err := k.IsRedemptionRateWithinSafetyBounds(ctx, *hostZone) - if !rateIsSafe || (err != nil) { - return nil, errorsmod.Wrapf(types.ErrRedemptionRateOutsideSafetyBounds, "HostZone: %s, err: %s", hostZone.ChainId, err.Error()) - } - - // Grab the deposit record that will be used for record keeping - strideEpochTracker, found := k.GetEpochTracker(ctx, epochtypes.STRIDE_EPOCH) - if !found { - return nil, errorsmod.Wrapf(sdkerrors.ErrNotFound, "no epoch number for epoch (%s)", epochtypes.STRIDE_EPOCH) - } - depositRecord, found := k.RecordsKeeper.GetTransferDepositRecordByEpochAndChain(ctx, strideEpochTracker.EpochNumber, hostZone.ChainId) - if !found { - return nil, errorsmod.Wrapf(sdkerrors.ErrNotFound, "no deposit record for epoch (%d)", strideEpochTracker.EpochNumber) - } - - // The tokens that are sent to the protocol are denominated in the ibc hash of the native token on stride (e.g. ibc/xxx) - nativeDenom := hostZone.IbcDenom - nativeCoin := sdk.NewCoin(nativeDenom, msg.Amount) - if !types.IsIBCToken(nativeDenom) { - return nil, errorsmod.Wrapf(types.ErrInvalidToken, "denom is not an IBC token (%s)", nativeDenom) - } - - // Confirm the user has a sufficient balance to execute the liquid stake - balance := k.bankKeeper.GetBalance(ctx, liquidStakerAddress, nativeDenom) - if balance.IsLT(nativeCoin) { - return nil, errorsmod.Wrapf(sdkerrors.ErrInsufficientFunds, "balance is lower than staking amount. staking amount: %v, balance: %v", msg.Amount, balance.Amount) - } - - // Determine the amount of stTokens to mint using the redemption rate - stAmount := (sdk.NewDecFromInt(msg.Amount).Quo(hostZone.RedemptionRate)).TruncateInt() - if stAmount.IsZero() { - return nil, errorsmod.Wrapf(types.ErrInsufficientLiquidStake, - "Liquid stake of %s%s would return 0 stTokens", msg.Amount.String(), hostZone.HostDenom) - } - - // Transfer the native tokens from the user to module account - if err := k.bankKeeper.SendCoins(ctx, liquidStakerAddress, hostZoneDepositAddress, sdk.NewCoins(nativeCoin)); err != nil { - return nil, errorsmod.Wrap(err, "failed to send tokens from Account to Module") - } - - // Mint the stTokens and transfer them to the user - stDenom := types.StAssetDenomFromHostZoneDenom(msg.HostDenom) - stCoin := sdk.NewCoin(stDenom, stAmount) - if err := k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(stCoin)); err != nil { - return nil, errorsmod.Wrapf(err, "Failed to mint coins") - } - if err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, liquidStakerAddress, sdk.NewCoins(stCoin)); err != nil { - return nil, errorsmod.Wrapf(err, "Failed to send %s from module to account", stCoin.String()) - } - - // Update the liquid staked amount on the deposit record - depositRecord.Amount = depositRecord.Amount.Add(msg.Amount) - k.RecordsKeeper.SetDepositRecord(ctx, *depositRecord) - - // Emit liquid stake event - EmitSuccessfulLiquidStakeEvent(ctx, msg, *hostZone, stAmount) - - k.hooks.AfterLiquidStake(ctx, liquidStakerAddress) - return &types.MsgLiquidStakeResponse{StToken: stCoin}, nil -} diff --git a/x/stakeibc/keeper/msg_server_liquid_stake_test.go b/x/stakeibc/keeper/msg_server_liquid_stake_test.go deleted file mode 100644 index 87c928e47e..0000000000 --- a/x/stakeibc/keeper/msg_server_liquid_stake_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package keeper_test - -import ( - "fmt" - - sdkmath "cosmossdk.io/math" - sdk "github.com/cosmos/cosmos-sdk/types" - _ "github.com/stretchr/testify/suite" - - epochtypes "github.com/Stride-Labs/stride/v18/x/epochs/types" - recordtypes "github.com/Stride-Labs/stride/v18/x/records/types" - stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type Account struct { - acc sdk.AccAddress - atomBalance sdk.Coin - stAtomBalance sdk.Coin -} - -type LiquidStakeState struct { - depositRecordAmount sdkmath.Int - hostZone stakeibctypes.HostZone -} - -type LiquidStakeTestCase struct { - user Account - zoneAccount Account - initialState LiquidStakeState - validMsg stakeibctypes.MsgLiquidStake -} - -func (s *KeeperTestSuite) SetupLiquidStake() LiquidStakeTestCase { - stakeAmount := sdkmath.NewInt(1_000_000) - initialDepositAmount := sdkmath.NewInt(1_000_000) - user := Account{ - acc: s.TestAccs[0], - atomBalance: sdk.NewInt64Coin(IbcAtom, 10_000_000), - stAtomBalance: sdk.NewInt64Coin(StAtom, 0), - } - s.FundAccount(user.acc, user.atomBalance) - - depositAddress := stakeibctypes.NewHostZoneDepositAddress(HostChainId) - - zoneAccount := Account{ - acc: depositAddress, - atomBalance: sdk.NewInt64Coin(IbcAtom, 10_000_000), - stAtomBalance: sdk.NewInt64Coin(StAtom, 10_000_000), - } - s.FundAccount(zoneAccount.acc, zoneAccount.atomBalance) - s.FundAccount(zoneAccount.acc, zoneAccount.stAtomBalance) - - hostZone := stakeibctypes.HostZone{ - ChainId: HostChainId, - HostDenom: Atom, - IbcDenom: IbcAtom, - RedemptionRate: sdk.NewDec(1.0), - DepositAddress: depositAddress.String(), - } - - epochTracker := stakeibctypes.EpochTracker{ - EpochIdentifier: epochtypes.STRIDE_EPOCH, - EpochNumber: 1, - } - - initialDepositRecord := recordtypes.DepositRecord{ - Id: 1, - DepositEpochNumber: 1, - HostZoneId: "GAIA", - Amount: initialDepositAmount, - Status: recordtypes.DepositRecord_TRANSFER_QUEUE, - } - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, epochTracker) - s.App.RecordsKeeper.SetDepositRecord(s.Ctx, initialDepositRecord) - - return LiquidStakeTestCase{ - user: user, - zoneAccount: zoneAccount, - initialState: LiquidStakeState{ - depositRecordAmount: initialDepositAmount, - hostZone: hostZone, - }, - validMsg: stakeibctypes.MsgLiquidStake{ - Creator: user.acc.String(), - HostDenom: Atom, - Amount: stakeAmount, - }, - } -} - -func (s *KeeperTestSuite) TestLiquidStake_Successful() { - tc := s.SetupLiquidStake() - user := tc.user - zoneAccount := tc.zoneAccount - msg := tc.validMsg - initialStAtomSupply := s.App.BankKeeper.GetSupply(s.Ctx, StAtom) - - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err) - - // Confirm balances - // User IBC/UATOM balance should have DECREASED by the size of the stake - expectedUserAtomBalance := user.atomBalance.SubAmount(msg.Amount) - actualUserAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, IbcAtom) - // zoneAccount IBC/UATOM balance should have INCREASED by the size of the stake - expectedzoneAccountAtomBalance := zoneAccount.atomBalance.AddAmount(msg.Amount) - actualzoneAccountAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, zoneAccount.acc, IbcAtom) - // User STUATOM balance should have INCREASED by the size of the stake - expectedUserStAtomBalance := user.stAtomBalance.AddAmount(msg.Amount) - actualUserStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, StAtom) - // Bank supply of STUATOM should have INCREASED by the size of the stake - expectedBankSupply := initialStAtomSupply.AddAmount(msg.Amount) - actualBankSupply := s.App.BankKeeper.GetSupply(s.Ctx, StAtom) - - s.CompareCoins(expectedUserStAtomBalance, actualUserStAtomBalance, "user stuatom balance") - s.CompareCoins(expectedUserAtomBalance, actualUserAtomBalance, "user ibc/uatom balance") - s.CompareCoins(expectedzoneAccountAtomBalance, actualzoneAccountAtomBalance, "zoneAccount ibc/uatom balance") - s.CompareCoins(expectedBankSupply, actualBankSupply, "bank stuatom supply") - - // Confirm deposit record adjustment - records := s.App.RecordsKeeper.GetAllDepositRecord(s.Ctx) - s.Require().Len(records, 1, "number of deposit records") - - expectedDepositRecordAmount := tc.initialState.depositRecordAmount.Add(msg.Amount) - actualDepositRecordAmount := records[0].Amount - s.Require().Equal(expectedDepositRecordAmount, actualDepositRecordAmount, "deposit record amount") -} - -func (s *KeeperTestSuite) TestLiquidStake_DifferentRedemptionRates() { - tc := s.SetupLiquidStake() - user := tc.user - msg := tc.validMsg - - // Loop over sharesToTokens rates: {0.92, 0.94, ..., 1.2} - for i := -8; i <= 10; i += 2 { - redemptionDelta := sdk.NewDecWithPrec(1.0, 1).Quo(sdk.NewDec(10)).Mul(sdk.NewDec(int64(i))) // i = 2 => delta = 0.02 - newRedemptionRate := sdk.NewDec(1.0).Add(redemptionDelta) - redemptionRateFloat := newRedemptionRate - - // Update rate in host zone - hz := tc.initialState.hostZone - hz.RedemptionRate = newRedemptionRate - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) - - // Liquid stake for each balance and confirm stAtom minted - startingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, StAtom).Amount - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err) - endingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, StAtom).Amount - actualStAtomMinted := endingStAtomBalance.Sub(startingStAtomBalance) - - expectedStAtomMinted := sdk.NewDecFromInt(msg.Amount).Quo(redemptionRateFloat).TruncateInt() - testDescription := fmt.Sprintf("st atom balance for redemption rate: %v", redemptionRateFloat) - s.Require().Equal(expectedStAtomMinted, actualStAtomMinted, testDescription) - } -} - -func (s *KeeperTestSuite) TestLiquidStake_HostZoneNotFound() { - tc := s.SetupLiquidStake() - // Update message with invalid denom - invalidMsg := tc.validMsg - invalidMsg.HostDenom = "ufakedenom" - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - - s.Require().EqualError(err, "no host zone found for denom (ufakedenom): invalid token denom") -} - -func (s *KeeperTestSuite) TestLiquidStake_HostZoneHalted() { - tc := s.SetupLiquidStake() - - // Update the host zone so that it's halted - badHostZone := tc.initialState.hostZone - badHostZone.Halted = true - s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) - - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "halted host zone found for denom (uatom): Halted host zone found") -} - -func (s *KeeperTestSuite) TestLiquidStake_InvalidUserAddress() { - tc := s.SetupLiquidStake() - - // Update hostzone with invalid address - invalidMsg := tc.validMsg - invalidMsg.Creator = "cosmosXXX" - - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, "user's address is invalid: decoding bech32 failed: string not all lowercase or all uppercase") -} - -func (s *KeeperTestSuite) TestLiquidStake_InvalidHostAddress() { - tc := s.SetupLiquidStake() - - // Update hostzone with invalid address - badHostZone := tc.initialState.hostZone - badHostZone.DepositAddress = "cosmosXXX" - s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) - - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "host zone address is invalid: decoding bech32 failed: string not all lowercase or all uppercase") -} - -func (s *KeeperTestSuite) TestLiquidStake_RateBelowMinThreshold() { - tc := s.SetupLiquidStake() - msg := tc.validMsg - - // Update rate in host zone to below min threshold - hz := tc.initialState.hostZone - hz.RedemptionRate = sdk.MustNewDecFromStr("0.8") - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) - - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().Error(err) -} - -func (s *KeeperTestSuite) TestLiquidStake_RateAboveMaxThreshold() { - tc := s.SetupLiquidStake() - msg := tc.validMsg - - // Update rate in host zone to below min threshold - hz := tc.initialState.hostZone - hz.RedemptionRate = sdk.NewDec(2) - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) - - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().Error(err) -} - -func (s *KeeperTestSuite) TestLiquidStake_NoEpochTracker() { - tc := s.SetupLiquidStake() - // Remove epoch tracker - s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.STRIDE_EPOCH) - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - - s.Require().EqualError(err, fmt.Sprintf("no epoch number for epoch (%s): not found", epochtypes.STRIDE_EPOCH)) -} - -func (s *KeeperTestSuite) TestLiquidStake_NoDepositRecord() { - tc := s.SetupLiquidStake() - // Remove deposit record - s.App.RecordsKeeper.RemoveDepositRecord(s.Ctx, 1) - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - - s.Require().EqualError(err, fmt.Sprintf("no deposit record for epoch (%d): not found", 1)) -} - -func (s *KeeperTestSuite) TestLiquidStake_NotIbcDenom() { - tc := s.SetupLiquidStake() - // Update hostzone with non-ibc denom - badDenom := "i/uatom" - badHostZone := tc.initialState.hostZone - badHostZone.IbcDenom = badDenom - s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) - // Fund the user with the non-ibc denom - s.FundAccount(tc.user.acc, sdk.NewInt64Coin(badDenom, 1000000000)) - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - - s.Require().EqualError(err, fmt.Sprintf("denom is not an IBC token (%s): invalid token denom", badHostZone.IbcDenom)) -} - -func (s *KeeperTestSuite) TestLiquidStake_ZeroStTokens() { - tc := s.SetupLiquidStake() - - // Adjust redemption rate and liquid stake amount so that the number of stTokens would be zero - // stTokens = 1(amount) / 1.1(RR) = rounds down to 0 - hostZone := tc.initialState.hostZone - hostZone.RedemptionRate = sdk.NewDecWithPrec(11, 1) - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - tc.validMsg.Amount = sdkmath.NewInt(1) - - // The liquid stake should fail - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "Liquid stake of 1uatom would return 0 stTokens: Liquid staked amount is too small") -} - -func (s *KeeperTestSuite) TestLiquidStake_InsufficientBalance() { - tc := s.SetupLiquidStake() - // Set liquid stake amount to value greater than account balance - invalidMsg := tc.validMsg - balance := tc.user.atomBalance.Amount - invalidMsg.Amount = balance.Add(sdkmath.NewInt(1000)) - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - - expectedErr := fmt.Sprintf("balance is lower than staking amount. staking amount: %v, balance: %v: insufficient funds", balance.Add(sdkmath.NewInt(1000)), balance) - s.Require().EqualError(err, expectedErr) -} - -func (s *KeeperTestSuite) TestLiquidStake_HaltedZone() { - tc := s.SetupLiquidStake() - haltedHostZone := tc.initialState.hostZone - haltedHostZone.Halted = true - s.App.StakeibcKeeper.SetHostZone(s.Ctx, haltedHostZone) - s.FundAccount(tc.user.acc, sdk.NewInt64Coin(haltedHostZone.IbcDenom, 1000000000)) - _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - - s.Require().EqualError(err, fmt.Sprintf("halted host zone found for denom (%s): Halted host zone found", haltedHostZone.HostDenom)) -} diff --git a/x/stakeibc/keeper/msg_server_lsm_liquid_stake.go b/x/stakeibc/keeper/msg_server_lsm_liquid_stake.go deleted file mode 100644 index 9659458dd6..0000000000 --- a/x/stakeibc/keeper/msg_server_lsm_liquid_stake.go +++ /dev/null @@ -1,190 +0,0 @@ -package keeper - -import ( - "context" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - "github.com/cosmos/gogoproto/proto" - - icqtypes "github.com/Stride-Labs/stride/v18/x/interchainquery/types" - recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -// Exchanges a user's LSM tokenized shares for stTokens using the current redemption rate -// The LSM tokens must live on Stride as an IBC voucher (whose denomtrace we recognize) -// before this function is called -// -// The typical flow: -// - A staker tokenizes their delegation on the host zone -// - The staker IBC transfers their tokenized shares to Stride -// - They then call LSMLiquidStake -// - - The staker's LSM Tokens are sent to the Stride module account -// - - The staker recieves stTokens -// -// As a safety measure, at period checkpoints, the validator's sharesToTokens rate is queried and the transaction -// is not settled until the query returns -// As a result, this transaction has been split up into a (1) Start and (2) Finish function -// - If no query is needed, (2) is called immediately after (1) -// - If a query is needed, (2) is called in the query callback -// -// The transaction response indicates if the query occurred by returning an attribute `TransactionComplete` set to false -func (k msgServer) LSMLiquidStake(goCtx context.Context, msg *types.MsgLSMLiquidStake) (*types.MsgLSMLiquidStakeResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - lsmLiquidStake, err := k.StartLSMLiquidStake(ctx, *msg) - if err != nil { - return nil, err - } - - if k.ShouldCheckIfValidatorWasSlashed(ctx, *lsmLiquidStake.Validator, msg.Amount) { - if err := k.SubmitValidatorSlashQuery(ctx, lsmLiquidStake); err != nil { - return nil, err - } - - EmitPendingLSMLiquidStakeEvent(ctx, *lsmLiquidStake.HostZone, *lsmLiquidStake.Deposit) - - return &types.MsgLSMLiquidStakeResponse{TransactionComplete: false}, nil - } - - async := false - if err := k.FinishLSMLiquidStake(ctx, lsmLiquidStake, async); err != nil { - return nil, err - } - - return &types.MsgLSMLiquidStakeResponse{TransactionComplete: true}, nil -} - -// StartLSMLiquidStake runs the transactional logic that occurs before the optional query -// This includes validation on the LSM Token and the stToken amount calculation -func (k Keeper) StartLSMLiquidStake(ctx sdk.Context, msg types.MsgLSMLiquidStake) (types.LSMLiquidStake, error) { - // Validate the provided message parameters - including the denom and staker balance - lsmLiquidStake, err := k.ValidateLSMLiquidStake(ctx, msg) - if err != nil { - return types.LSMLiquidStake{}, err - } - hostZone := lsmLiquidStake.HostZone - - if hostZone.Halted { - return types.LSMLiquidStake{}, errorsmod.Wrapf(types.ErrHaltedHostZone, "host zone %s is halted", hostZone.ChainId) - } - - // Check if we already have tokens with this denom in records - _, found := k.RecordsKeeper.GetLSMTokenDeposit(ctx, hostZone.ChainId, lsmLiquidStake.Deposit.Denom) - if found { - return types.LSMLiquidStake{}, errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, - "there is already a previous record with this denom being processed: %s", lsmLiquidStake.Deposit.Denom) - } - - // Determine the amount of stTokens to mint using the redemption rate and the validator's sharesToTokens rate - // StTokens = LSMTokenShares * Validator SharesToTokens Rate / Redemption Rate - // Note: in the event of a slash query, these tokens will be minted only if the - // validator's sharesToTokens rate did not change - stCoin := k.CalculateLSMStToken(msg.Amount, lsmLiquidStake) - if stCoin.Amount.IsZero() { - return types.LSMLiquidStake{}, errorsmod.Wrapf(types.ErrInsufficientLiquidStake, - "Liquid stake of %s%s would return 0 stTokens", msg.Amount.String(), hostZone.HostDenom) - } - - // Add the stToken to this deposit record - lsmLiquidStake.Deposit.StToken = stCoin - k.RecordsKeeper.SetLSMTokenDeposit(ctx, *lsmLiquidStake.Deposit) - - return lsmLiquidStake, nil -} - -// SubmitValidatorSlashQuery submits an interchain query for the validator's sharesToTokens rate -// This is done periodically at checkpoints denominated in native tokens -// (e.g. every 100k ATOM that's LSM liquid staked with validator X) -func (k Keeper) SubmitValidatorSlashQuery(ctx sdk.Context, lsmLiquidStake types.LSMLiquidStake) error { - chainId := lsmLiquidStake.HostZone.ChainId - validatorAddress := lsmLiquidStake.Validator.Address - timeoutDuration := LSMSlashQueryTimeout - timeoutPolicy := icqtypes.TimeoutPolicy_EXECUTE_QUERY_CALLBACK - - // Build and serialize the callback data required to complete the LSM Liquid stake upon query callback - callbackData := types.ValidatorSharesToTokensQueryCallback{ - LsmLiquidStake: &lsmLiquidStake, - } - callbackDataBz, err := proto.Marshal(&callbackData) - if err != nil { - return errorsmod.Wrapf(err, "unable to serialize LSMLiquidStake struct for validator sharesToTokens rate query callback") - } - - return k.SubmitValidatorSharesToTokensRateICQ(ctx, chainId, validatorAddress, callbackDataBz, timeoutDuration, timeoutPolicy) -} - -// FinishLSMLiquidStake finishes the liquid staking flow by escrowing the LSM token, -// sending a user their stToken, and then IBC transfering the LSM Token to the host zone -// -// If the slash query interrupted the transaction, this function is called -// asynchronously after the query callback -// -// If no slash query was needed, this is called synchronously after StartLSMLiquidStake -// If this is run asynchronously, we need to re-validate the transaction info (e.g. staker's balance) -func (k Keeper) FinishLSMLiquidStake(ctx sdk.Context, lsmLiquidStake types.LSMLiquidStake, async bool) error { - hostZone := lsmLiquidStake.HostZone - lsmTokenDeposit := *lsmLiquidStake.Deposit - - // If the transaction was interrupted by the slash query, - // validate the LSM Liquid stake message parameters again - // The most significant check here is that the user still has sufficient balance for this LSM liquid stake - if async { - lsmLiquidStakeMsg := types.MsgLSMLiquidStake{ - Creator: lsmTokenDeposit.StakerAddress, - LsmTokenIbcDenom: lsmTokenDeposit.IbcDenom, - Amount: lsmTokenDeposit.Amount, - } - if _, err := k.ValidateLSMLiquidStake(ctx, lsmLiquidStakeMsg); err != nil { - return err - } - } - - // Get the staker's address and the host zone's deposit account address (which will custody the tokens) - liquidStakerAddress := sdk.MustAccAddressFromBech32(lsmTokenDeposit.StakerAddress) - hostZoneDepositAddress, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) - if err != nil { - return errorsmod.Wrapf(err, "host zone address is invalid") - } - - // Transfer the LSM token to the deposit account - lsmIBCToken := sdk.NewCoin(lsmTokenDeposit.IbcDenom, lsmTokenDeposit.Amount) - if err := k.bankKeeper.SendCoins(ctx, liquidStakerAddress, hostZoneDepositAddress, sdk.NewCoins(lsmIBCToken)); err != nil { - return errorsmod.Wrap(err, "failed to send tokens from Account to Module") - } - - // Mint stToken and send to the user - stToken := sdk.NewCoins(lsmTokenDeposit.StToken) - if err := k.bankKeeper.MintCoins(ctx, types.ModuleName, stToken); err != nil { - return errorsmod.Wrapf(err, "Failed to mint stTokens") - } - if err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, liquidStakerAddress, stToken); err != nil { - return errorsmod.Wrapf(err, "Failed to send %s from module to account", lsmTokenDeposit.StToken.String()) - } - - // Get delegation account address as the destination for the LSM Token - if hostZone.DelegationIcaAddress == "" { - return errorsmod.Wrapf(types.ErrICAAccountNotFound, "no delegation address found for %s", hostZone.ChainId) - } - - // Update the deposit status - k.RecordsKeeper.UpdateLSMTokenDepositStatus(ctx, lsmTokenDeposit, recordstypes.LSMTokenDeposit_TRANSFER_QUEUE) - - // Update the slash query progress on the validator - if err := k.IncrementValidatorSlashQueryProgress( - ctx, - hostZone.ChainId, - lsmTokenDeposit.ValidatorAddress, - lsmTokenDeposit.Amount, - ); err != nil { - return err - } - - // Emit an LSM liquid stake event - EmitSuccessfulLSMLiquidStakeEvent(ctx, *hostZone, lsmTokenDeposit) - - k.hooks.AfterLiquidStake(ctx, liquidStakerAddress) - return nil -} diff --git a/x/stakeibc/keeper/msg_server_lsm_liquid_stake_test.go b/x/stakeibc/keeper/msg_server_lsm_liquid_stake_test.go deleted file mode 100644 index 9ced44c20b..0000000000 --- a/x/stakeibc/keeper/msg_server_lsm_liquid_stake_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package keeper_test - -import ( - "fmt" - - "github.com/cosmos/gogoproto/proto" - - sdkmath "cosmossdk.io/math" - sdk "github.com/cosmos/cosmos-sdk/types" - transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" - ibctesting "github.com/cosmos/ibc-go/v7/testing" - - icqtypes "github.com/Stride-Labs/stride/v18/x/interchainquery/types" - recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/keeper" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type LSMLiquidStakeTestCase struct { - hostZone types.HostZone - liquidStakerAddress sdk.AccAddress - depositAddress sdk.AccAddress - initialBalance sdkmath.Int - initialQueryProgress sdkmath.Int - queryCheckpoint sdkmath.Int - lsmTokenIBCDenom string - validMsg *types.MsgLSMLiquidStake -} - -// Helper function to add the port and channel onto the LSMTokenBaseDenom, -// hash it, and then store the trace in the IBC store -// Returns the ibc hash -func (s *KeeperTestSuite) getLSMTokenIBCDenom() string { - sourcePrefix := transfertypes.GetDenomPrefix(transfertypes.PortID, ibctesting.FirstChannelID) - prefixedDenom := sourcePrefix + LSMTokenBaseDenom - lsmTokenDenomTrace := transfertypes.ParseDenomTrace(prefixedDenom) - s.App.TransferKeeper.SetDenomTrace(s.Ctx, lsmTokenDenomTrace) - return lsmTokenDenomTrace.IBCDenom() -} - -func (s *KeeperTestSuite) SetupTestLSMLiquidStake() LSMLiquidStakeTestCase { - initialBalance := sdkmath.NewInt(3000) - stakeAmount := sdkmath.NewInt(1000) - userAddress := s.TestAccs[0] - depositAddress := types.NewHostZoneDepositAddress(HostChainId) - - // Need valid IBC denom here to test parsing - lsmTokenIBCDenom := s.getLSMTokenIBCDenom() - - // Fund the user's account with the LSM token - s.FundAccount(userAddress, sdk.NewCoin(lsmTokenIBCDenom, initialBalance)) - - // Add the slash interval - // TVL: 100k, Checkpoint: 1% of 1M = 10k - // Progress towards query: 8000 - // => Liquid Stake of 2k will trip query - totalHostZoneStake := sdkmath.NewInt(1_000_000) - queryCheckpoint := sdkmath.NewInt(10_000) - progressTowardsQuery := sdkmath.NewInt(8000) - params := types.DefaultParams() - params.ValidatorSlashQueryThreshold = 1 // 1 % - s.App.StakeibcKeeper.SetParams(s.Ctx, params) - - // Sanity check - onePercent := sdk.MustNewDecFromStr("0.01") - s.Require().Equal(queryCheckpoint.Int64(), onePercent.Mul(sdk.NewDecFromInt(totalHostZoneStake)).TruncateInt64(), - "setup failed - query checkpoint must be 1% of total host zone stake") - - // Add the host zone with a valid zone address as the LSM custodian - hostZone := types.HostZone{ - ChainId: HostChainId, - HostDenom: Atom, - RedemptionRate: sdk.NewDec(1.0), - DepositAddress: depositAddress.String(), - TransferChannelId: ibctesting.FirstChannelID, - ConnectionId: ibctesting.FirstConnectionID, - TotalDelegations: totalHostZoneStake, - Validators: []*types.Validator{{ - Address: ValAddress, - SlashQueryProgressTracker: progressTowardsQuery, - SlashQueryCheckpoint: queryCheckpoint, - SharesToTokensRate: sdk.OneDec(), - }}, - DelegationIcaAddress: "cosmos_DELEGATION", - LsmLiquidStakeEnabled: true, - } - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - // Mock the latest client height for the ICQ submission - s.MockClientLatestHeight(1) - - return LSMLiquidStakeTestCase{ - hostZone: hostZone, - liquidStakerAddress: userAddress, - depositAddress: depositAddress, - initialBalance: initialBalance, - initialQueryProgress: progressTowardsQuery, - queryCheckpoint: queryCheckpoint, - lsmTokenIBCDenom: lsmTokenIBCDenom, - validMsg: &types.MsgLSMLiquidStake{ - Creator: userAddress.String(), - LsmTokenIbcDenom: lsmTokenIBCDenom, - Amount: stakeAmount, - }, - } -} - -func (s *KeeperTestSuite) TestLSMLiquidStake_Successful_NoSharesToTokensRateQuery() { - tc := s.SetupTestLSMLiquidStake() - - // Call LSM Liquid stake with a valid message - msgResponse, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) - s.Require().NoError(err, "no error expected when calling lsm liquid stake") - s.Require().True(msgResponse.TransactionComplete, "transaction should be complete") - - // Confirm the LSM token was sent to the protocol - userLsmBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, tc.lsmTokenIBCDenom) - s.Require().Equal(tc.initialBalance.Sub(tc.validMsg.Amount).Int64(), userLsmBalance.Amount.Int64(), - "lsm token balance of user account") - - // Confirm stToken was sent to the user - userStTokenBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom) - s.Require().Equal(tc.validMsg.Amount.Int64(), userStTokenBalance.Amount.Int64(), "user stToken balance") - - // Confirm an LSMDeposit was created - expectedDepositId := keeper.GetLSMTokenDepositId(s.Ctx.BlockHeight(), HostChainId, tc.validMsg.Creator, LSMTokenBaseDenom) - expectedDeposit := recordstypes.LSMTokenDeposit{ - DepositId: expectedDepositId, - ChainId: HostChainId, - Denom: LSMTokenBaseDenom, - StakerAddress: s.TestAccs[0].String(), - IbcDenom: tc.lsmTokenIBCDenom, - ValidatorAddress: ValAddress, - Amount: tc.validMsg.Amount, - Status: recordstypes.LSMTokenDeposit_TRANSFER_QUEUE, - StToken: sdk.NewCoin(StAtom, tc.validMsg.Amount), - } - actualDeposit, found := s.App.RecordsKeeper.GetLSMTokenDeposit(s.Ctx, HostChainId, LSMTokenBaseDenom) - s.Require().True(found, "lsm token deposit should have been found after LSM liquid stake") - s.Require().Equal(expectedDeposit, actualDeposit) - - // Confirm slash query progress was incremented - hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) - expectedQueryProgress := tc.initialQueryProgress.Add(tc.validMsg.Amount) - s.Require().True(found, "host zone should have been found") - s.Require().Equal(expectedQueryProgress.Int64(), hostZone.Validators[0].SlashQueryProgressTracker.Int64(), "slash query progress") -} - -func (s *KeeperTestSuite) TestLSMLiquidStake_Successful_WithSharesToTokensRateQuery() { - tc := s.SetupTestLSMLiquidStake() - - // Increase the liquid stake size so that it breaks the query checkpoint - // queryProgressSlack is the remaining amount that can be staked in one message before a slash query is issued - queryProgressSlack := tc.queryCheckpoint.Sub(tc.initialQueryProgress) - tc.validMsg.Amount = queryProgressSlack.Add(sdk.NewInt(1000)) - - // Call LSM Liquid stake - msgResponse, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) - s.Require().NoError(err, "no error expected when calling lsm liquid stake") - s.Require().False(msgResponse.TransactionComplete, "transaction should still be pending") - - // Confirm stToken was NOT sent to the user - userStTokenBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom) - s.Require().True(userStTokenBalance.Amount.IsZero(), "user stToken balance") - - // Confirm query was submitted - allQueries := s.App.InterchainqueryKeeper.AllQueries(s.Ctx) - s.Require().Len(allQueries, 1) - - // Confirm query metadata - actualQuery := allQueries[0] - s.Require().Equal(HostChainId, actualQuery.ChainId, "query chain-id") - s.Require().Equal(ibctesting.FirstConnectionID, actualQuery.ConnectionId, "query connection-id") - s.Require().Equal(icqtypes.STAKING_STORE_QUERY_WITH_PROOF, actualQuery.QueryType, "query types") - - s.Require().Equal(types.ModuleName, actualQuery.CallbackModule, "callback module") - s.Require().Equal(keeper.ICQCallbackID_Validator, actualQuery.CallbackId, "callback-id") - - expectedTimeout := uint64(s.Ctx.BlockTime().UnixNano() + (keeper.LSMSlashQueryTimeout).Nanoseconds()) - s.Require().Equal(keeper.LSMSlashQueryTimeout, actualQuery.TimeoutDuration, "timeout duration") - s.Require().Equal(int64(expectedTimeout), int64(actualQuery.TimeoutTimestamp), "timeout timestamp") - - // Confirm query callback data - s.Require().True(len(actualQuery.CallbackData) > 0, "callback data exists") - - expectedStToken := sdk.NewCoin(StAtom, tc.validMsg.Amount) - expectedDepositId := keeper.GetLSMTokenDepositId(s.Ctx.BlockHeight(), HostChainId, tc.validMsg.Creator, LSMTokenBaseDenom) - expectedLSMTokenDeposit := recordstypes.LSMTokenDeposit{ - DepositId: expectedDepositId, - ChainId: HostChainId, - Denom: LSMTokenBaseDenom, - IbcDenom: tc.lsmTokenIBCDenom, - StakerAddress: tc.validMsg.Creator, - ValidatorAddress: ValAddress, - Amount: tc.validMsg.Amount, - StToken: expectedStToken, - Status: recordstypes.LSMTokenDeposit_DEPOSIT_PENDING, - } - - var actualCallbackData types.ValidatorSharesToTokensQueryCallback - err = proto.Unmarshal(actualQuery.CallbackData, &actualCallbackData) - s.Require().NoError(err, "no error expected when unmarshalling query callback data") - - lsmLiquidStake := actualCallbackData.LsmLiquidStake - s.Require().Equal(HostChainId, lsmLiquidStake.HostZone.ChainId, "callback data - host zone") - s.Require().Equal(ValAddress, lsmLiquidStake.Validator.Address, "callback data - validator") - - s.Require().Equal(expectedLSMTokenDeposit, *lsmLiquidStake.Deposit, "callback data - deposit") -} - -func (s *KeeperTestSuite) TestLSMLiquidStake_DifferentRedemptionRates() { - tc := s.SetupTestLSMLiquidStake() - tc.validMsg.Amount = sdk.NewInt(100) // reduce the stake amount to prevent insufficient balance error - - // Loop over sharesToTokens rates: {0.92, 0.94, ..., 1.2} - interval := sdk.MustNewDecFromStr("0.01") - for i := -8; i <= 10; i += 2 { - redemptionDelta := interval.Mul(sdk.NewDec(int64(i))) // i = 2 => delta = 0.02 - newRedemptionRate := sdk.NewDec(1.0).Add(redemptionDelta) - redemptionRateFloat := newRedemptionRate - - // Update rate in host zone - hz := tc.hostZone - hz.RedemptionRate = newRedemptionRate - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) - - // Liquid stake for each balance and confirm stAtom minted - startingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom).Amount - _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) - s.Require().NoError(err) - endingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom).Amount - actualStAtomMinted := endingStAtomBalance.Sub(startingStAtomBalance) - - expectedStAtomMinted := sdk.NewDecFromInt(tc.validMsg.Amount).Quo(redemptionRateFloat).TruncateInt() - testDescription := fmt.Sprintf("st atom balance for redemption rate: %v", redemptionRateFloat) - s.Require().Equal(expectedStAtomMinted, actualStAtomMinted, testDescription) - - // Cleanup the LSMTokenDeposit record to prevent an error on the next run - s.App.RecordsKeeper.RemoveLSMTokenDeposit(s.Ctx, HostChainId, LSMTokenBaseDenom) - } -} - -func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_NotIBCDenom() { - tc := s.SetupTestLSMLiquidStake() - - // Change the message so that the denom is not an IBC token - invalidMsg := tc.validMsg - invalidMsg.LsmTokenIbcDenom = "fake_ibc_denom" - - _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), invalidMsg) - s.Require().ErrorContains(err, "lsm token is not an IBC token (fake_ibc_denom)") -} - -func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_HostZoneNotFound() { - tc := s.SetupTestLSMLiquidStake() - - // Change the message so that the denom is an IBC denom from a channel that is not supported - sourcePrefix := transfertypes.GetDenomPrefix(transfertypes.PortID, "channel-1") - prefixedDenom := sourcePrefix + LSMTokenBaseDenom - lsmTokenDenomTrace := transfertypes.ParseDenomTrace(prefixedDenom) - s.App.TransferKeeper.SetDenomTrace(s.Ctx, lsmTokenDenomTrace) - - invalidMsg := tc.validMsg - invalidMsg.LsmTokenIbcDenom = lsmTokenDenomTrace.IBCDenom() - - _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), invalidMsg) - s.Require().ErrorContains(err, "transfer channel-id from LSM token (channel-1) does not match any registered host zone") -} - -func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_ValidatorNotFound() { - tc := s.SetupTestLSMLiquidStake() - - // Change the message so that the base denom is from a non-existent validator - sourcePrefix := transfertypes.GetDenomPrefix(transfertypes.PortID, ibctesting.FirstChannelID) - prefixedDenom := sourcePrefix + "cosmosvaloperXXX/42" - lsmTokenDenomTrace := transfertypes.ParseDenomTrace(prefixedDenom) - s.App.TransferKeeper.SetDenomTrace(s.Ctx, lsmTokenDenomTrace) - - invalidMsg := tc.validMsg - invalidMsg.LsmTokenIbcDenom = lsmTokenDenomTrace.IBCDenom() - - _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), invalidMsg) - s.Require().ErrorContains(err, "validator (cosmosvaloperXXX) is not registered in the Stride validator set") -} - -func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_DepositAlreadyExists() { - tc := s.SetupTestLSMLiquidStake() - - // Set a deposit with the same chainID and denom in the store - s.App.RecordsKeeper.SetLSMTokenDeposit(s.Ctx, recordstypes.LSMTokenDeposit{ - ChainId: HostChainId, - Denom: LSMTokenBaseDenom, - }) - - _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) - s.Require().ErrorContains(err, "there is already a previous record with this denom being processed") -} - -func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_InvalidDepositAddress() { - tc := s.SetupTestLSMLiquidStake() - - // Remove the host zones address from the store - invalidHostZone := tc.hostZone - invalidHostZone.DepositAddress = "" - s.App.StakeibcKeeper.SetHostZone(s.Ctx, invalidHostZone) - - _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) - s.Require().ErrorContains(err, "host zone address is invalid") -} - -func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_InsufficientBalance() { - tc := s.SetupTestLSMLiquidStake() - - // Send out all the user's coins so that they have an insufficient balance of LSM tokens - initialBalanceCoin := sdk.NewCoins(sdk.NewCoin(tc.lsmTokenIBCDenom, tc.initialBalance)) - err := s.App.BankKeeper.SendCoins(s.Ctx, tc.liquidStakerAddress, s.TestAccs[1], initialBalanceCoin) - s.Require().NoError(err) - - _, err = s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) - s.Require().ErrorContains(err, "insufficient funds") -} - -func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_ZeroStTokens() { - tc := s.SetupTestLSMLiquidStake() - - // Adjust redemption rate and liquid stake amount so that the number of stTokens would be zero - // stTokens = 1(amount) / 1.1(RR) = rounds down to 0 - hostZone := tc.hostZone - hostZone.RedemptionRate = sdk.NewDecWithPrec(11, 1) - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - tc.validMsg.Amount = sdkmath.NewInt(1) - - // The liquid stake should fail - _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) - s.Require().EqualError(err, "Liquid stake of 1uatom would return 0 stTokens: Liquid staked amount is too small") -} diff --git a/x/stakeibc/keeper/msg_server_rebalance_validators.go b/x/stakeibc/keeper/msg_server_rebalance_validators.go deleted file mode 100644 index 624cbd893e..0000000000 --- a/x/stakeibc/keeper/msg_server_rebalance_validators.go +++ /dev/null @@ -1,20 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (k msgServer) RebalanceValidators(goCtx context.Context, msg *types.MsgRebalanceValidators) (*types.MsgRebalanceValidatorsResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - k.Logger(ctx).Info(fmt.Sprintf("RebalanceValidators executing %v", msg)) - - if err := k.RebalanceDelegationsForHostZone(ctx, msg.HostZone); err != nil { - return nil, err - } - return &types.MsgRebalanceValidatorsResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_redeem_stake.go b/x/stakeibc/keeper/msg_server_redeem_stake.go deleted file mode 100644 index 04a4b852f3..0000000000 --- a/x/stakeibc/keeper/msg_server_redeem_stake.go +++ /dev/null @@ -1,154 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - - "github.com/Stride-Labs/stride/v18/utils" -) - -func (k msgServer) RedeemStake(goCtx context.Context, msg *types.MsgRedeemStake) (*types.MsgRedeemStakeResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - k.Logger(ctx).Info(fmt.Sprintf("redeem stake: %s", msg.String())) - - // ----------------- PRELIMINARY CHECKS ----------------- - // get our addresses, make sure they're valid - sender, err := sdk.AccAddressFromBech32(msg.Creator) - if err != nil { - return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, "creator address is invalid: %s. err: %s", msg.Creator, err.Error()) - } - // then make sure host zone is valid - hostZone, found := k.GetHostZone(ctx, msg.HostZone) - if !found { - return nil, errorsmod.Wrapf(types.ErrInvalidHostZone, "host zone is invalid: %s", msg.HostZone) - } - - if hostZone.Halted { - k.Logger(ctx).Error(fmt.Sprintf("Host Zone halted for zone (%s)", msg.HostZone)) - return nil, errorsmod.Wrapf(types.ErrHaltedHostZone, "halted host zone found for zone (%s)", msg.HostZone) - } - - // first construct a user redemption record - epochTracker, found := k.GetEpochTracker(ctx, "day") - if !found { - return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "epoch tracker found: %s", "day") - } - - // ensure the recipient address is a valid bech32 address on the hostZone - _, err = utils.AccAddressFromBech32(msg.Receiver, hostZone.Bech32Prefix) - if err != nil { - return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, "invalid receiver address (%s)", err) - } - - // construct desired unstaking amount from host zone - // TODO [cleanup]: Consider changing to truncate int - stDenom := types.StAssetDenomFromHostZoneDenom(hostZone.HostDenom) - nativeAmount := sdk.NewDecFromInt(msg.Amount).Mul(hostZone.RedemptionRate).RoundInt() - - if nativeAmount.GT(hostZone.TotalDelegations) { - return nil, errorsmod.Wrapf(types.ErrInvalidAmount, "cannot unstake an amount g.t. staked balance on host zone: %v", msg.Amount) - } - - // safety check: redemption rate must be within safety bounds - rateIsSafe, err := k.IsRedemptionRateWithinSafetyBounds(ctx, hostZone) - if !rateIsSafe || (err != nil) { - errMsg := fmt.Sprintf("IsRedemptionRateWithinSafetyBounds check failed. hostZone: %s, err: %s", hostZone.String(), err.Error()) - return nil, errorsmod.Wrapf(types.ErrRedemptionRateOutsideSafetyBounds, errMsg) - } - - // safety checks on the coin - // - Redemption amount must be positive - if !nativeAmount.IsPositive() { - return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidCoins, "amount must be greater than 0. found: %v", msg.Amount) - } - // - Creator owns at least "amount" stAssets - balance := k.bankKeeper.GetBalance(ctx, sender, stDenom) - if balance.Amount.LT(msg.Amount) { - return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidCoins, "balance is lower than redemption amount. redemption amount: %v, balance %v: ", msg.Amount, balance.Amount) - } - - // ----------------- UNBONDING RECORD KEEPING ----------------- - // Fetch the record - redemptionId := recordstypes.UserRedemptionRecordKeyFormatter(hostZone.ChainId, epochTracker.EpochNumber, msg.Receiver) - userRedemptionRecord, userHasRedeemedThisEpoch := k.RecordsKeeper.GetUserRedemptionRecord(ctx, redemptionId) - if userHasRedeemedThisEpoch { - k.Logger(ctx).Info(fmt.Sprintf("UserRedemptionRecord found for %s", redemptionId)) - // Add the unbonded amount to the UserRedemptionRecord - // The record is set below - userRedemptionRecord.StTokenAmount = userRedemptionRecord.StTokenAmount.Add(msg.Amount) - userRedemptionRecord.NativeTokenAmount = userRedemptionRecord.NativeTokenAmount.Add(nativeAmount) - } else { - // First time a user is redeeming this epoch - userRedemptionRecord = recordstypes.UserRedemptionRecord{ - Id: redemptionId, - Receiver: msg.Receiver, - NativeTokenAmount: nativeAmount, - Denom: hostZone.HostDenom, - HostZoneId: hostZone.ChainId, - EpochNumber: epochTracker.EpochNumber, - StTokenAmount: msg.Amount, - // claimIsPending represents whether a redemption is currently being claimed, - // contingent on the host zone unbonding having status CLAIMABLE - ClaimIsPending: false, - } - k.Logger(ctx).Info(fmt.Sprintf("UserRedemptionRecord not found - creating for %s", redemptionId)) - } - - // then add undelegation amount to epoch unbonding records - epochUnbondingRecord, found := k.RecordsKeeper.GetEpochUnbondingRecord(ctx, epochTracker.EpochNumber) - if !found { - k.Logger(ctx).Error("latest epoch unbonding record not found") - return nil, errorsmod.Wrapf(recordstypes.ErrEpochUnbondingRecordNotFound, "latest epoch unbonding record not found") - } - // get relevant host zone on this epoch unbonding record - hostZoneUnbonding, found := k.RecordsKeeper.GetHostZoneUnbondingByChainId(ctx, epochUnbondingRecord.EpochNumber, hostZone.ChainId) - if !found { - return nil, errorsmod.Wrapf(types.ErrInvalidHostZone, "host zone not found in unbondings: %s", hostZone.ChainId) - } - hostZoneUnbonding.NativeTokenAmount = hostZoneUnbonding.NativeTokenAmount.Add(nativeAmount) - if !userHasRedeemedThisEpoch { - // Only append a UserRedemptionRecord to the HZU if it wasn't previously appended - hostZoneUnbonding.UserRedemptionRecords = append(hostZoneUnbonding.UserRedemptionRecords, userRedemptionRecord.Id) - } - - // Escrow user's balance - redeemCoin := sdk.NewCoins(sdk.NewCoin(stDenom, msg.Amount)) - depositAddress, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) - if err != nil { - return nil, fmt.Errorf("could not bech32 decode address %s of zone with id: %s", hostZone.DepositAddress, hostZone.ChainId) - } - err = k.bankKeeper.SendCoins(ctx, sender, depositAddress, redeemCoin) - if err != nil { - k.Logger(ctx).Error("Failed to send sdk.NewCoins(inCoins) from account to module") - return nil, errorsmod.Wrapf(types.ErrInsufficientFunds, "couldn't send %v derivative %s tokens to module account. err: %s", msg.Amount, hostZone.HostDenom, err.Error()) - } - - // record the number of stAssets that should be burned after unbonding - hostZoneUnbonding.StTokenAmount = hostZoneUnbonding.StTokenAmount.Add(msg.Amount) - - // Actually set the records, we wait until now to prevent any errors - k.RecordsKeeper.SetUserRedemptionRecord(ctx, userRedemptionRecord) - - // Set the UserUnbondingRecords on the proper HostZoneUnbondingRecord - hostZoneUnbondings := epochUnbondingRecord.GetHostZoneUnbondings() - if hostZoneUnbondings == nil { - hostZoneUnbondings = []*recordstypes.HostZoneUnbonding{} - epochUnbondingRecord.HostZoneUnbondings = hostZoneUnbondings - } - updatedEpochUnbondingRecord, success := k.RecordsKeeper.AddHostZoneToEpochUnbondingRecord(ctx, epochUnbondingRecord.EpochNumber, hostZone.ChainId, hostZoneUnbonding) - if !success { - k.Logger(ctx).Error(fmt.Sprintf("Failed to set host zone epoch unbonding record: epochNumber %d, chainId %s, hostZoneUnbonding %v", epochUnbondingRecord.EpochNumber, hostZone.ChainId, hostZoneUnbonding)) - return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "couldn't set host zone epoch unbonding record. err: %s", err.Error()) - } - k.RecordsKeeper.SetEpochUnbondingRecord(ctx, *updatedEpochUnbondingRecord) - - k.Logger(ctx).Info(fmt.Sprintf("executed redeem stake: %s", msg.String())) - return &types.MsgRedeemStakeResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_redeem_stake_test.go b/x/stakeibc/keeper/msg_server_redeem_stake_test.go deleted file mode 100644 index 455e845f75..0000000000 --- a/x/stakeibc/keeper/msg_server_redeem_stake_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package keeper_test - -import ( - "fmt" - - sdkmath "cosmossdk.io/math" - sdk "github.com/cosmos/cosmos-sdk/types" - _ "github.com/stretchr/testify/suite" - - epochtypes "github.com/Stride-Labs/stride/v18/x/epochs/types" - recordtypes "github.com/Stride-Labs/stride/v18/x/records/types" - stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type RedeemStakeState struct { - epochNumber uint64 - initialNativeEpochUnbondingAmount sdkmath.Int - initialStTokenEpochUnbondingAmount sdkmath.Int -} -type RedeemStakeTestCase struct { - user Account - hostZone stakeibctypes.HostZone - zoneAccount Account - initialState RedeemStakeState - validMsg stakeibctypes.MsgRedeemStake - expectedNativeAmount sdkmath.Int -} - -func (s *KeeperTestSuite) SetupRedeemStake() RedeemStakeTestCase { - redeemAmount := sdkmath.NewInt(1_000_000) - redemptionRate := sdk.MustNewDecFromStr("1.5") - expectedNativeAmount := sdkmath.NewInt(1_500_000) - - user := Account{ - acc: s.TestAccs[0], - atomBalance: sdk.NewInt64Coin("ibc/uatom", 10_000_000), - stAtomBalance: sdk.NewInt64Coin("stuatom", 10_000_000), - } - s.FundAccount(user.acc, user.atomBalance) - s.FundAccount(user.acc, user.stAtomBalance) - - depositAddress := stakeibctypes.NewHostZoneDepositAddress(HostChainId) - - zoneAccount := Account{ - acc: depositAddress, - atomBalance: sdk.NewInt64Coin("ibc/uatom", 10_000_000), - stAtomBalance: sdk.NewInt64Coin("stuatom", 10_000_000), - } - s.FundAccount(zoneAccount.acc, zoneAccount.atomBalance) - s.FundAccount(zoneAccount.acc, zoneAccount.stAtomBalance) - - // TODO define the host zone with total delegation and validators with staked amounts - hostZone := stakeibctypes.HostZone{ - ChainId: HostChainId, - HostDenom: "uatom", - Bech32Prefix: "cosmos", - RedemptionRate: redemptionRate, - TotalDelegations: sdkmath.NewInt(1234567890), - DepositAddress: depositAddress.String(), - } - - epochTrackerDay := stakeibctypes.EpochTracker{ - EpochIdentifier: epochtypes.DAY_EPOCH, - EpochNumber: 1, - } - - epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ - EpochNumber: 1, - HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{}, - } - - hostZoneUnbonding := &recordtypes.HostZoneUnbonding{ - NativeTokenAmount: sdkmath.ZeroInt(), - Denom: "uatom", - HostZoneId: HostChainId, - Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, - } - epochUnbondingRecord.HostZoneUnbondings = append(epochUnbondingRecord.HostZoneUnbondings, hostZoneUnbonding) - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, epochTrackerDay) - s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) - - return RedeemStakeTestCase{ - user: user, - hostZone: hostZone, - zoneAccount: zoneAccount, - expectedNativeAmount: expectedNativeAmount, - initialState: RedeemStakeState{ - epochNumber: epochTrackerDay.EpochNumber, - initialNativeEpochUnbondingAmount: sdkmath.ZeroInt(), - initialStTokenEpochUnbondingAmount: sdkmath.ZeroInt(), - }, - validMsg: stakeibctypes.MsgRedeemStake{ - Creator: user.acc.String(), - Amount: redeemAmount, - HostZone: HostChainId, - // TODO set this dynamically through test helpers for host zone - Receiver: "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8uf", - }, - } -} - -func (s *KeeperTestSuite) TestRedeemStake_Successful() { - tc := s.SetupRedeemStake() - initialState := tc.initialState - - msg := tc.validMsg - user := tc.user - redeemAmount := msg.Amount - - // Split the message amount in 2, and call redeem stake twice (each with half the amount) - // This will check that the same user can redeem multiple times - msg1 := msg - msg1.Amount = msg1.Amount.Quo(sdkmath.NewInt(2)) // half the amount - - msg2 := msg - msg2.Amount = msg.Amount.Sub(msg1.Amount) // remaining half - - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &msg1) - s.Require().NoError(err, "no error expected during first redemption") - - _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &msg2) - s.Require().NoError(err, "no error expected during second redemption") - - // User STUATOM balance should have DECREASED by the amount to be redeemed - expectedUserStAtomBalance := user.stAtomBalance.SubAmount(redeemAmount) - actualUserStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, "stuatom") - s.CompareCoins(expectedUserStAtomBalance, actualUserStAtomBalance, "user stuatom balance") - - // Gaia's hostZoneUnbonding NATIVE TOKEN amount should have INCREASED from 0 to the amount redeemed multiplied by the redemption rate - // Gaia's hostZoneUnbonding STTOKEN amount should have INCREASED from 0 to be amount redeemed - epochTracker, found := s.App.StakeibcKeeper.GetEpochTracker(s.Ctx, "day") - s.Require().True(found, "epoch tracker") - epochUnbondingRecord, found := s.App.RecordsKeeper.GetEpochUnbondingRecord(s.Ctx, epochTracker.EpochNumber) - s.Require().True(found, "epoch unbonding record") - hostZoneUnbonding, found := s.App.RecordsKeeper.GetHostZoneUnbondingByChainId(s.Ctx, epochUnbondingRecord.EpochNumber, HostChainId) - s.Require().True(found, "host zone unbondings by chain ID") - - expectedHostZoneUnbondingNativeAmount := initialState.initialNativeEpochUnbondingAmount.Add(tc.expectedNativeAmount) - expectedHostZoneUnbondingStTokenAmount := initialState.initialStTokenEpochUnbondingAmount.Add(redeemAmount) - - s.Require().Equal(expectedHostZoneUnbondingNativeAmount, hostZoneUnbonding.NativeTokenAmount, "host zone native unbonding amount") - s.Require().Equal(expectedHostZoneUnbondingStTokenAmount, hostZoneUnbonding.StTokenAmount, "host zone stToken burn amount") - - // UserRedemptionRecord should have been created with correct amount, sender, receiver, host zone, claimIsPending - userRedemptionRecords := hostZoneUnbonding.UserRedemptionRecords - s.Require().Equal(len(userRedemptionRecords), 1) - userRedemptionRecordId := userRedemptionRecords[0] - userRedemptionRecord, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, userRedemptionRecordId) - s.Require().True(found) - - s.Require().Equal(msg.Amount, userRedemptionRecord.StTokenAmount, "redemption record sttoken amount") - s.Require().Equal(tc.expectedNativeAmount, userRedemptionRecord.NativeTokenAmount, "redemption record native amount") - s.Require().Equal(msg.Receiver, userRedemptionRecord.Receiver, "redemption record receiver") - s.Require().Equal(msg.HostZone, userRedemptionRecord.HostZoneId, "redemption record host zone") - s.Require().False(userRedemptionRecord.ClaimIsPending, "redemption record is not claimable") - s.Require().NotEqual(hostZoneUnbonding.Status, recordtypes.HostZoneUnbonding_CLAIMABLE, "host zone unbonding should NOT be marked as CLAIMABLE") -} - -func (s *KeeperTestSuite) TestRedeemStake_InvalidCreatorAddress() { - tc := s.SetupRedeemStake() - invalidMsg := tc.validMsg - - // cosmos instead of stride address - invalidMsg.Creator = "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8uf" - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: invalid Bech32 prefix; expected stride, got cosmos: invalid address", invalidMsg.Creator)) - - // invalid stride address - invalidMsg.Creator = "stride1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8uf" - _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: decoding bech32 failed: invalid checksum (expected 8dpmg9 got yxp8uf): invalid address", invalidMsg.Creator)) - - // empty address - invalidMsg.Creator = "" - _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: empty address string is not allowed: invalid address", invalidMsg.Creator)) - - // wrong len address - invalidMsg.Creator = "stride1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8ufabc" - _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: decoding bech32 failed: invalid character not part of charset: 98: invalid address", invalidMsg.Creator)) -} - -func (s *KeeperTestSuite) TestRedeemStake_HostZoneNotFound() { - tc := s.SetupRedeemStake() - - invalidMsg := tc.validMsg - invalidMsg.HostZone = "fake_host_zone" - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - - s.Require().EqualError(err, "host zone is invalid: fake_host_zone: host zone not registered") -} - -func (s *KeeperTestSuite) TestRedeemStake_RateAboveMaxThreshold() { - tc := s.SetupRedeemStake() - - hz := tc.hostZone - hz.RedemptionRate = sdk.NewDec(100) - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) - - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().Error(err) -} - -func (s *KeeperTestSuite) TestRedeemStake_InvalidReceiverAddress() { - tc := s.SetupRedeemStake() - - invalidMsg := tc.validMsg - - // stride instead of cosmos address - invalidMsg.Receiver = "stride159atdlc3ksl50g0659w5tq42wwer334ajl7xnq" - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, "invalid receiver address (invalid Bech32 prefix; expected cosmos, got stride): invalid address") - - // invalid cosmos address - invalidMsg.Receiver = "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8ua" - _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, "invalid receiver address (decoding bech32 failed: invalid checksum (expected yxp8uf got yxp8ua)): invalid address") - - // empty address - invalidMsg.Receiver = "" - _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, "invalid receiver address (empty address string is not allowed): invalid address") - - // wrong len address - invalidMsg.Receiver = "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8ufa" - _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().EqualError(err, "invalid receiver address (decoding bech32 failed: invalid checksum (expected xp8ugp got xp8ufa)): invalid address") -} - -func (s *KeeperTestSuite) TestRedeemStake_RedeemMoreThanStaked() { - tc := s.SetupRedeemStake() - - invalidMsg := tc.validMsg - invalidMsg.Amount = sdkmath.NewInt(1_000_000_000_000_000) - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - - s.Require().EqualError(err, fmt.Sprintf("cannot unstake an amount g.t. staked balance on host zone: %v: invalid amount", invalidMsg.Amount)) -} - -func (s *KeeperTestSuite) TestRedeemStake_NoEpochTrackerDay() { - tc := s.SetupRedeemStake() - - invalidMsg := tc.validMsg - s.App.RecordsKeeper.RemoveEpochUnbondingRecord(s.Ctx, tc.initialState.epochNumber) - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - - s.Require().EqualError(err, "latest epoch unbonding record not found: epoch unbonding record not found") -} - -func (s *KeeperTestSuite) TestRedeemStake_HostZoneNoUnbondings() { - tc := s.SetupRedeemStake() - - invalidMsg := tc.validMsg - epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ - EpochNumber: 1, - HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{}, - } - hostZoneUnbonding := &recordtypes.HostZoneUnbonding{ - NativeTokenAmount: sdkmath.ZeroInt(), - Denom: "uatom", - HostZoneId: "NOT_GAIA", - } - epochUnbondingRecord.HostZoneUnbondings = append(epochUnbondingRecord.HostZoneUnbondings, hostZoneUnbonding) - - s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - - s.Require().EqualError(err, "host zone not found in unbondings: GAIA: host zone not registered") -} - -func (s *KeeperTestSuite) TestRedeemStake_InvalidHostAddress() { - tc := s.SetupRedeemStake() - - // Update hostzone with invalid address - badHostZone, _ := s.App.StakeibcKeeper.GetHostZone(s.Ctx, tc.validMsg.HostZone) - badHostZone.DepositAddress = "cosmosXXX" - s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) - - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "could not bech32 decode address cosmosXXX of zone with id: GAIA") -} - -func (s *KeeperTestSuite) TestRedeemStake_HaltedZone() { - tc := s.SetupRedeemStake() - - // Update hostzone with halted - haltedHostZone, _ := s.App.StakeibcKeeper.GetHostZone(s.Ctx, tc.validMsg.HostZone) - haltedHostZone.Halted = true - s.App.StakeibcKeeper.SetHostZone(s.Ctx, haltedHostZone) - - _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().EqualError(err, "halted host zone found for zone (GAIA): Halted host zone found") -} diff --git a/x/stakeibc/keeper/msg_server_register_host_zone.go b/x/stakeibc/keeper/msg_server_register_host_zone.go deleted file mode 100644 index ab95ad24bd..0000000000 --- a/x/stakeibc/keeper/msg_server_register_host_zone.go +++ /dev/null @@ -1,247 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - sdkmath "cosmossdk.io/math" - icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" - - "github.com/Stride-Labs/stride/v18/utils" - epochtypes "github.com/Stride-Labs/stride/v18/x/epochs/types" - recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" -) - -const ( - CommunityPoolStakeHoldingAddressKey = "community-pool-stake" - CommunityPoolRedeemHoldingAddressKey = "community-pool-redeem" -) - -func (k msgServer) RegisterHostZone(goCtx context.Context, msg *types.MsgRegisterHostZone) (*types.MsgRegisterHostZoneResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - // Get ConnectionEnd (for counterparty connection) - connectionEnd, found := k.IBCKeeper.ConnectionKeeper.GetConnection(ctx, msg.ConnectionId) - if !found { - errMsg := fmt.Sprintf("invalid connection id, %s not found", msg.ConnectionId) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - counterpartyConnection := connectionEnd.Counterparty - - // Get chain id from connection - chainId, err := k.GetChainIdFromConnectionId(ctx, msg.ConnectionId) - if err != nil { - errMsg := fmt.Sprintf("unable to obtain chain id from connection %s, err: %s", msg.ConnectionId, err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - - // get zone - _, found = k.GetHostZone(ctx, chainId) - if found { - errMsg := fmt.Sprintf("invalid chain id, zone for %s already registered", chainId) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - - // check the denom is not already registered - hostZones := k.GetAllHostZone(ctx) - for _, hostZone := range hostZones { - if hostZone.HostDenom == msg.HostDenom { - errMsg := fmt.Sprintf("host denom %s already registered", msg.HostDenom) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - if hostZone.ConnectionId == msg.ConnectionId { - errMsg := fmt.Sprintf("connectionId %s already registered", msg.ConnectionId) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - if hostZone.TransferChannelId == msg.TransferChannelId { - errMsg := fmt.Sprintf("transfer channel %s already registered", msg.TransferChannelId) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - if hostZone.Bech32Prefix == msg.Bech32Prefix { - errMsg := fmt.Sprintf("bech32prefix %s already registered", msg.Bech32Prefix) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - } - - // create and save the zones's module account - depositAddress := types.NewHostZoneDepositAddress(chainId) - if err := utils.CreateModuleAccount(ctx, k.AccountKeeper, depositAddress); err != nil { - return nil, errorsmod.Wrapf(err, "unable to create deposit account for host zone %s", chainId) - } - - // Create the host zone's community pool holding accounts - communityPoolStakeAddress := types.NewHostZoneModuleAddress(chainId, CommunityPoolStakeHoldingAddressKey) - communityPoolRedeemAddress := types.NewHostZoneModuleAddress(chainId, CommunityPoolRedeemHoldingAddressKey) - if err := utils.CreateModuleAccount(ctx, k.AccountKeeper, communityPoolStakeAddress); err != nil { - return nil, errorsmod.Wrapf(err, "unable to create community pool stake account for host zone %s", chainId) - } - if err := utils.CreateModuleAccount(ctx, k.AccountKeeper, communityPoolRedeemAddress); err != nil { - return nil, errorsmod.Wrapf(err, "unable to create community pool redeem account for host zone %s", chainId) - } - - params := k.GetParams(ctx) - if msg.MinRedemptionRate.IsNil() || msg.MinRedemptionRate.IsZero() { - msg.MinRedemptionRate = sdk.NewDecWithPrec(int64(params.DefaultMinRedemptionRateThreshold), 2) - } - if msg.MaxRedemptionRate.IsNil() || msg.MaxRedemptionRate.IsZero() { - msg.MaxRedemptionRate = sdk.NewDecWithPrec(int64(params.DefaultMaxRedemptionRateThreshold), 2) - } - - // set the zone - zone := types.HostZone{ - ChainId: chainId, - ConnectionId: msg.ConnectionId, - Bech32Prefix: msg.Bech32Prefix, - IbcDenom: msg.IbcDenom, - HostDenom: msg.HostDenom, - TransferChannelId: msg.TransferChannelId, - // Start sharesToTokens rate at 1 upon registration - RedemptionRate: sdk.NewDec(1), - LastRedemptionRate: sdk.NewDec(1), - UnbondingPeriod: msg.UnbondingPeriod, - DepositAddress: depositAddress.String(), - CommunityPoolStakeHoldingAddress: communityPoolStakeAddress.String(), - CommunityPoolRedeemHoldingAddress: communityPoolRedeemAddress.String(), - MinRedemptionRate: msg.MinRedemptionRate, - MaxRedemptionRate: msg.MaxRedemptionRate, - // Default the inner bounds to the outer bounds - MinInnerRedemptionRate: msg.MinRedemptionRate, - MaxInnerRedemptionRate: msg.MaxRedemptionRate, - LsmLiquidStakeEnabled: msg.LsmLiquidStakeEnabled, - } - // write the zone back to the store - k.SetHostZone(ctx, zone) - - appVersion := string(icatypes.ModuleCdc.MustMarshalJSON(&icatypes.Metadata{ - Version: icatypes.Version, - ControllerConnectionId: zone.ConnectionId, - HostConnectionId: counterpartyConnection.ConnectionId, - Encoding: icatypes.EncodingProtobuf, - TxType: icatypes.TxTypeSDKMultiMsg, - })) - - // generate delegate account - // NOTE: in the future, if we implement proxy governance, we'll need many more delegate accounts - delegateAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_DELEGATION) - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, delegateAccount, appVersion); err != nil { - errMsg := fmt.Sprintf("unable to register delegation account, err: %s", err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - - // generate fee account - feeAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_FEE) - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, feeAccount, appVersion); err != nil { - errMsg := fmt.Sprintf("unable to register fee account, err: %s", err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - - // generate withdrawal account - withdrawalAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_WITHDRAWAL) - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, withdrawalAccount, appVersion); err != nil { - errMsg := fmt.Sprintf("unable to register withdrawal account, err: %s", err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - - // generate redemption account - redemptionAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_REDEMPTION) - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, redemptionAccount, appVersion); err != nil { - errMsg := fmt.Sprintf("unable to register redemption account, err: %s", err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - - // create community pool deposit account - communityPoolDepositAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_COMMUNITY_POOL_DEPOSIT) - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, communityPoolDepositAccount, appVersion); err != nil { - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, "failed to register community pool deposit ICA") - } - - // create community pool return account - communityPoolReturnAccount := types.FormatHostZoneICAOwner(chainId, types.ICAAccountType_COMMUNITY_POOL_RETURN) - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, zone.ConnectionId, communityPoolReturnAccount, appVersion); err != nil { - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, "failed to register community pool return ICA") - } - - // add this host zone to unbonding hostZones, otherwise users won't be able to unbond - // for this host zone until the following day - dayEpochTracker, found := k.GetEpochTracker(ctx, epochtypes.DAY_EPOCH) - if !found { - return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "epoch tracker (%s) not found", epochtypes.DAY_EPOCH) - } - epochUnbondingRecord, found := k.RecordsKeeper.GetEpochUnbondingRecord(ctx, dayEpochTracker.EpochNumber) - if !found { - errMsg := "unable to find latest epoch unbonding record" - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(recordstypes.ErrEpochUnbondingRecordNotFound, errMsg) - } - hostZoneUnbonding := &recordstypes.HostZoneUnbonding{ - NativeTokenAmount: sdkmath.ZeroInt(), - StTokenAmount: sdkmath.ZeroInt(), - Denom: zone.HostDenom, - HostZoneId: zone.ChainId, - Status: recordstypes.HostZoneUnbonding_UNBONDING_QUEUE, - } - updatedEpochUnbondingRecord, success := k.RecordsKeeper.AddHostZoneToEpochUnbondingRecord(ctx, epochUnbondingRecord.EpochNumber, chainId, hostZoneUnbonding) - if !success { - errMsg := fmt.Sprintf("Failed to set host zone epoch unbonding record: epochNumber %d, chainId %s, hostZoneUnbonding %v. Err: %s", - epochUnbondingRecord.EpochNumber, chainId, hostZoneUnbonding, err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrEpochNotFound, errMsg) - } - k.RecordsKeeper.SetEpochUnbondingRecord(ctx, *updatedEpochUnbondingRecord) - - // create an empty deposit record for the host zone - strideEpochTracker, found := k.GetEpochTracker(ctx, epochtypes.STRIDE_EPOCH) - if !found { - return nil, errorsmod.Wrapf(types.ErrEpochNotFound, "epoch tracker (%s) not found", epochtypes.STRIDE_EPOCH) - } - depositRecord := recordstypes.DepositRecord{ - Id: 0, - Amount: sdkmath.ZeroInt(), - Denom: zone.HostDenom, - HostZoneId: zone.ChainId, - Status: recordstypes.DepositRecord_TRANSFER_QUEUE, - DepositEpochNumber: strideEpochTracker.EpochNumber, - } - k.RecordsKeeper.AppendDepositRecord(ctx, depositRecord) - - // register stToken to consumer reward denom whitelist so that - // stToken rewards can be distributed to provider validators - err = k.RegisterStTokenDenomsToWhitelist(ctx, []string{types.StAssetDenomFromHostZoneDenom(zone.HostDenom)}) - if err != nil { - errMsg := fmt.Sprintf("unable to register reward denom, err: %s", err.Error()) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrFailedToRegisterHostZone, errMsg) - } - - // emit events - ctx.EventManager().EmitEvent( - sdk.NewEvent( - sdk.EventTypeMessage, - sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), - ), - ) - ctx.EventManager().EmitEvent( - sdk.NewEvent( - types.EventTypeRegisterZone, - sdk.NewAttribute(types.AttributeKeyConnectionId, msg.ConnectionId), - sdk.NewAttribute(types.AttributeKeyRecipientChain, chainId), - ), - ) - - return &types.MsgRegisterHostZoneResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_register_host_zone_test.go b/x/stakeibc/keeper/msg_server_register_host_zone_test.go deleted file mode 100644 index a7fa9be010..0000000000 --- a/x/stakeibc/keeper/msg_server_register_host_zone_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package keeper_test - -import ( - "fmt" - - sdkmath "cosmossdk.io/math" - sdk "github.com/cosmos/cosmos-sdk/types" - ibctesting "github.com/cosmos/ibc-go/v7/testing" - _ "github.com/stretchr/testify/suite" - - icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" - - channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types" - - epochtypes "github.com/Stride-Labs/stride/v18/x/epochs/types" - recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" - recordtypes "github.com/Stride-Labs/stride/v18/x/records/types" - stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type RegisterHostZoneTestCase struct { - validMsg stakeibctypes.MsgRegisterHostZone - epochUnbondingRecordNumber uint64 - strideEpochNumber uint64 - unbondingPeriod uint64 - defaultRedemptionRate sdk.Dec - atomHostZoneChainId string -} - -func (s *KeeperTestSuite) SetupRegisterHostZone() RegisterHostZoneTestCase { - epochUnbondingRecordNumber := uint64(3) - strideEpochNumber := uint64(4) - unbondingPeriod := uint64(14) - defaultRedemptionRate := sdk.NewDec(1) - atomHostZoneChainId := "GAIA" - - s.CreateTransferChannel(HostChainId) - - s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, stakeibctypes.EpochTracker{ - EpochIdentifier: epochtypes.DAY_EPOCH, - EpochNumber: epochUnbondingRecordNumber, - }) - - s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, stakeibctypes.EpochTracker{ - EpochIdentifier: epochtypes.STRIDE_EPOCH, - EpochNumber: strideEpochNumber, - }) - - epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ - EpochNumber: epochUnbondingRecordNumber, - HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{}, - } - s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) - - defaultMsg := stakeibctypes.MsgRegisterHostZone{ - ConnectionId: ibctesting.FirstConnectionID, - Bech32Prefix: GaiaPrefix, - HostDenom: Atom, - IbcDenom: IbcAtom, - TransferChannelId: ibctesting.FirstChannelID, - UnbondingPeriod: unbondingPeriod, - MinRedemptionRate: sdk.NewDec(0), - MaxRedemptionRate: sdk.NewDec(0), - } - - return RegisterHostZoneTestCase{ - validMsg: defaultMsg, - epochUnbondingRecordNumber: epochUnbondingRecordNumber, - strideEpochNumber: strideEpochNumber, - unbondingPeriod: unbondingPeriod, - defaultRedemptionRate: defaultRedemptionRate, - atomHostZoneChainId: atomHostZoneChainId, - } -} - -// Helper function to test registering a duplicate host zone -// If there's a duplicate connection ID, register_host_zone will error before checking other fields for duplicates -// In order to test those cases, we need to first create a new host zone, -// -// and then attempt to register with duplicate fields in the message -// -// This function 1) creates a new host zone and 2) returns what would be a successful register message -func (s *KeeperTestSuite) createNewHostZoneMessage(chainID string, denom string, prefix string) stakeibctypes.MsgRegisterHostZone { - // Create a new test chain and connection ID - ibctesting.DefaultTestingAppInit = ibctesting.SetupTestingApp - osmoChain := ibctesting.NewTestChain(s.T(), s.Coordinator, chainID) - path := ibctesting.NewPath(s.StrideChain, osmoChain) - s.Coordinator.SetupConnections(path) - connectionId := path.EndpointA.ConnectionID - - // Build what would be a successful message to register the host zone - // Note: this is purposefully missing fields because it is used in failure cases that short circuit - return stakeibctypes.MsgRegisterHostZone{ - ConnectionId: connectionId, - Bech32Prefix: prefix, - HostDenom: denom, - } -} - -// Helper function to assist in testing a failure to create an ICA account -// This function will occupy one of the specified port with the specified channel -// -// so that the registration fails -func (s *KeeperTestSuite) createActiveChannelOnICAPort(accountName string, channelID string) { - portID := fmt.Sprintf("%s%s.%s", icatypes.ControllerPortPrefix, HostChainId, accountName) - openChannel := channeltypes.Channel{State: channeltypes.OPEN} - - // The channel ID doesn't matter here - all that matters is that theres an open channel on the port - s.App.IBCKeeper.ChannelKeeper.SetChannel(s.Ctx, portID, channelID, openChannel) - s.App.ICAControllerKeeper.SetActiveChannelID(s.Ctx, ibctesting.FirstConnectionID, portID, channelID) -} - -func (s *KeeperTestSuite) TestRegisterHostZone_Success() { - tc := s.SetupRegisterHostZone() - msg := tc.validMsg - - // Register host zone - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err, "able to successfully register host zone") - - // Confirm host zone unbonding was added - hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) - s.Require().True(found, "host zone found") - s.Require().Equal(tc.defaultRedemptionRate, hostZone.RedemptionRate, "redemption rate set to default: 1") - s.Require().Equal(tc.defaultRedemptionRate, hostZone.LastRedemptionRate, "last redemption rate set to default: 1") - defaultMinThreshold := sdk.NewDec(int64(stakeibctypes.DefaultMinRedemptionRateThreshold)).Quo(sdk.NewDec(100)) - defaultMaxThreshold := sdk.NewDec(int64(stakeibctypes.DefaultMaxRedemptionRateThreshold)).Quo(sdk.NewDec(100)) - s.Require().Equal(defaultMinThreshold, hostZone.MinRedemptionRate, "min redemption rate set to default") - s.Require().Equal(defaultMaxThreshold, hostZone.MaxRedemptionRate, "max redemption rate set to default") - s.Require().Equal(tc.unbondingPeriod, hostZone.UnbondingPeriod, "unbonding period") - - // Confirm host zone unbonding record was created - epochUnbondingRecord, found := s.App.RecordsKeeper.GetEpochUnbondingRecord(s.Ctx, tc.epochUnbondingRecordNumber) - s.Require().True(found, "epoch unbonding record found") - s.Require().Len(epochUnbondingRecord.HostZoneUnbondings, 1, "host zone unbonding record has one entry") - - // Confirm host zone unbonding was added - hostZoneUnbonding := epochUnbondingRecord.HostZoneUnbondings[0] - s.Require().Equal(HostChainId, hostZoneUnbonding.HostZoneId, "host zone unbonding set for this host zone") - s.Require().Equal(sdkmath.ZeroInt(), hostZoneUnbonding.NativeTokenAmount, "host zone unbonding set to 0 tokens") - s.Require().Equal(recordstypes.HostZoneUnbonding_UNBONDING_QUEUE, hostZoneUnbonding.Status, "host zone unbonding set to bonded") - - // Confirm a module account was created - hostZoneModuleAccount, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) - s.Require().NoError(err, "converting module address to account") - acc := s.App.AccountKeeper.GetAccount(s.Ctx, hostZoneModuleAccount) - s.Require().NotNil(acc, "host zone module account found in account keeper") - - // Confirm an empty deposit record was created - expectedDepositRecord := recordstypes.DepositRecord{ - Id: uint64(0), - Amount: sdkmath.ZeroInt(), - HostZoneId: hostZone.ChainId, - Denom: hostZone.HostDenom, - Status: recordstypes.DepositRecord_TRANSFER_QUEUE, - DepositEpochNumber: tc.strideEpochNumber, - } - - depositRecords := s.App.RecordsKeeper.GetAllDepositRecord(s.Ctx) - s.Require().Len(depositRecords, 1, "number of deposit records") - s.Require().Equal(expectedDepositRecord, depositRecords[0], "deposit record") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_InvalidConnectionId() { - tc := s.SetupRegisterHostZone() - msg := tc.validMsg - msg.ConnectionId = "connection-10" // an invalid connection ID - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().EqualError(err, "invalid connection id, connection-10 not found: failed to register host zone") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateConnectionIdInIBCState() { - // tests for a failure if we register the same host zone twice - // (with a duplicate connectionId stored in the IBCKeeper's state) - tc := s.SetupRegisterHostZone() - msg := tc.validMsg - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err, "able to successfully register host zone once") - - // now all attributes are different, EXCEPT the connection ID - msg.Bech32Prefix = "cosmos-different" // a different Bech32 prefix - msg.HostDenom = "atom-different" // a different host denom - msg.IbcDenom = "ibc-atom-different" // a different IBC denom - - _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - expectedErrMsg := "invalid chain id, zone for GAIA already registered: " - expectedErrMsg += "failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate connection ID should fail") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateConnectionIdInStakeibcState() { - // tests for a failure if we register the same host zone twice - // (with a duplicate connectionId stored in a different host zone in stakeibc) - tc := s.SetupRegisterHostZone() - msg := tc.validMsg - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err, "able to successfully register host zone once") - - // Create the message for a brand new host zone - // (without modifications, you would expect this to be successful) - newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") - - // Add a different host zone with the same connection Id as OSMO - newHostZone := stakeibctypes.HostZone{ - ChainId: "JUNO", - ConnectionId: newHostZoneMsg.ConnectionId, - } - s.App.StakeibcKeeper.SetHostZone(s.Ctx, newHostZone) - - // Registering should fail with a duplicate connection ID - _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &newHostZoneMsg) - expectedErrMsg := "connectionId connection-1 already registered: " - expectedErrMsg += "failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate connection ID should fail") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateHostDenom() { - // tests for a failure if we register the same host zone twice (with a duplicate host denom) - tc := s.SetupRegisterHostZone() - - // Register host zones successfully - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().NoError(err, "able to successfully register host zone once") - - // Create the message for a brand new host zone - // (without modifications, you would expect this to be successful) - newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") - - // Try to register with a duplicate host denom - it should fail - invalidMsg := newHostZoneMsg - invalidMsg.HostDenom = tc.validMsg.HostDenom - - _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - expectedErrMsg := "host denom uatom already registered: failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate host denom should fail") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateTransferChannel() { - // tests for a failure if we register the same host zone twice (with a duplicate transfer) - tc := s.SetupRegisterHostZone() - - // Register host zones successfully - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().NoError(err, "able to successfully register host zone once") - - // Create the message for a brand new host zone - // (without modifications, you would expect this to be successful) - newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") - - // Try to register with a duplicate transfer channel - it should fail - invalidMsg := newHostZoneMsg - invalidMsg.TransferChannelId = tc.validMsg.TransferChannelId - - _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - expectedErrMsg := "transfer channel channel-0 already registered: failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate host denom should fail") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateBech32Prefix() { - // tests for a failure if we register the same host zone twice (with a duplicate bech32 prefix) - tc := s.SetupRegisterHostZone() - - // Register host zones successfully - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().NoError(err, "able to successfully register host zone once") - - // Create the message for a brand new host zone - // (without modifications, you would expect this to be successful) - newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") - - // Try to register with a duplicate bech32prefix - it should fail - invalidMsg := newHostZoneMsg - invalidMsg.Bech32Prefix = tc.validMsg.Bech32Prefix - - _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - expectedErrMsg := "bech32prefix cosmos already registered: failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate bech32 prefix should fail") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_CannotFindDayEpochTracker() { - // tests for a failure if the epoch tracker cannot be found - tc := s.SetupRegisterHostZone() - msg := tc.validMsg - - // delete the epoch tracker - s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.DAY_EPOCH) - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - expectedErrMsg := "epoch tracker (day) not found: epoch not found" - s.Require().EqualError(err, expectedErrMsg, "day epoch tracker not found") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_CannotFindStrideEpochTracker() { - // tests for a failure if the epoch tracker cannot be found - tc := s.SetupRegisterHostZone() - msg := tc.validMsg - - // delete the epoch tracker - s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.STRIDE_EPOCH) - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - expectedErrMsg := "epoch tracker (stride_epoch) not found: epoch not found" - s.Require().EqualError(err, expectedErrMsg, "stride epoch tracker not found") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_CannotFindEpochUnbondingRecord() { - // tests for a failure if the epoch unbonding record cannot be found - tc := s.SetupRegisterHostZone() - msg := tc.validMsg - - // delete the epoch unbonding record - s.App.RecordsKeeper.RemoveEpochUnbondingRecord(s.Ctx, tc.epochUnbondingRecordNumber) - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) - expectedErrMsg := "unable to find latest epoch unbonding record: epoch unbonding record not found" - s.Require().EqualError(err, expectedErrMsg, " epoch unbonding record not found") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterDelegationAccount() { - // tests for a failure if the epoch unbonding record cannot be found - tc := s.SetupRegisterHostZone() - - // Create channel on delegation port - s.createActiveChannelOnICAPort("DELEGATION", "channel-1") - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - expectedErrMsg := "unable to register delegation account, err: existing active channel channel-1 for portID icacontroller-GAIA.DELEGATION " - expectedErrMsg += "on connection connection-0: active channel already set for this owner: " - expectedErrMsg += "failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "can't register delegation account") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterFeeAccount() { - // tests for a failure if the epoch unbonding record cannot be found - tc := s.SetupRegisterHostZone() - - // Create channel on fee port - s.createActiveChannelOnICAPort("FEE", "channel-1") - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - expectedErrMsg := "unable to register fee account, err: existing active channel channel-1 for portID icacontroller-GAIA.FEE " - expectedErrMsg += "on connection connection-0: active channel already set for this owner: " - expectedErrMsg += "failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "can't register redemption account") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterWithdrawalAccount() { - // tests for a failure if the epoch unbonding record cannot be found - tc := s.SetupRegisterHostZone() - - // Create channel on withdrawal port - s.createActiveChannelOnICAPort("WITHDRAWAL", "channel-1") - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - expectedErrMsg := "unable to register withdrawal account, err: existing active channel channel-1 for portID icacontroller-GAIA.WITHDRAWAL " - expectedErrMsg += "on connection connection-0: active channel already set for this owner: " - expectedErrMsg += "failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "can't register redemption account") -} - -func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterRedemptionAccount() { - // tests for a failure if the epoch unbonding record cannot be found - tc := s.SetupRegisterHostZone() - - // Create channel on redemption port - s.createActiveChannelOnICAPort("REDEMPTION", "channel-1") - - _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - expectedErrMsg := "unable to register redemption account, err: existing active channel channel-1 for portID icacontroller-GAIA.REDEMPTION " - expectedErrMsg += "on connection connection-0: active channel already set for this owner: " - expectedErrMsg += "failed to register host zone" - s.Require().EqualError(err, expectedErrMsg, "can't register redemption account") -} diff --git a/x/stakeibc/keeper/msg_server_restore_interchain_account.go b/x/stakeibc/keeper/msg_server_restore_interchain_account.go deleted file mode 100644 index c34435a5f0..0000000000 --- a/x/stakeibc/keeper/msg_server_restore_interchain_account.go +++ /dev/null @@ -1,127 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" - connectiontypes "github.com/cosmos/ibc-go/v7/modules/core/03-connection/types" - - recordtypes "github.com/Stride-Labs/stride/v18/x/records/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (k msgServer) RestoreInterchainAccount(goCtx context.Context, msg *types.MsgRestoreInterchainAccount) (*types.MsgRestoreInterchainAccountResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - // Get ConnectionEnd (for counterparty connection) - connectionEnd, found := k.IBCKeeper.ConnectionKeeper.GetConnection(ctx, msg.ConnectionId) - if !found { - return nil, errorsmod.Wrapf(connectiontypes.ErrConnectionNotFound, "connection %s not found", msg.ConnectionId) - } - counterpartyConnection := connectionEnd.Counterparty - - // only allow restoring an account if it already exists - portID, err := icatypes.NewControllerPortID(msg.AccountOwner) - if err != nil { - return nil, err - } - _, exists := k.ICAControllerKeeper.GetInterchainAccountAddress(ctx, msg.ConnectionId, portID) - if !exists { - return nil, errorsmod.Wrapf(types.ErrInvalidInterchainAccountAddress, - "ICA controller account address not found: %s", msg.AccountOwner) - } - - appVersion := string(icatypes.ModuleCdc.MustMarshalJSON(&icatypes.Metadata{ - Version: icatypes.Version, - ControllerConnectionId: msg.ConnectionId, - HostConnectionId: counterpartyConnection.ConnectionId, - Encoding: icatypes.EncodingProtobuf, - TxType: icatypes.TxTypeSDKMultiMsg, - })) - - if err := k.ICAControllerKeeper.RegisterInterchainAccount(ctx, msg.ConnectionId, msg.AccountOwner, appVersion); err != nil { - return nil, errorsmod.Wrapf(err, "unable to register account for owner %s", msg.AccountOwner) - } - - // If we're restoring a delegation account, we also have to reset record state - if msg.AccountOwner == types.FormatHostZoneICAOwner(msg.ChainId, types.ICAAccountType_DELEGATION) { - hostZone, found := k.GetHostZone(ctx, msg.ChainId) - if !found { - return nil, types.ErrHostZoneNotFound.Wrapf("delegation ICA supplied, but no associated host zone") - } - - // Since any ICAs along the original channel will never get relayed, - // we have to reset the delegation_changes_in_progress field on each validator - for _, validator := range hostZone.Validators { - validator.DelegationChangesInProgress = 0 - } - k.SetHostZone(ctx, hostZone) - - // revert DELEGATION_IN_PROGRESS records for the closed ICA channel (so that they can be staked) - depositRecords := k.RecordsKeeper.GetAllDepositRecord(ctx) - for _, depositRecord := range depositRecords { - // only revert records for the select host zone - if depositRecord.HostZoneId == hostZone.ChainId && depositRecord.Status == recordtypes.DepositRecord_DELEGATION_IN_PROGRESS { - depositRecord.Status = recordtypes.DepositRecord_DELEGATION_QUEUE - k.Logger(ctx).Info(fmt.Sprintf("Setting DepositRecord %d to status DepositRecord_DELEGATION_IN_PROGRESS", depositRecord.Id)) - k.RecordsKeeper.SetDepositRecord(ctx, depositRecord) - } - } - - // revert epoch unbonding records for the closed ICA channel - epochUnbondingRecords := k.RecordsKeeper.GetAllEpochUnbondingRecord(ctx) - epochNumberForPendingUnbondingRecords := []uint64{} - epochNumberForPendingTransferRecords := []uint64{} - for _, epochUnbondingRecord := range epochUnbondingRecords { - // only revert records for the select host zone - hostZoneUnbonding, found := k.RecordsKeeper.GetHostZoneUnbondingByChainId(ctx, epochUnbondingRecord.EpochNumber, hostZone.ChainId) - if !found { - k.Logger(ctx).Info(fmt.Sprintf("No HostZoneUnbonding found for chainId: %s, epoch: %d", hostZone.ChainId, epochUnbondingRecord.EpochNumber)) - continue - } - - // Revert UNBONDING_IN_PROGRESS and EXIT_TRANSFER_IN_PROGRESS records - if hostZoneUnbonding.Status == recordtypes.HostZoneUnbonding_UNBONDING_IN_PROGRESS { - k.Logger(ctx).Info(fmt.Sprintf("HostZoneUnbonding for %s at EpochNumber %d is stuck in status %s", - hostZone.ChainId, epochUnbondingRecord.EpochNumber, recordtypes.HostZoneUnbonding_UNBONDING_IN_PROGRESS.String(), - )) - epochNumberForPendingUnbondingRecords = append(epochNumberForPendingUnbondingRecords, epochUnbondingRecord.EpochNumber) - - } else if hostZoneUnbonding.Status == recordtypes.HostZoneUnbonding_EXIT_TRANSFER_IN_PROGRESS { - k.Logger(ctx).Info(fmt.Sprintf("HostZoneUnbonding for %s at EpochNumber %d to in status %s", - hostZone.ChainId, epochUnbondingRecord.EpochNumber, recordtypes.HostZoneUnbonding_EXIT_TRANSFER_IN_PROGRESS.String(), - )) - epochNumberForPendingTransferRecords = append(epochNumberForPendingTransferRecords, epochUnbondingRecord.EpochNumber) - } - } - // Revert UNBONDING_IN_PROGRESS records to UNBONDING_QUEUE - err := k.RecordsKeeper.SetHostZoneUnbondingStatus(ctx, hostZone.ChainId, epochNumberForPendingUnbondingRecords, recordtypes.HostZoneUnbonding_UNBONDING_QUEUE) - if err != nil { - errMsg := fmt.Sprintf("unable to update host zone unbonding record status to %s for chainId: %s and epochUnbondingRecordIds: %v, err: %s", - recordtypes.HostZoneUnbonding_UNBONDING_QUEUE.String(), hostZone.ChainId, epochNumberForPendingUnbondingRecords, err) - k.Logger(ctx).Error(errMsg) - return nil, err - } - - // Revert EXIT_TRANSFER_IN_PROGRESS records to EXIT_TRANSFER_QUEUE - err = k.RecordsKeeper.SetHostZoneUnbondingStatus(ctx, hostZone.ChainId, epochNumberForPendingTransferRecords, recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE) - if err != nil { - errMsg := fmt.Sprintf("unable to update host zone unbonding record status to %s for chainId: %s and epochUnbondingRecordIds: %v, err: %s", - recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE.String(), hostZone.ChainId, epochNumberForPendingTransferRecords, err) - k.Logger(ctx).Error(errMsg) - return nil, err - } - - // Revert all pending LSM Detokenizations from status DETOKENIZATION_IN_PROGRESS to status DETOKENIZATION_QUEUE - pendingDeposits := k.RecordsKeeper.GetLSMDepositsForHostZoneWithStatus(ctx, hostZone.ChainId, recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS) - for _, lsmDeposit := range pendingDeposits { - k.Logger(ctx).Info(fmt.Sprintf("Setting LSMTokenDeposit %s to status DETOKENIZATION_QUEUE", lsmDeposit.Denom)) - k.RecordsKeeper.UpdateLSMTokenDepositStatus(ctx, lsmDeposit, recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE) - } - } - - return &types.MsgRestoreInterchainAccountResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_restore_interchain_account_test.go b/x/stakeibc/keeper/msg_server_restore_interchain_account_test.go deleted file mode 100644 index d5d65d2ec9..0000000000 --- a/x/stakeibc/keeper/msg_server_restore_interchain_account_test.go +++ /dev/null @@ -1,364 +0,0 @@ -package keeper_test - -import ( - sdk "github.com/cosmos/cosmos-sdk/types" - channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types" - ibctesting "github.com/cosmos/ibc-go/v7/testing" - _ "github.com/stretchr/testify/suite" - - recordtypes "github.com/Stride-Labs/stride/v18/x/records/types" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type DepositRecordStatusUpdate struct { - chainId string - initialStatus recordtypes.DepositRecord_Status - revertedStatus recordtypes.DepositRecord_Status -} - -type HostZoneUnbondingStatusUpdate struct { - initialStatus recordtypes.HostZoneUnbonding_Status - revertedStatus recordtypes.HostZoneUnbonding_Status -} - -type LSMTokenDepositStatusUpdate struct { - chainId string - denom string - initialStatus recordtypes.LSMTokenDeposit_Status - revertedStatus recordtypes.LSMTokenDeposit_Status -} - -type RestoreInterchainAccountTestCase struct { - validMsg types.MsgRestoreInterchainAccount - depositRecordStatusUpdates []DepositRecordStatusUpdate - unbondingRecordStatusUpdate []HostZoneUnbondingStatusUpdate - lsmTokenDepositStatusUpdate []LSMTokenDepositStatusUpdate - delegationChannelID string - delegationPortID string -} - -func (s *KeeperTestSuite) SetupRestoreInterchainAccount(createDelegationICAChannel bool) RestoreInterchainAccountTestCase { - s.CreateTransferChannel(HostChainId) - - // We have to setup the ICA channel before the LSM Token is stored, - // otherwise when the EndBlocker runs in the channel setup, the LSM Token - // statuses will get updated - var channelID, portID string - if createDelegationICAChannel { - owner := "GAIA.DELEGATION" - channelID, portID = s.CreateICAChannel(owner) - } - - hostZone := types.HostZone{ - ChainId: HostChainId, - ConnectionId: ibctesting.FirstConnectionID, - RedemptionRate: sdk.OneDec(), // if not set, the beginblocker invariant panics - Validators: []*types.Validator{ - {Address: "valA", DelegationChangesInProgress: 1}, - {Address: "valB", DelegationChangesInProgress: 2}, - {Address: "valC", DelegationChangesInProgress: 3}, - }, - } - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - // Store deposit records with some in state pending - depositRecords := []DepositRecordStatusUpdate{ - { - // Status doesn't change - chainId: HostChainId, - initialStatus: recordtypes.DepositRecord_TRANSFER_IN_PROGRESS, - revertedStatus: recordtypes.DepositRecord_TRANSFER_IN_PROGRESS, - }, - { - // Status gets reverted from IN_PROGRESS to QUEUE - chainId: HostChainId, - initialStatus: recordtypes.DepositRecord_DELEGATION_IN_PROGRESS, - revertedStatus: recordtypes.DepositRecord_DELEGATION_QUEUE, - }, - { - // Status doesn't get reveted because it's a different host zone - chainId: "different_host_zone", - initialStatus: recordtypes.DepositRecord_DELEGATION_IN_PROGRESS, - revertedStatus: recordtypes.DepositRecord_DELEGATION_IN_PROGRESS, - }, - } - for i, depositRecord := range depositRecords { - s.App.RecordsKeeper.SetDepositRecord(s.Ctx, recordtypes.DepositRecord{ - Id: uint64(i), - HostZoneId: depositRecord.chainId, - Status: depositRecord.initialStatus, - }) - } - - // Store epoch unbonding records with some in state pending - hostZoneUnbondingRecords := []HostZoneUnbondingStatusUpdate{ - { - // Status doesn't change - initialStatus: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, - revertedStatus: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, - }, - { - // Status gets reverted from IN_PROGRESS to QUEUE - initialStatus: recordtypes.HostZoneUnbonding_UNBONDING_IN_PROGRESS, - revertedStatus: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, - }, - { - // Status doesn't change - initialStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, - revertedStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, - }, - { - // Status gets reverted from IN_PROGRESS to QUEUE - initialStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_IN_PROGRESS, - revertedStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, - }, - } - for i, hostZoneUnbonding := range hostZoneUnbondingRecords { - s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ - EpochNumber: uint64(i), - HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ - // The first unbonding record will get reverted, the other one will not - { - HostZoneId: HostChainId, - Status: hostZoneUnbonding.initialStatus, - }, - { - HostZoneId: "different_host_zone", - Status: hostZoneUnbonding.initialStatus, - }, - }, - }) - } - - // Store LSM Token Deposits with some state pending - lsmTokenDeposits := []LSMTokenDepositStatusUpdate{ - { - // Status doesn't change - chainId: HostChainId, - denom: "denom-1", - initialStatus: recordtypes.LSMTokenDeposit_TRANSFER_IN_PROGRESS, - revertedStatus: recordtypes.LSMTokenDeposit_TRANSFER_IN_PROGRESS, - }, - { - // Status gets reverted from IN_PROGRESS to QUEUE - chainId: HostChainId, - denom: "denom-2", - initialStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS, - revertedStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE, - }, - { - // Status doesn't change - chainId: HostChainId, - denom: "denom-3", - initialStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE, - revertedStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE, - }, - { - // Status doesn't change (different host zone) - chainId: "different_host_zone", - denom: "denom-4", - initialStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS, - revertedStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS, - }, - } - for _, lsmTokenDeposit := range lsmTokenDeposits { - s.App.RecordsKeeper.SetLSMTokenDeposit(s.Ctx, recordtypes.LSMTokenDeposit{ - ChainId: lsmTokenDeposit.chainId, - Status: lsmTokenDeposit.initialStatus, - Denom: lsmTokenDeposit.denom, - }) - } - - defaultMsg := types.MsgRestoreInterchainAccount{ - Creator: "creatoraddress", - ChainId: HostChainId, - ConnectionId: ibctesting.FirstConnectionID, - AccountOwner: types.FormatHostZoneICAOwner(HostChainId, types.ICAAccountType_DELEGATION), - } - - return RestoreInterchainAccountTestCase{ - validMsg: defaultMsg, - depositRecordStatusUpdates: depositRecords, - unbondingRecordStatusUpdate: hostZoneUnbondingRecords, - lsmTokenDepositStatusUpdate: lsmTokenDeposits, - delegationChannelID: channelID, - delegationPortID: portID, - } -} - -// Helper function to close an ICA channel -func (s *KeeperTestSuite) closeICAChannel(portId, channelID string) { - channel, found := s.App.IBCKeeper.ChannelKeeper.GetChannel(s.Ctx, portId, channelID) - s.Require().True(found, "unable to close channel because channel was not found") - channel.State = channeltypes.CLOSED - s.App.IBCKeeper.ChannelKeeper.SetChannel(s.Ctx, portId, channelID, channel) -} - -// Helper function to call RestoreChannel and check that a new channel was created and opened -func (s *KeeperTestSuite) restoreChannelAndVerifySuccess(msg types.MsgRestoreInterchainAccount, portID string, channelID string) { - // Restore the channel - _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err, "registered ica account successfully") - - // Confirm channel was created - channels := s.App.IBCKeeper.ChannelKeeper.GetAllChannels(s.Ctx) - s.Require().Len(channels, 3, "there should be 3 channels after restoring") - - // Confirm the new channel is in state INIT - newChannelActive := false - for _, channel := range channels { - // The new channel should have the same port, a new channel ID and be in state INIT - if channel.PortId == portID && channel.ChannelId != channelID && channel.State == channeltypes.INIT { - newChannelActive = true - } - } - s.Require().True(newChannelActive, "a new channel should have been created") -} - -// Helper function to check that each DepositRecord's status was either left alone or reverted to it's prior status -func (s *KeeperTestSuite) verifyDepositRecordsStatus(expectedDepositRecords []DepositRecordStatusUpdate, revert bool) { - for i, expectedDepositRecord := range expectedDepositRecords { - actualDepositRecord, found := s.App.RecordsKeeper.GetDepositRecord(s.Ctx, uint64(i)) - s.Require().True(found, "deposit record found") - - // Only revert records if the revert option is passed and the host zone matches - expectedStatus := expectedDepositRecord.initialStatus - if revert && actualDepositRecord.HostZoneId == HostChainId { - expectedStatus = expectedDepositRecord.revertedStatus - } - s.Require().Equal(expectedStatus.String(), actualDepositRecord.Status.String(), "deposit record %d status", i) - } -} - -// Helper function to check that each HostZoneUnbonding's status was either left alone or reverted to it's prior status -func (s *KeeperTestSuite) verifyHostZoneUnbondingStatus(expectedUnbondingRecords []HostZoneUnbondingStatusUpdate, revert bool) { - for i, expectedUnbonding := range expectedUnbondingRecords { - epochUnbondingRecord, found := s.App.RecordsKeeper.GetEpochUnbondingRecord(s.Ctx, uint64(i)) - s.Require().True(found, "epoch unbonding record found") - - for _, actualUnbonding := range epochUnbondingRecord.HostZoneUnbondings { - // Only revert records if the revert option is passed and the host zone matches - expectedStatus := expectedUnbonding.initialStatus - if revert && actualUnbonding.HostZoneId == HostChainId { - expectedStatus = expectedUnbonding.revertedStatus - } - s.Require().Equal(expectedStatus.String(), actualUnbonding.Status.String(), "host zone unbonding for epoch %d record status", i) - } - } -} - -// Helper function to check that each LSMTokenDepoit's status was either left alone or reverted to it's prior status -func (s *KeeperTestSuite) verifyLSMDepositStatus(expectedLSMDeposits []LSMTokenDepositStatusUpdate, revert bool) { - for i, expectedLSMDeposit := range expectedLSMDeposits { - actualLSMDeposit, found := s.App.RecordsKeeper.GetLSMTokenDeposit(s.Ctx, expectedLSMDeposit.chainId, expectedLSMDeposit.denom) - s.Require().True(found, "lsm deposit found") - - // Only revert record if the revert option is passed and the host zone matches - expectedStatus := expectedLSMDeposit.initialStatus - if revert && actualLSMDeposit.ChainId == HostChainId { - expectedStatus = expectedLSMDeposit.revertedStatus - } - s.Require().Equal(expectedStatus.String(), actualLSMDeposit.Status.String(), "lsm deposit %d", i) - } -} - -// Helper function to check that the delegation changes in progress field was reset to 0 for each validator -func (s *KeeperTestSuite) verifyDelegationChangeInProgressReset() { - hostZone := s.MustGetHostZone(HostChainId) - s.Require().Len(hostZone.Validators, 3, "there should be 3 validators on this host zone") - - for _, validator := range hostZone.Validators { - s.Require().Zero(validator.DelegationChangesInProgress, - "delegation change in progress should have been reset for validator %s", validator.Address) - } -} - -func (s *KeeperTestSuite) TestRestoreInterchainAccount_Success() { - tc := s.SetupRestoreInterchainAccount(true) - - // Confirm there are two channels originally - channels := s.App.IBCKeeper.ChannelKeeper.GetAllChannels(s.Ctx) - s.Require().Len(channels, 2, "there should be 2 channels initially (transfer + delegate)") - - // Close the delegation channel - s.closeICAChannel(tc.delegationPortID, tc.delegationChannelID) - - // Confirm the new channel was created - s.restoreChannelAndVerifySuccess(tc.validMsg, tc.delegationPortID, tc.delegationChannelID) - - // Verify the record status' were reverted - s.verifyDepositRecordsStatus(tc.depositRecordStatusUpdates, true) - s.verifyHostZoneUnbondingStatus(tc.unbondingRecordStatusUpdate, true) - s.verifyLSMDepositStatus(tc.lsmTokenDepositStatusUpdate, true) - s.verifyDelegationChangeInProgressReset() -} - -func (s *KeeperTestSuite) TestRestoreInterchainAccount_InvalidConnectionId() { - tc := s.SetupRestoreInterchainAccount(false) - - // Update the connectionId on the host zone so that it doesn't exist - invalidMsg := tc.validMsg - invalidMsg.ConnectionId = "fake_connection" - - _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().ErrorContains(err, "connection fake_connection not found") -} - -func (s *KeeperTestSuite) TestRestoreInterchainAccount_CannotRestoreNonExistentAcct() { - tc := s.SetupRestoreInterchainAccount(false) - - // Attempt to restore an account that does not exist - msg := tc.validMsg - msg.AccountOwner = types.FormatHostZoneICAOwner(HostChainId, types.ICAAccountType_WITHDRAWAL) - - _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().ErrorContains(err, "ICA controller account address not found: GAIA.WITHDRAWAL") -} - -func (s *KeeperTestSuite) TestRestoreInterchainAccount_HostZoneNotFound() { - tc := s.SetupRestoreInterchainAccount(true) - s.closeICAChannel(tc.delegationPortID, tc.delegationChannelID) - - // Delete the host zone so the lookup fails - // (this check only runs for the delegation channel) - s.App.StakeibcKeeper.RemoveHostZone(s.Ctx, HostChainId) - - _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().ErrorContains(err, "delegation ICA supplied, but no associated host zone") -} - -func (s *KeeperTestSuite) TestRestoreInterchainAccount_RevertDepositRecords_Failure() { - tc := s.SetupRestoreInterchainAccount(true) - - _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) - s.Require().ErrorContains(err, "existing active channel channel-1 for portID icacontroller-GAIA.DELEGATION") - - // Verify the record status' were NOT reverted - s.verifyDepositRecordsStatus(tc.depositRecordStatusUpdates, false) - s.verifyHostZoneUnbondingStatus(tc.unbondingRecordStatusUpdate, false) - s.verifyLSMDepositStatus(tc.lsmTokenDepositStatusUpdate, false) -} - -func (s *KeeperTestSuite) TestRestoreInterchainAccount_NoRecordChange_Success() { - // Here, we're closing and restoring the withdrawal channel so records should not be reverted - tc := s.SetupRestoreInterchainAccount(false) - owner := "GAIA.WITHDRAWAL" - channelID, portID := s.CreateICAChannel(owner) - - // Confirm there are two channels originally - channels := s.App.IBCKeeper.ChannelKeeper.GetAllChannels(s.Ctx) - s.Require().Len(channels, 2, "there should be 2 channels initially (transfer + withdrawal)") - - // Close the withdrawal channel - s.closeICAChannel(portID, channelID) - - // Restore the channel - msg := tc.validMsg - msg.AccountOwner = types.FormatHostZoneICAOwner(HostChainId, types.ICAAccountType_WITHDRAWAL) - s.restoreChannelAndVerifySuccess(msg, portID, channelID) - - // Verify the record status' were NOT reverted - s.verifyDepositRecordsStatus(tc.depositRecordStatusUpdates, false) - s.verifyHostZoneUnbondingStatus(tc.unbondingRecordStatusUpdate, false) - s.verifyLSMDepositStatus(tc.lsmTokenDepositStatusUpdate, false) -} diff --git a/x/stakeibc/keeper/msg_server_resume_host_zone.go b/x/stakeibc/keeper/msg_server_resume_host_zone.go deleted file mode 100644 index 8ce59f15f8..0000000000 --- a/x/stakeibc/keeper/msg_server_resume_host_zone.go +++ /dev/null @@ -1,40 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" -) - -func (k msgServer) ResumeHostZone(goCtx context.Context, msg *types.MsgResumeHostZone) (*types.MsgResumeHostZoneResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - // Get Host Zone - hostZone, found := k.GetHostZone(ctx, msg.ChainId) - if !found { - errMsg := fmt.Sprintf("invalid chain id, zone for %s not found", msg.ChainId) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrHostZoneNotFound, errMsg) - } - - // Check the zone is halted - if !hostZone.Halted { - errMsg := fmt.Sprintf("invalid chain id, zone for %s not halted", msg.ChainId) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrHostZoneNotHalted, errMsg) - } - - // remove from blacklist - stDenom := types.StAssetDenomFromHostZoneDenom(hostZone.HostDenom) - k.RatelimitKeeper.RemoveDenomFromBlacklist(ctx, stDenom) - - // Resume zone - hostZone.Halted = false - k.SetHostZone(ctx, hostZone) - - return &types.MsgResumeHostZoneResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_resume_host_zone_test.go b/x/stakeibc/keeper/msg_server_resume_host_zone_test.go deleted file mode 100644 index 2839b56d50..0000000000 --- a/x/stakeibc/keeper/msg_server_resume_host_zone_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package keeper_test - -import ( - "fmt" - - sdk "github.com/cosmos/cosmos-sdk/types" - _ "github.com/stretchr/testify/suite" - - stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type ResumeHostZoneTestCase struct { - validMsg stakeibctypes.MsgResumeHostZone - zone stakeibctypes.HostZone -} - -func (s *KeeperTestSuite) SetupResumeHostZone() ResumeHostZoneTestCase { - // Register a host zone - hostZone := stakeibctypes.HostZone{ - ChainId: HostChainId, - HostDenom: Atom, - IbcDenom: IbcAtom, - RedemptionRate: sdk.NewDec(1.0), - MinRedemptionRate: sdk.NewDec(9).Quo(sdk.NewDec(10)), - MaxRedemptionRate: sdk.NewDec(15).Quo(sdk.NewDec(10)), - Halted: true, - } - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - defaultMsg := stakeibctypes.MsgResumeHostZone{ - Creator: s.TestAccs[0].String(), - ChainId: HostChainId, - } - - return ResumeHostZoneTestCase{ - validMsg: defaultMsg, - zone: hostZone, - } -} - -// Verify that bounds can be set successfully -func (s *KeeperTestSuite) TestResumeHostZone_Success() { - tc := s.SetupResumeHostZone() - - // Set the inner bounds on the host zone - _, err := s.GetMsgServer().ResumeHostZone(s.Ctx, &tc.validMsg) - s.Require().NoError(err, "should not throw an error") - - // Confirm the inner bounds were set - zone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) - s.Require().True(found, "host zone should be in the store") - - s.Require().False(zone.Halted, "host zone should not be halted") -} - -// verify that non-admins can't call the tx -func (s *KeeperTestSuite) TestResumeHostZone_NonAdmin() { - tc := s.SetupResumeHostZone() - - invalidMsg := tc.validMsg - invalidMsg.Creator = s.TestAccs[1].String() - - err := invalidMsg.ValidateBasic() - s.Require().Error(err, "nonadmins shouldn't be able to call this tx") -} - -// verify that the function can't be called on missing zones -func (s *KeeperTestSuite) TestResumeHostZone_MissingZones() { - tc := s.SetupResumeHostZone() - - invalidMsg := tc.validMsg - invalidChainId := "invalid-chain" - invalidMsg.ChainId = invalidChainId - - // Set the inner bounds on the host zone - _, err := s.GetMsgServer().ResumeHostZone(s.Ctx, &invalidMsg) - - s.Require().Error(err, "shouldn't be able to call tx on missing zones") - expectedErrorMsg := fmt.Sprintf("invalid chain id, zone for %s not found: host zone not found", invalidChainId) - s.Require().Equal(expectedErrorMsg, err.Error(), "should return correct error msg") -} - -// verify that the function can't be called on unhalted zones -func (s *KeeperTestSuite) TestResumeHostZone_UnhaltedZones() { - tc := s.SetupResumeHostZone() - - zone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) - s.Require().True(found, "host zone should be in the store") - s.Require().True(zone.Halted, "host zone should be halted") - zone.Halted = false - s.App.StakeibcKeeper.SetHostZone(s.Ctx, zone) - - // Set the inner bounds on the host zone - _, err := s.GetMsgServer().ResumeHostZone(s.Ctx, &tc.validMsg) - s.Require().Error(err, "shouldn't be able to call tx on unhalted zones") - expectedErrorMsg := fmt.Sprintf("invalid chain id, zone for %s not halted: host zone is not halted", HostChainId) - s.Require().Equal(expectedErrorMsg, err.Error(), "should return correct error msg") -} diff --git a/x/stakeibc/keeper/msg_server_test.go b/x/stakeibc/keeper/msg_server_test.go new file mode 100644 index 0000000000..d89a7a1e9f --- /dev/null +++ b/x/stakeibc/keeper/msg_server_test.go @@ -0,0 +1,2577 @@ +package keeper_test + +import ( + "fmt" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/bech32" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/cosmos/gogoproto/proto" + icatypes "github.com/cosmos/ibc-go/v7/modules/apps/27-interchain-accounts/types" + transfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/types" + channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types" + ibctesting "github.com/cosmos/ibc-go/v7/testing" + + epochtypes "github.com/Stride-Labs/stride/v18/x/epochs/types" + icqtypes "github.com/Stride-Labs/stride/v18/x/interchainquery/types" + recordstypes "github.com/Stride-Labs/stride/v18/x/records/types" + recordtypes "github.com/Stride-Labs/stride/v18/x/records/types" + "github.com/Stride-Labs/stride/v18/x/stakeibc/keeper" + "github.com/Stride-Labs/stride/v18/x/stakeibc/types" + stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" +) + +// ---------------------------------------------------- +// RegisterHostZone +// ---------------------------------------------------- + +type RegisterHostZoneTestCase struct { + validMsg stakeibctypes.MsgRegisterHostZone + epochUnbondingRecordNumber uint64 + strideEpochNumber uint64 + unbondingPeriod uint64 + defaultRedemptionRate sdk.Dec + atomHostZoneChainId string +} + +func (s *KeeperTestSuite) SetupRegisterHostZone() RegisterHostZoneTestCase { + epochUnbondingRecordNumber := uint64(3) + strideEpochNumber := uint64(4) + unbondingPeriod := uint64(14) + defaultRedemptionRate := sdk.NewDec(1) + atomHostZoneChainId := "GAIA" + + s.CreateTransferChannel(HostChainId) + + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, stakeibctypes.EpochTracker{ + EpochIdentifier: epochtypes.DAY_EPOCH, + EpochNumber: epochUnbondingRecordNumber, + }) + + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, stakeibctypes.EpochTracker{ + EpochIdentifier: epochtypes.STRIDE_EPOCH, + EpochNumber: strideEpochNumber, + }) + + epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ + EpochNumber: epochUnbondingRecordNumber, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{}, + } + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) + + defaultMsg := stakeibctypes.MsgRegisterHostZone{ + ConnectionId: ibctesting.FirstConnectionID, + Bech32Prefix: GaiaPrefix, + HostDenom: Atom, + IbcDenom: IbcAtom, + TransferChannelId: ibctesting.FirstChannelID, + UnbondingPeriod: unbondingPeriod, + MinRedemptionRate: sdk.NewDec(0), + MaxRedemptionRate: sdk.NewDec(0), + } + + return RegisterHostZoneTestCase{ + validMsg: defaultMsg, + epochUnbondingRecordNumber: epochUnbondingRecordNumber, + strideEpochNumber: strideEpochNumber, + unbondingPeriod: unbondingPeriod, + defaultRedemptionRate: defaultRedemptionRate, + atomHostZoneChainId: atomHostZoneChainId, + } +} + +// Helper function to test registering a duplicate host zone +// If there's a duplicate connection ID, register_host_zone will error before checking other fields for duplicates +// In order to test those cases, we need to first create a new host zone, +// +// and then attempt to register with duplicate fields in the message +// +// This function 1) creates a new host zone and 2) returns what would be a successful register message +func (s *KeeperTestSuite) createNewHostZoneMessage(chainID string, denom string, prefix string) stakeibctypes.MsgRegisterHostZone { + // Create a new test chain and connection ID + ibctesting.DefaultTestingAppInit = ibctesting.SetupTestingApp + osmoChain := ibctesting.NewTestChain(s.T(), s.Coordinator, chainID) + path := ibctesting.NewPath(s.StrideChain, osmoChain) + s.Coordinator.SetupConnections(path) + connectionId := path.EndpointA.ConnectionID + + // Build what would be a successful message to register the host zone + // Note: this is purposefully missing fields because it is used in failure cases that short circuit + return stakeibctypes.MsgRegisterHostZone{ + ConnectionId: connectionId, + Bech32Prefix: prefix, + HostDenom: denom, + } +} + +// Helper function to assist in testing a failure to create an ICA account +// This function will occupy one of the specified port with the specified channel +// +// so that the registration fails +func (s *KeeperTestSuite) createActiveChannelOnICAPort(accountName string, channelID string) { + portID := fmt.Sprintf("%s%s.%s", icatypes.ControllerPortPrefix, HostChainId, accountName) + openChannel := channeltypes.Channel{State: channeltypes.OPEN} + + // The channel ID doesn't matter here - all that matters is that theres an open channel on the port + s.App.IBCKeeper.ChannelKeeper.SetChannel(s.Ctx, portID, channelID, openChannel) + s.App.ICAControllerKeeper.SetActiveChannelID(s.Ctx, ibctesting.FirstConnectionID, portID, channelID) +} + +func (s *KeeperTestSuite) TestRegisterHostZone_Success() { + tc := s.SetupRegisterHostZone() + msg := tc.validMsg + + // Register host zone + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err, "able to successfully register host zone") + + // Confirm host zone unbonding was added + hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) + s.Require().True(found, "host zone found") + s.Require().Equal(tc.defaultRedemptionRate, hostZone.RedemptionRate, "redemption rate set to default: 1") + s.Require().Equal(tc.defaultRedemptionRate, hostZone.LastRedemptionRate, "last redemption rate set to default: 1") + defaultMinThreshold := sdk.NewDec(int64(stakeibctypes.DefaultMinRedemptionRateThreshold)).Quo(sdk.NewDec(100)) + defaultMaxThreshold := sdk.NewDec(int64(stakeibctypes.DefaultMaxRedemptionRateThreshold)).Quo(sdk.NewDec(100)) + s.Require().Equal(defaultMinThreshold, hostZone.MinRedemptionRate, "min redemption rate set to default") + s.Require().Equal(defaultMaxThreshold, hostZone.MaxRedemptionRate, "max redemption rate set to default") + s.Require().Equal(tc.unbondingPeriod, hostZone.UnbondingPeriod, "unbonding period") + + // Confirm host zone unbonding record was created + epochUnbondingRecord, found := s.App.RecordsKeeper.GetEpochUnbondingRecord(s.Ctx, tc.epochUnbondingRecordNumber) + s.Require().True(found, "epoch unbonding record found") + s.Require().Len(epochUnbondingRecord.HostZoneUnbondings, 1, "host zone unbonding record has one entry") + + // Confirm host zone unbonding was added + hostZoneUnbonding := epochUnbondingRecord.HostZoneUnbondings[0] + s.Require().Equal(HostChainId, hostZoneUnbonding.HostZoneId, "host zone unbonding set for this host zone") + s.Require().Equal(sdkmath.ZeroInt(), hostZoneUnbonding.NativeTokenAmount, "host zone unbonding set to 0 tokens") + s.Require().Equal(recordstypes.HostZoneUnbonding_UNBONDING_QUEUE, hostZoneUnbonding.Status, "host zone unbonding set to bonded") + + // Confirm a module account was created + hostZoneModuleAccount, err := sdk.AccAddressFromBech32(hostZone.DepositAddress) + s.Require().NoError(err, "converting module address to account") + acc := s.App.AccountKeeper.GetAccount(s.Ctx, hostZoneModuleAccount) + s.Require().NotNil(acc, "host zone module account found in account keeper") + + // Confirm an empty deposit record was created + expectedDepositRecord := recordstypes.DepositRecord{ + Id: uint64(0), + Amount: sdkmath.ZeroInt(), + HostZoneId: hostZone.ChainId, + Denom: hostZone.HostDenom, + Status: recordstypes.DepositRecord_TRANSFER_QUEUE, + DepositEpochNumber: tc.strideEpochNumber, + } + + depositRecords := s.App.RecordsKeeper.GetAllDepositRecord(s.Ctx) + s.Require().Len(depositRecords, 1, "number of deposit records") + s.Require().Equal(expectedDepositRecord, depositRecords[0], "deposit record") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_InvalidConnectionId() { + tc := s.SetupRegisterHostZone() + msg := tc.validMsg + msg.ConnectionId = "connection-10" // an invalid connection ID + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().EqualError(err, "invalid connection id, connection-10 not found: failed to register host zone") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateConnectionIdInIBCState() { + // tests for a failure if we register the same host zone twice + // (with a duplicate connectionId stored in the IBCKeeper's state) + tc := s.SetupRegisterHostZone() + msg := tc.validMsg + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err, "able to successfully register host zone once") + + // now all attributes are different, EXCEPT the connection ID + msg.Bech32Prefix = "cosmos-different" // a different Bech32 prefix + msg.HostDenom = "atom-different" // a different host denom + msg.IbcDenom = "ibc-atom-different" // a different IBC denom + + _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + expectedErrMsg := "invalid chain id, zone for GAIA already registered: " + expectedErrMsg += "failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate connection ID should fail") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateConnectionIdInStakeibcState() { + // tests for a failure if we register the same host zone twice + // (with a duplicate connectionId stored in a different host zone in stakeibc) + tc := s.SetupRegisterHostZone() + msg := tc.validMsg + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err, "able to successfully register host zone once") + + // Create the message for a brand new host zone + // (without modifications, you would expect this to be successful) + newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") + + // Add a different host zone with the same connection Id as OSMO + newHostZone := stakeibctypes.HostZone{ + ChainId: "JUNO", + ConnectionId: newHostZoneMsg.ConnectionId, + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, newHostZone) + + // Registering should fail with a duplicate connection ID + _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &newHostZoneMsg) + expectedErrMsg := "connectionId connection-1 already registered: " + expectedErrMsg += "failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate connection ID should fail") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateHostDenom() { + // tests for a failure if we register the same host zone twice (with a duplicate host denom) + tc := s.SetupRegisterHostZone() + + // Register host zones successfully + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().NoError(err, "able to successfully register host zone once") + + // Create the message for a brand new host zone + // (without modifications, you would expect this to be successful) + newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") + + // Try to register with a duplicate host denom - it should fail + invalidMsg := newHostZoneMsg + invalidMsg.HostDenom = tc.validMsg.HostDenom + + _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + expectedErrMsg := "host denom uatom already registered: failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate host denom should fail") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateTransferChannel() { + // tests for a failure if we register the same host zone twice (with a duplicate transfer) + tc := s.SetupRegisterHostZone() + + // Register host zones successfully + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().NoError(err, "able to successfully register host zone once") + + // Create the message for a brand new host zone + // (without modifications, you would expect this to be successful) + newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") + + // Try to register with a duplicate transfer channel - it should fail + invalidMsg := newHostZoneMsg + invalidMsg.TransferChannelId = tc.validMsg.TransferChannelId + + _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + expectedErrMsg := "transfer channel channel-0 already registered: failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate host denom should fail") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_DuplicateBech32Prefix() { + // tests for a failure if we register the same host zone twice (with a duplicate bech32 prefix) + tc := s.SetupRegisterHostZone() + + // Register host zones successfully + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().NoError(err, "able to successfully register host zone once") + + // Create the message for a brand new host zone + // (without modifications, you would expect this to be successful) + newHostZoneMsg := s.createNewHostZoneMessage("OSMO", "osmo", "osmo") + + // Try to register with a duplicate bech32prefix - it should fail + invalidMsg := newHostZoneMsg + invalidMsg.Bech32Prefix = tc.validMsg.Bech32Prefix + + _, err = s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + expectedErrMsg := "bech32prefix cosmos already registered: failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "registering host zone with duplicate bech32 prefix should fail") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_CannotFindDayEpochTracker() { + // tests for a failure if the epoch tracker cannot be found + tc := s.SetupRegisterHostZone() + msg := tc.validMsg + + // delete the epoch tracker + s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.DAY_EPOCH) + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + expectedErrMsg := "epoch tracker (day) not found: epoch not found" + s.Require().EqualError(err, expectedErrMsg, "day epoch tracker not found") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_CannotFindStrideEpochTracker() { + // tests for a failure if the epoch tracker cannot be found + tc := s.SetupRegisterHostZone() + msg := tc.validMsg + + // delete the epoch tracker + s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.STRIDE_EPOCH) + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + expectedErrMsg := "epoch tracker (stride_epoch) not found: epoch not found" + s.Require().EqualError(err, expectedErrMsg, "stride epoch tracker not found") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_CannotFindEpochUnbondingRecord() { + // tests for a failure if the epoch unbonding record cannot be found + tc := s.SetupRegisterHostZone() + msg := tc.validMsg + + // delete the epoch unbonding record + s.App.RecordsKeeper.RemoveEpochUnbondingRecord(s.Ctx, tc.epochUnbondingRecordNumber) + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &msg) + expectedErrMsg := "unable to find latest epoch unbonding record: epoch unbonding record not found" + s.Require().EqualError(err, expectedErrMsg, " epoch unbonding record not found") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterDelegationAccount() { + // tests for a failure if the epoch unbonding record cannot be found + tc := s.SetupRegisterHostZone() + + // Create channel on delegation port + s.createActiveChannelOnICAPort("DELEGATION", "channel-1") + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + expectedErrMsg := "unable to register delegation account, err: existing active channel channel-1 for portID icacontroller-GAIA.DELEGATION " + expectedErrMsg += "on connection connection-0: active channel already set for this owner: " + expectedErrMsg += "failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "can't register delegation account") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterFeeAccount() { + // tests for a failure if the epoch unbonding record cannot be found + tc := s.SetupRegisterHostZone() + + // Create channel on fee port + s.createActiveChannelOnICAPort("FEE", "channel-1") + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + expectedErrMsg := "unable to register fee account, err: existing active channel channel-1 for portID icacontroller-GAIA.FEE " + expectedErrMsg += "on connection connection-0: active channel already set for this owner: " + expectedErrMsg += "failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "can't register redemption account") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterWithdrawalAccount() { + // tests for a failure if the epoch unbonding record cannot be found + tc := s.SetupRegisterHostZone() + + // Create channel on withdrawal port + s.createActiveChannelOnICAPort("WITHDRAWAL", "channel-1") + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + expectedErrMsg := "unable to register withdrawal account, err: existing active channel channel-1 for portID icacontroller-GAIA.WITHDRAWAL " + expectedErrMsg += "on connection connection-0: active channel already set for this owner: " + expectedErrMsg += "failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "can't register redemption account") +} + +func (s *KeeperTestSuite) TestRegisterHostZone_CannotRegisterRedemptionAccount() { + // tests for a failure if the epoch unbonding record cannot be found + tc := s.SetupRegisterHostZone() + + // Create channel on redemption port + s.createActiveChannelOnICAPort("REDEMPTION", "channel-1") + + _, err := s.GetMsgServer().RegisterHostZone(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + expectedErrMsg := "unable to register redemption account, err: existing active channel channel-1 for portID icacontroller-GAIA.REDEMPTION " + expectedErrMsg += "on connection connection-0: active channel already set for this owner: " + expectedErrMsg += "failed to register host zone" + s.Require().EqualError(err, expectedErrMsg, "can't register redemption account") +} + +// ---------------------------------------------------- +// AddValidator +// ---------------------------------------------------- + +type AddValidatorsTestCase struct { + hostZone types.HostZone + validMsg types.MsgAddValidators + expectedValidators []*types.Validator + validatorQueryDataToName map[string]string +} + +// Helper function to determine the validator's key in the staking store +// which is used as the request data in the ICQ +func (s *KeeperTestSuite) getSharesToTokensRateQueryData(validatorAddress string) []byte { + _, validatorAddressBz, err := bech32.DecodeAndConvert(validatorAddress) + s.Require().NoError(err, "no error expected when decoding validator address") + return stakingtypes.GetValidatorKey(validatorAddressBz) +} + +func (s *KeeperTestSuite) SetupAddValidators() AddValidatorsTestCase { + slashThreshold := uint64(10) + params := types.DefaultParams() + params.ValidatorSlashQueryThreshold = slashThreshold + s.App.StakeibcKeeper.SetParams(s.Ctx, params) + + totalDelegations := sdkmath.NewInt(100_000) + expectedSlashCheckpoint := sdkmath.NewInt(10_000) + + hostZone := types.HostZone{ + ChainId: "GAIA", + ConnectionId: ibctesting.FirstConnectionID, + Validators: []*types.Validator{}, + TotalDelegations: totalDelegations, + } + + validatorAddresses := map[string]string{ + "val1": "stridevaloper1uk4ze0x4nvh4fk0xm4jdud58eqn4yxhrgpwsqm", + "val2": "stridevaloper17kht2x2ped6qytr2kklevtvmxpw7wq9rcfud5c", + "val3": "stridevaloper1nnurja9zt97huqvsfuartetyjx63tc5zrj5x9f", + } + + // mapping of query request data to validator name + // serves as a reverse lookup to map sharesToTokens rate queries to validators + validatorQueryDataToName := map[string]string{} + for name, address := range validatorAddresses { + queryData := s.getSharesToTokensRateQueryData(address) + validatorQueryDataToName[string(queryData)] = name + } + + validMsg := types.MsgAddValidators{ + Creator: "stride_ADMIN", + HostZone: HostChainId, + Validators: []*types.Validator{ + {Name: "val1", Address: validatorAddresses["val1"], Weight: 1}, + {Name: "val2", Address: validatorAddresses["val2"], Weight: 2}, + {Name: "val3", Address: validatorAddresses["val3"], Weight: 3}, + }, + } + + expectedValidators := []*types.Validator{ + {Name: "val1", Address: validatorAddresses["val1"], Weight: 1}, + {Name: "val2", Address: validatorAddresses["val2"], Weight: 2}, + {Name: "val3", Address: validatorAddresses["val3"], Weight: 3}, + } + for _, validator := range expectedValidators { + validator.Delegation = sdkmath.ZeroInt() + validator.SlashQueryProgressTracker = sdkmath.ZeroInt() + validator.SharesToTokensRate = sdk.ZeroDec() + validator.SlashQueryCheckpoint = expectedSlashCheckpoint + } + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + // Mock the latest client height for the ICQ submission + s.MockClientLatestHeight(1) + + return AddValidatorsTestCase{ + hostZone: hostZone, + validMsg: validMsg, + expectedValidators: expectedValidators, + validatorQueryDataToName: validatorQueryDataToName, + } +} + +func (s *KeeperTestSuite) TestAddValidators_Successful() { + tc := s.SetupAddValidators() + + // Add validators + _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().NoError(err) + + hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, "GAIA") + s.Require().True(found, "host zone found") + s.Require().Equal(3, len(hostZone.Validators), "number of validators") + + for i := 0; i < 3; i++ { + s.Require().Equal(*tc.expectedValidators[i], *hostZone.Validators[i], "validators %d", i) + } + + // Confirm ICQs were submitted + queries := s.App.InterchainqueryKeeper.AllQueries(s.Ctx) + s.Require().Len(queries, 3) + + // Map the query responses to the validator names to get the names of the validators that + // were queried + queriedValidators := []string{} + for i, query := range queries { + validator, ok := tc.validatorQueryDataToName[string(query.RequestData)] + s.Require().True(ok, "query from response %d does not match any expected requests", i) + queriedValidators = append(queriedValidators, validator) + } + + // Confirm the list of queried validators matches the full list of validators + allValidatorNames := []string{} + for _, expected := range tc.expectedValidators { + allValidatorNames = append(allValidatorNames, expected.Name) + } + s.Require().ElementsMatch(allValidatorNames, queriedValidators, "queried validators") +} + +func (s *KeeperTestSuite) TestAddValidators_HostZoneNotFound() { + tc := s.SetupAddValidators() + + // Replace hostzone in msg to a host zone that doesn't exist + badHostZoneMsg := tc.validMsg + badHostZoneMsg.HostZone = "gaia" + _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &badHostZoneMsg) + s.Require().EqualError(err, "Host Zone (gaia) not found: host zone not found") +} + +func (s *KeeperTestSuite) TestAddValidators_AddressAlreadyExists() { + tc := s.SetupAddValidators() + + // Update host zone so that the name val1 already exists + hostZone := tc.hostZone + duplicateAddress := tc.expectedValidators[0].Address + duplicateVal := types.Validator{Name: "new_val", Address: duplicateAddress} + hostZone.Validators = []*types.Validator{&duplicateVal} + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + // Change the validator address to val1 so that the message errors + expectedError := fmt.Sprintf("Validator address (%s) already exists on Host Zone (GAIA)", duplicateAddress) + _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().ErrorContains(err, expectedError) +} + +func (s *KeeperTestSuite) TestAddValidators_NameAlreadyExists() { + tc := s.SetupAddValidators() + + // Update host zone so that val1's address already exists + hostZone := tc.hostZone + duplicateName := tc.expectedValidators[0].Name + duplicateVal := types.Validator{Name: duplicateName, Address: "new_address"} + hostZone.Validators = []*types.Validator{&duplicateVal} + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + // Change the validator name to val1 so that the message errors + expectedError := fmt.Sprintf("Validator name (%s) already exists on Host Zone (GAIA)", duplicateName) + _, err := s.GetMsgServer().AddValidators(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().ErrorContains(err, expectedError) +} + +// ---------------------------------------------------- +// DeleteValidator +// ---------------------------------------------------- + +type DeleteValidatorTestCase struct { + hostZone stakeibctypes.HostZone + initialValidators []*stakeibctypes.Validator + validMsgs []stakeibctypes.MsgDeleteValidator +} + +func (s *KeeperTestSuite) SetupDeleteValidator() DeleteValidatorTestCase { + initialValidators := []*stakeibctypes.Validator{ + { + Name: "val1", + Address: "stride_VAL1", + Weight: 0, + Delegation: sdkmath.ZeroInt(), + SharesToTokensRate: sdk.OneDec(), + }, + { + Name: "val2", + Address: "stride_VAL2", + Weight: 0, + Delegation: sdkmath.ZeroInt(), + SharesToTokensRate: sdk.OneDec(), + }, + } + + hostZone := stakeibctypes.HostZone{ + ChainId: "GAIA", + Validators: initialValidators, + } + validMsgs := []stakeibctypes.MsgDeleteValidator{ + { + Creator: "stride_ADDRESS", + HostZone: "GAIA", + ValAddr: "stride_VAL1", + }, + { + Creator: "stride_ADDRESS", + HostZone: "GAIA", + ValAddr: "stride_VAL2", + }, + } + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + return DeleteValidatorTestCase{ + hostZone: hostZone, + initialValidators: initialValidators, + validMsgs: validMsgs, + } +} + +func (s *KeeperTestSuite) TestDeleteValidator_Successful() { + tc := s.SetupDeleteValidator() + + // Delete first validator + _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[0]) + s.Require().NoError(err) + + hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, "GAIA") + s.Require().True(found, "host zone found") + s.Require().Equal(1, len(hostZone.Validators), "number of validators should be 1") + s.Require().Equal(tc.initialValidators[1:], hostZone.Validators, "validators list after removing 1 validator") + + // Delete second validator + _, err = s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[1]) + s.Require().NoError(err) + + hostZone, found = s.App.StakeibcKeeper.GetHostZone(s.Ctx, "GAIA") + s.Require().True(found, "host zone found") + s.Require().Equal(0, len(hostZone.Validators), "number of validators should be 0") +} + +func (s *KeeperTestSuite) TestDeleteValidator_HostZoneNotFound() { + tc := s.SetupDeleteValidator() + + // Replace hostzone in msg to a host zone that doesn't exist + badHostZoneMsg := tc.validMsgs[0] + badHostZoneMsg.HostZone = "gaia" + _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &badHostZoneMsg) + errMsg := "Validator (stride_VAL1) not removed from host zone (gaia) " + errMsg += "| err: HostZone (gaia) not found: host zone not found: validator not removed" + s.Require().EqualError(err, errMsg) +} + +func (s *KeeperTestSuite) TestDeleteValidator_AddressNotFound() { + tc := s.SetupDeleteValidator() + + // Build message with a validator address that does not exist + badAddressMsg := tc.validMsgs[0] + badAddressMsg.ValAddr = "stride_VAL5" + _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &badAddressMsg) + + errMsg := "Validator (stride_VAL5) not removed from host zone (GAIA) " + errMsg += "| err: Validator address (stride_VAL5) not found on host zone (GAIA): " + errMsg += "validator not found: validator not removed" + s.Require().EqualError(err, errMsg) +} + +func (s *KeeperTestSuite) TestDeleteValidator_NonZeroDelegation() { + tc := s.SetupDeleteValidator() + + // Update val1 to have a non-zero delegation + hostZone := tc.hostZone + hostZone.Validators[0].Delegation = sdkmath.NewInt(1) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[0]) + errMsg := "Validator (stride_VAL1) not removed from host zone (GAIA) " + errMsg += "| err: Validator (stride_VAL1) has non-zero delegation (1) or weight (0): " + errMsg += "validator not removed" + s.Require().EqualError(err, errMsg) +} + +func (s *KeeperTestSuite) TestDeleteValidator_NonZeroWeight() { + tc := s.SetupDeleteValidator() + + // Update val1 to have a non-zero weight + hostZone := tc.hostZone + hostZone.Validators[0].Weight = 1 + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + _, err := s.GetMsgServer().DeleteValidator(sdk.WrapSDKContext(s.Ctx), &tc.validMsgs[0]) + errMsg := "Validator (stride_VAL1) not removed from host zone (GAIA) " + errMsg += "| err: Validator (stride_VAL1) has non-zero delegation (0) or weight (1): " + errMsg += "validator not removed" + s.Require().EqualError(err, errMsg) +} + +// ---------------------------------------------------- +// ClearBalance +// ---------------------------------------------------- + +type ClearBalanceState struct { + feeChannel Channel + hz stakeibctypes.HostZone +} + +type ClearBalanceTestCase struct { + initialState ClearBalanceState + validMsg stakeibctypes.MsgClearBalance +} + +func (s *KeeperTestSuite) SetupClearBalance() ClearBalanceTestCase { + // fee account + feeAccountOwner := fmt.Sprintf("%s.%s", HostChainId, "FEE") + feeChannelID, _ := s.CreateICAChannel(feeAccountOwner) + feeAddress := s.IcaAddresses[feeAccountOwner] + // hz + depositAddress := types.NewHostZoneDepositAddress(HostChainId) + hostZone := stakeibctypes.HostZone{ + ChainId: HostChainId, + ConnectionId: ibctesting.FirstConnectionID, + HostDenom: Atom, + IbcDenom: IbcAtom, + RedemptionRate: sdk.NewDec(1.0), + DepositAddress: depositAddress.String(), + FeeIcaAddress: feeAddress, + } + + amount := sdkmath.NewInt(1_000_000) + + user := Account{ + acc: s.TestAccs[0], + } + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + return ClearBalanceTestCase{ + initialState: ClearBalanceState{ + hz: hostZone, + feeChannel: Channel{ + PortID: icatypes.ControllerPortPrefix + feeAccountOwner, + ChannelID: feeChannelID, + }, + }, + validMsg: stakeibctypes.MsgClearBalance{ + Creator: user.acc.String(), + ChainId: HostChainId, + Amount: amount, + Channel: feeChannelID, + }, + } +} + +func (s *KeeperTestSuite) TestClearBalance_Successful() { + tc := s.SetupClearBalance() + + // Get the sequence number before the ICA is submitted to confirm it incremented + feeChannel := tc.initialState.feeChannel + feePortId := feeChannel.PortID + feeChannelId := feeChannel.ChannelID + + startSequence, found := s.App.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, feePortId, feeChannelId) + s.Require().True(found, "sequence number not found before clear balance") + + _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().NoError(err, "balance clears") + + // Confirm the sequence number was incremented + endSequence, found := s.App.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, feePortId, feeChannelId) + s.Require().True(found, "sequence number not found after clear balance") + s.Require().Equal(endSequence, startSequence+1, "sequence number after clear balance") +} + +func (s *KeeperTestSuite) TestClearBalance_HostChainMissing() { + tc := s.SetupClearBalance() + // remove the host zone + s.App.StakeibcKeeper.RemoveHostZone(s.Ctx, HostChainId) + _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "chainId: GAIA: host zone not registered") +} + +func (s *KeeperTestSuite) TestClearBalance_FeeAccountMissing() { + tc := s.SetupClearBalance() + // no fee account + tc.initialState.hz.FeeIcaAddress = "" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, tc.initialState.hz) + _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "fee acount not found for chainId: GAIA: ICA acccount not found on host zone") +} + +func (s *KeeperTestSuite) TestClearBalance_ParseCoinError() { + tc := s.SetupClearBalance() + // invalid denom + tc.initialState.hz.HostDenom = ":" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, tc.initialState.hz) + _, err := s.GetMsgServer().ClearBalance(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "failed to parse coin (1000000:): invalid decimal coin expression: 1000000:") +} + +// ---------------------------------------------------- +// LiquidStake +// ---------------------------------------------------- + +type Account struct { + acc sdk.AccAddress + atomBalance sdk.Coin + stAtomBalance sdk.Coin +} + +type LiquidStakeState struct { + depositRecordAmount sdkmath.Int + hostZone stakeibctypes.HostZone +} + +type LiquidStakeTestCase struct { + user Account + zoneAccount Account + initialState LiquidStakeState + validMsg stakeibctypes.MsgLiquidStake +} + +func (s *KeeperTestSuite) SetupLiquidStake() LiquidStakeTestCase { + stakeAmount := sdkmath.NewInt(1_000_000) + initialDepositAmount := sdkmath.NewInt(1_000_000) + user := Account{ + acc: s.TestAccs[0], + atomBalance: sdk.NewInt64Coin(IbcAtom, 10_000_000), + stAtomBalance: sdk.NewInt64Coin(StAtom, 0), + } + s.FundAccount(user.acc, user.atomBalance) + + depositAddress := stakeibctypes.NewHostZoneDepositAddress(HostChainId) + + zoneAccount := Account{ + acc: depositAddress, + atomBalance: sdk.NewInt64Coin(IbcAtom, 10_000_000), + stAtomBalance: sdk.NewInt64Coin(StAtom, 10_000_000), + } + s.FundAccount(zoneAccount.acc, zoneAccount.atomBalance) + s.FundAccount(zoneAccount.acc, zoneAccount.stAtomBalance) + + hostZone := stakeibctypes.HostZone{ + ChainId: HostChainId, + HostDenom: Atom, + IbcDenom: IbcAtom, + RedemptionRate: sdk.NewDec(1.0), + DepositAddress: depositAddress.String(), + } + + epochTracker := stakeibctypes.EpochTracker{ + EpochIdentifier: epochtypes.STRIDE_EPOCH, + EpochNumber: 1, + } + + initialDepositRecord := recordtypes.DepositRecord{ + Id: 1, + DepositEpochNumber: 1, + HostZoneId: "GAIA", + Amount: initialDepositAmount, + Status: recordtypes.DepositRecord_TRANSFER_QUEUE, + } + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, epochTracker) + s.App.RecordsKeeper.SetDepositRecord(s.Ctx, initialDepositRecord) + + return LiquidStakeTestCase{ + user: user, + zoneAccount: zoneAccount, + initialState: LiquidStakeState{ + depositRecordAmount: initialDepositAmount, + hostZone: hostZone, + }, + validMsg: stakeibctypes.MsgLiquidStake{ + Creator: user.acc.String(), + HostDenom: Atom, + Amount: stakeAmount, + }, + } +} + +func (s *KeeperTestSuite) TestLiquidStake_Successful() { + tc := s.SetupLiquidStake() + user := tc.user + zoneAccount := tc.zoneAccount + msg := tc.validMsg + initialStAtomSupply := s.App.BankKeeper.GetSupply(s.Ctx, StAtom) + + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err) + + // Confirm balances + // User IBC/UATOM balance should have DECREASED by the size of the stake + expectedUserAtomBalance := user.atomBalance.SubAmount(msg.Amount) + actualUserAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, IbcAtom) + // zoneAccount IBC/UATOM balance should have INCREASED by the size of the stake + expectedzoneAccountAtomBalance := zoneAccount.atomBalance.AddAmount(msg.Amount) + actualzoneAccountAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, zoneAccount.acc, IbcAtom) + // User STUATOM balance should have INCREASED by the size of the stake + expectedUserStAtomBalance := user.stAtomBalance.AddAmount(msg.Amount) + actualUserStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, StAtom) + // Bank supply of STUATOM should have INCREASED by the size of the stake + expectedBankSupply := initialStAtomSupply.AddAmount(msg.Amount) + actualBankSupply := s.App.BankKeeper.GetSupply(s.Ctx, StAtom) + + s.CompareCoins(expectedUserStAtomBalance, actualUserStAtomBalance, "user stuatom balance") + s.CompareCoins(expectedUserAtomBalance, actualUserAtomBalance, "user ibc/uatom balance") + s.CompareCoins(expectedzoneAccountAtomBalance, actualzoneAccountAtomBalance, "zoneAccount ibc/uatom balance") + s.CompareCoins(expectedBankSupply, actualBankSupply, "bank stuatom supply") + + // Confirm deposit record adjustment + records := s.App.RecordsKeeper.GetAllDepositRecord(s.Ctx) + s.Require().Len(records, 1, "number of deposit records") + + expectedDepositRecordAmount := tc.initialState.depositRecordAmount.Add(msg.Amount) + actualDepositRecordAmount := records[0].Amount + s.Require().Equal(expectedDepositRecordAmount, actualDepositRecordAmount, "deposit record amount") +} + +func (s *KeeperTestSuite) TestLiquidStake_DifferentRedemptionRates() { + tc := s.SetupLiquidStake() + user := tc.user + msg := tc.validMsg + + // Loop over sharesToTokens rates: {0.92, 0.94, ..., 1.2} + for i := -8; i <= 10; i += 2 { + redemptionDelta := sdk.NewDecWithPrec(1.0, 1).Quo(sdk.NewDec(10)).Mul(sdk.NewDec(int64(i))) // i = 2 => delta = 0.02 + newRedemptionRate := sdk.NewDec(1.0).Add(redemptionDelta) + redemptionRateFloat := newRedemptionRate + + // Update rate in host zone + hz := tc.initialState.hostZone + hz.RedemptionRate = newRedemptionRate + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) + + // Liquid stake for each balance and confirm stAtom minted + startingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, StAtom).Amount + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err) + endingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, StAtom).Amount + actualStAtomMinted := endingStAtomBalance.Sub(startingStAtomBalance) + + expectedStAtomMinted := sdk.NewDecFromInt(msg.Amount).Quo(redemptionRateFloat).TruncateInt() + testDescription := fmt.Sprintf("st atom balance for redemption rate: %v", redemptionRateFloat) + s.Require().Equal(expectedStAtomMinted, actualStAtomMinted, testDescription) + } +} + +func (s *KeeperTestSuite) TestLiquidStake_HostZoneNotFound() { + tc := s.SetupLiquidStake() + // Update message with invalid denom + invalidMsg := tc.validMsg + invalidMsg.HostDenom = "ufakedenom" + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + + s.Require().EqualError(err, "no host zone found for denom (ufakedenom): invalid token denom") +} + +func (s *KeeperTestSuite) TestLiquidStake_HostZoneHalted() { + tc := s.SetupLiquidStake() + + // Update the host zone so that it's halted + badHostZone := tc.initialState.hostZone + badHostZone.Halted = true + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "halted host zone found for denom (uatom): Halted host zone found") +} + +func (s *KeeperTestSuite) TestLiquidStake_InvalidUserAddress() { + tc := s.SetupLiquidStake() + + // Update hostzone with invalid address + invalidMsg := tc.validMsg + invalidMsg.Creator = "cosmosXXX" + + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, "user's address is invalid: decoding bech32 failed: string not all lowercase or all uppercase") +} + +func (s *KeeperTestSuite) TestLiquidStake_InvalidHostAddress() { + tc := s.SetupLiquidStake() + + // Update hostzone with invalid address + badHostZone := tc.initialState.hostZone + badHostZone.DepositAddress = "cosmosXXX" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "host zone address is invalid: decoding bech32 failed: string not all lowercase or all uppercase") +} + +func (s *KeeperTestSuite) TestLiquidStake_RateBelowMinThreshold() { + tc := s.SetupLiquidStake() + msg := tc.validMsg + + // Update rate in host zone to below min threshold + hz := tc.initialState.hostZone + hz.RedemptionRate = sdk.MustNewDecFromStr("0.8") + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) + + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().Error(err) +} + +func (s *KeeperTestSuite) TestLiquidStake_RateAboveMaxThreshold() { + tc := s.SetupLiquidStake() + msg := tc.validMsg + + // Update rate in host zone to below min threshold + hz := tc.initialState.hostZone + hz.RedemptionRate = sdk.NewDec(2) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) + + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().Error(err) +} + +func (s *KeeperTestSuite) TestLiquidStake_NoEpochTracker() { + tc := s.SetupLiquidStake() + // Remove epoch tracker + s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.STRIDE_EPOCH) + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + + s.Require().EqualError(err, fmt.Sprintf("no epoch number for epoch (%s): not found", epochtypes.STRIDE_EPOCH)) +} + +func (s *KeeperTestSuite) TestLiquidStake_NoDepositRecord() { + tc := s.SetupLiquidStake() + // Remove deposit record + s.App.RecordsKeeper.RemoveDepositRecord(s.Ctx, 1) + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + + s.Require().EqualError(err, fmt.Sprintf("no deposit record for epoch (%d): not found", 1)) +} + +func (s *KeeperTestSuite) TestLiquidStake_NotIbcDenom() { + tc := s.SetupLiquidStake() + // Update hostzone with non-ibc denom + badDenom := "i/uatom" + badHostZone := tc.initialState.hostZone + badHostZone.IbcDenom = badDenom + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + // Fund the user with the non-ibc denom + s.FundAccount(tc.user.acc, sdk.NewInt64Coin(badDenom, 1000000000)) + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + + s.Require().EqualError(err, fmt.Sprintf("denom is not an IBC token (%s): invalid token denom", badHostZone.IbcDenom)) +} + +func (s *KeeperTestSuite) TestLiquidStake_ZeroStTokens() { + tc := s.SetupLiquidStake() + + // Adjust redemption rate and liquid stake amount so that the number of stTokens would be zero + // stTokens = 1(amount) / 1.1(RR) = rounds down to 0 + hostZone := tc.initialState.hostZone + hostZone.RedemptionRate = sdk.NewDecWithPrec(11, 1) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + tc.validMsg.Amount = sdkmath.NewInt(1) + + // The liquid stake should fail + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "Liquid stake of 1uatom would return 0 stTokens: Liquid staked amount is too small") +} + +func (s *KeeperTestSuite) TestLiquidStake_InsufficientBalance() { + tc := s.SetupLiquidStake() + // Set liquid stake amount to value greater than account balance + invalidMsg := tc.validMsg + balance := tc.user.atomBalance.Amount + invalidMsg.Amount = balance.Add(sdkmath.NewInt(1000)) + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + + expectedErr := fmt.Sprintf("balance is lower than staking amount. staking amount: %v, balance: %v: insufficient funds", balance.Add(sdkmath.NewInt(1000)), balance) + s.Require().EqualError(err, expectedErr) +} + +func (s *KeeperTestSuite) TestLiquidStake_HaltedZone() { + tc := s.SetupLiquidStake() + haltedHostZone := tc.initialState.hostZone + haltedHostZone.Halted = true + s.App.StakeibcKeeper.SetHostZone(s.Ctx, haltedHostZone) + s.FundAccount(tc.user.acc, sdk.NewInt64Coin(haltedHostZone.IbcDenom, 1000000000)) + _, err := s.GetMsgServer().LiquidStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + + s.Require().EqualError(err, fmt.Sprintf("halted host zone found for denom (%s): Halted host zone found", haltedHostZone.HostDenom)) +} + +// ---------------------------------------------------- +// RedeemStake +// ---------------------------------------------------- + +type RedeemStakeState struct { + epochNumber uint64 + initialNativeEpochUnbondingAmount sdkmath.Int + initialStTokenEpochUnbondingAmount sdkmath.Int +} +type RedeemStakeTestCase struct { + user Account + hostZone stakeibctypes.HostZone + zoneAccount Account + initialState RedeemStakeState + validMsg stakeibctypes.MsgRedeemStake + expectedNativeAmount sdkmath.Int +} + +func (s *KeeperTestSuite) SetupRedeemStake() RedeemStakeTestCase { + redeemAmount := sdkmath.NewInt(1_000_000) + redemptionRate := sdk.MustNewDecFromStr("1.5") + expectedNativeAmount := sdkmath.NewInt(1_500_000) + + user := Account{ + acc: s.TestAccs[0], + atomBalance: sdk.NewInt64Coin("ibc/uatom", 10_000_000), + stAtomBalance: sdk.NewInt64Coin("stuatom", 10_000_000), + } + s.FundAccount(user.acc, user.atomBalance) + s.FundAccount(user.acc, user.stAtomBalance) + + depositAddress := stakeibctypes.NewHostZoneDepositAddress(HostChainId) + + zoneAccount := Account{ + acc: depositAddress, + atomBalance: sdk.NewInt64Coin("ibc/uatom", 10_000_000), + stAtomBalance: sdk.NewInt64Coin("stuatom", 10_000_000), + } + s.FundAccount(zoneAccount.acc, zoneAccount.atomBalance) + s.FundAccount(zoneAccount.acc, zoneAccount.stAtomBalance) + + // TODO define the host zone with total delegation and validators with staked amounts + hostZone := stakeibctypes.HostZone{ + ChainId: HostChainId, + HostDenom: "uatom", + Bech32Prefix: "cosmos", + RedemptionRate: redemptionRate, + TotalDelegations: sdkmath.NewInt(1234567890), + DepositAddress: depositAddress.String(), + } + + epochTrackerDay := stakeibctypes.EpochTracker{ + EpochIdentifier: epochtypes.DAY_EPOCH, + EpochNumber: 1, + } + + epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ + EpochNumber: 1, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{}, + } + + hostZoneUnbonding := &recordtypes.HostZoneUnbonding{ + NativeTokenAmount: sdkmath.ZeroInt(), + Denom: "uatom", + HostZoneId: HostChainId, + Status: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + } + epochUnbondingRecord.HostZoneUnbondings = append(epochUnbondingRecord.HostZoneUnbondings, hostZoneUnbonding) + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, epochTrackerDay) + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) + + return RedeemStakeTestCase{ + user: user, + hostZone: hostZone, + zoneAccount: zoneAccount, + expectedNativeAmount: expectedNativeAmount, + initialState: RedeemStakeState{ + epochNumber: epochTrackerDay.EpochNumber, + initialNativeEpochUnbondingAmount: sdkmath.ZeroInt(), + initialStTokenEpochUnbondingAmount: sdkmath.ZeroInt(), + }, + validMsg: stakeibctypes.MsgRedeemStake{ + Creator: user.acc.String(), + Amount: redeemAmount, + HostZone: HostChainId, + // TODO set this dynamically through test helpers for host zone + Receiver: "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8uf", + }, + } +} + +func (s *KeeperTestSuite) TestRedeemStake_Successful() { + tc := s.SetupRedeemStake() + initialState := tc.initialState + + msg := tc.validMsg + user := tc.user + redeemAmount := msg.Amount + + // Split the message amount in 2, and call redeem stake twice (each with half the amount) + // This will check that the same user can redeem multiple times + msg1 := msg + msg1.Amount = msg1.Amount.Quo(sdkmath.NewInt(2)) // half the amount + + msg2 := msg + msg2.Amount = msg.Amount.Sub(msg1.Amount) // remaining half + + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &msg1) + s.Require().NoError(err, "no error expected during first redemption") + + _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &msg2) + s.Require().NoError(err, "no error expected during second redemption") + + // User STUATOM balance should have DECREASED by the amount to be redeemed + expectedUserStAtomBalance := user.stAtomBalance.SubAmount(redeemAmount) + actualUserStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, user.acc, "stuatom") + s.CompareCoins(expectedUserStAtomBalance, actualUserStAtomBalance, "user stuatom balance") + + // Gaia's hostZoneUnbonding NATIVE TOKEN amount should have INCREASED from 0 to the amount redeemed multiplied by the redemption rate + // Gaia's hostZoneUnbonding STTOKEN amount should have INCREASED from 0 to be amount redeemed + epochTracker, found := s.App.StakeibcKeeper.GetEpochTracker(s.Ctx, "day") + s.Require().True(found, "epoch tracker") + epochUnbondingRecord, found := s.App.RecordsKeeper.GetEpochUnbondingRecord(s.Ctx, epochTracker.EpochNumber) + s.Require().True(found, "epoch unbonding record") + hostZoneUnbonding, found := s.App.RecordsKeeper.GetHostZoneUnbondingByChainId(s.Ctx, epochUnbondingRecord.EpochNumber, HostChainId) + s.Require().True(found, "host zone unbondings by chain ID") + + expectedHostZoneUnbondingNativeAmount := initialState.initialNativeEpochUnbondingAmount.Add(tc.expectedNativeAmount) + expectedHostZoneUnbondingStTokenAmount := initialState.initialStTokenEpochUnbondingAmount.Add(redeemAmount) + + s.Require().Equal(expectedHostZoneUnbondingNativeAmount, hostZoneUnbonding.NativeTokenAmount, "host zone native unbonding amount") + s.Require().Equal(expectedHostZoneUnbondingStTokenAmount, hostZoneUnbonding.StTokenAmount, "host zone stToken burn amount") + + // UserRedemptionRecord should have been created with correct amount, sender, receiver, host zone, claimIsPending + userRedemptionRecords := hostZoneUnbonding.UserRedemptionRecords + s.Require().Equal(len(userRedemptionRecords), 1) + userRedemptionRecordId := userRedemptionRecords[0] + userRedemptionRecord, found := s.App.RecordsKeeper.GetUserRedemptionRecord(s.Ctx, userRedemptionRecordId) + s.Require().True(found) + + s.Require().Equal(msg.Amount, userRedemptionRecord.StTokenAmount, "redemption record sttoken amount") + s.Require().Equal(tc.expectedNativeAmount, userRedemptionRecord.NativeTokenAmount, "redemption record native amount") + s.Require().Equal(msg.Receiver, userRedemptionRecord.Receiver, "redemption record receiver") + s.Require().Equal(msg.HostZone, userRedemptionRecord.HostZoneId, "redemption record host zone") + s.Require().False(userRedemptionRecord.ClaimIsPending, "redemption record is not claimable") + s.Require().NotEqual(hostZoneUnbonding.Status, recordtypes.HostZoneUnbonding_CLAIMABLE, "host zone unbonding should NOT be marked as CLAIMABLE") +} + +func (s *KeeperTestSuite) TestRedeemStake_InvalidCreatorAddress() { + tc := s.SetupRedeemStake() + invalidMsg := tc.validMsg + + // cosmos instead of stride address + invalidMsg.Creator = "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8uf" + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: invalid Bech32 prefix; expected stride, got cosmos: invalid address", invalidMsg.Creator)) + + // invalid stride address + invalidMsg.Creator = "stride1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8uf" + _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: decoding bech32 failed: invalid checksum (expected 8dpmg9 got yxp8uf): invalid address", invalidMsg.Creator)) + + // empty address + invalidMsg.Creator = "" + _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: empty address string is not allowed: invalid address", invalidMsg.Creator)) + + // wrong len address + invalidMsg.Creator = "stride1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8ufabc" + _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, fmt.Sprintf("creator address is invalid: %s. err: decoding bech32 failed: invalid character not part of charset: 98: invalid address", invalidMsg.Creator)) +} + +func (s *KeeperTestSuite) TestRedeemStake_HostZoneNotFound() { + tc := s.SetupRedeemStake() + + invalidMsg := tc.validMsg + invalidMsg.HostZone = "fake_host_zone" + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + + s.Require().EqualError(err, "host zone is invalid: fake_host_zone: host zone not registered") +} + +func (s *KeeperTestSuite) TestRedeemStake_RateAboveMaxThreshold() { + tc := s.SetupRedeemStake() + + hz := tc.hostZone + hz.RedemptionRate = sdk.NewDec(100) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) + + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().Error(err) +} + +func (s *KeeperTestSuite) TestRedeemStake_InvalidReceiverAddress() { + tc := s.SetupRedeemStake() + + invalidMsg := tc.validMsg + + // stride instead of cosmos address + invalidMsg.Receiver = "stride159atdlc3ksl50g0659w5tq42wwer334ajl7xnq" + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, "invalid receiver address (invalid Bech32 prefix; expected cosmos, got stride): invalid address") + + // invalid cosmos address + invalidMsg.Receiver = "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8ua" + _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, "invalid receiver address (decoding bech32 failed: invalid checksum (expected yxp8uf got yxp8ua)): invalid address") + + // empty address + invalidMsg.Receiver = "" + _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, "invalid receiver address (empty address string is not allowed): invalid address") + + // wrong len address + invalidMsg.Receiver = "cosmos1g6qdx6kdhpf000afvvpte7hp0vnpzapuyxp8ufa" + _, err = s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().EqualError(err, "invalid receiver address (decoding bech32 failed: invalid checksum (expected xp8ugp got xp8ufa)): invalid address") +} + +func (s *KeeperTestSuite) TestRedeemStake_RedeemMoreThanStaked() { + tc := s.SetupRedeemStake() + + invalidMsg := tc.validMsg + invalidMsg.Amount = sdkmath.NewInt(1_000_000_000_000_000) + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + + s.Require().EqualError(err, fmt.Sprintf("cannot unstake an amount g.t. staked balance on host zone: %v: invalid amount", invalidMsg.Amount)) +} + +func (s *KeeperTestSuite) TestRedeemStake_NoEpochTrackerDay() { + tc := s.SetupRedeemStake() + + invalidMsg := tc.validMsg + s.App.RecordsKeeper.RemoveEpochUnbondingRecord(s.Ctx, tc.initialState.epochNumber) + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + + s.Require().EqualError(err, "latest epoch unbonding record not found: epoch unbonding record not found") +} + +func (s *KeeperTestSuite) TestRedeemStake_HostZoneNoUnbondings() { + tc := s.SetupRedeemStake() + + invalidMsg := tc.validMsg + epochUnbondingRecord := recordtypes.EpochUnbondingRecord{ + EpochNumber: 1, + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{}, + } + hostZoneUnbonding := &recordtypes.HostZoneUnbonding{ + NativeTokenAmount: sdkmath.ZeroInt(), + Denom: "uatom", + HostZoneId: "NOT_GAIA", + } + epochUnbondingRecord.HostZoneUnbondings = append(epochUnbondingRecord.HostZoneUnbondings, hostZoneUnbonding) + + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, epochUnbondingRecord) + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + + s.Require().EqualError(err, "host zone not found in unbondings: GAIA: host zone not registered") +} + +func (s *KeeperTestSuite) TestRedeemStake_InvalidHostAddress() { + tc := s.SetupRedeemStake() + + // Update hostzone with invalid address + badHostZone, _ := s.App.StakeibcKeeper.GetHostZone(s.Ctx, tc.validMsg.HostZone) + badHostZone.DepositAddress = "cosmosXXX" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, badHostZone) + + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "could not bech32 decode address cosmosXXX of zone with id: GAIA") +} + +func (s *KeeperTestSuite) TestRedeemStake_HaltedZone() { + tc := s.SetupRedeemStake() + + // Update hostzone with halted + haltedHostZone, _ := s.App.StakeibcKeeper.GetHostZone(s.Ctx, tc.validMsg.HostZone) + haltedHostZone.Halted = true + s.App.StakeibcKeeper.SetHostZone(s.Ctx, haltedHostZone) + + _, err := s.GetMsgServer().RedeemStake(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().EqualError(err, "halted host zone found for zone (GAIA): Halted host zone found") +} + +type LSMLiquidStakeTestCase struct { + hostZone types.HostZone + liquidStakerAddress sdk.AccAddress + depositAddress sdk.AccAddress + initialBalance sdkmath.Int + initialQueryProgress sdkmath.Int + queryCheckpoint sdkmath.Int + lsmTokenIBCDenom string + validMsg *types.MsgLSMLiquidStake +} + +// Helper function to add the port and channel onto the LSMTokenBaseDenom, +// hash it, and then store the trace in the IBC store +// Returns the ibc hash +func (s *KeeperTestSuite) getLSMTokenIBCDenom() string { + sourcePrefix := transfertypes.GetDenomPrefix(transfertypes.PortID, ibctesting.FirstChannelID) + prefixedDenom := sourcePrefix + LSMTokenBaseDenom + lsmTokenDenomTrace := transfertypes.ParseDenomTrace(prefixedDenom) + s.App.TransferKeeper.SetDenomTrace(s.Ctx, lsmTokenDenomTrace) + return lsmTokenDenomTrace.IBCDenom() +} + +func (s *KeeperTestSuite) SetupTestLSMLiquidStake() LSMLiquidStakeTestCase { + initialBalance := sdkmath.NewInt(3000) + stakeAmount := sdkmath.NewInt(1000) + userAddress := s.TestAccs[0] + depositAddress := types.NewHostZoneDepositAddress(HostChainId) + + // Need valid IBC denom here to test parsing + lsmTokenIBCDenom := s.getLSMTokenIBCDenom() + + // Fund the user's account with the LSM token + s.FundAccount(userAddress, sdk.NewCoin(lsmTokenIBCDenom, initialBalance)) + + // Add the slash interval + // TVL: 100k, Checkpoint: 1% of 1M = 10k + // Progress towards query: 8000 + // => Liquid Stake of 2k will trip query + totalHostZoneStake := sdkmath.NewInt(1_000_000) + queryCheckpoint := sdkmath.NewInt(10_000) + progressTowardsQuery := sdkmath.NewInt(8000) + params := types.DefaultParams() + params.ValidatorSlashQueryThreshold = 1 // 1 % + s.App.StakeibcKeeper.SetParams(s.Ctx, params) + + // Sanity check + onePercent := sdk.MustNewDecFromStr("0.01") + s.Require().Equal(queryCheckpoint.Int64(), onePercent.Mul(sdk.NewDecFromInt(totalHostZoneStake)).TruncateInt64(), + "setup failed - query checkpoint must be 1% of total host zone stake") + + // Add the host zone with a valid zone address as the LSM custodian + hostZone := types.HostZone{ + ChainId: HostChainId, + HostDenom: Atom, + RedemptionRate: sdk.NewDec(1.0), + DepositAddress: depositAddress.String(), + TransferChannelId: ibctesting.FirstChannelID, + ConnectionId: ibctesting.FirstConnectionID, + TotalDelegations: totalHostZoneStake, + Validators: []*types.Validator{{ + Address: ValAddress, + SlashQueryProgressTracker: progressTowardsQuery, + SlashQueryCheckpoint: queryCheckpoint, + SharesToTokensRate: sdk.OneDec(), + }}, + DelegationIcaAddress: "cosmos_DELEGATION", + LsmLiquidStakeEnabled: true, + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + // Mock the latest client height for the ICQ submission + s.MockClientLatestHeight(1) + + return LSMLiquidStakeTestCase{ + hostZone: hostZone, + liquidStakerAddress: userAddress, + depositAddress: depositAddress, + initialBalance: initialBalance, + initialQueryProgress: progressTowardsQuery, + queryCheckpoint: queryCheckpoint, + lsmTokenIBCDenom: lsmTokenIBCDenom, + validMsg: &types.MsgLSMLiquidStake{ + Creator: userAddress.String(), + LsmTokenIbcDenom: lsmTokenIBCDenom, + Amount: stakeAmount, + }, + } +} + +func (s *KeeperTestSuite) TestLSMLiquidStake_Successful_NoSharesToTokensRateQuery() { + tc := s.SetupTestLSMLiquidStake() + + // Call LSM Liquid stake with a valid message + msgResponse, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) + s.Require().NoError(err, "no error expected when calling lsm liquid stake") + s.Require().True(msgResponse.TransactionComplete, "transaction should be complete") + + // Confirm the LSM token was sent to the protocol + userLsmBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, tc.lsmTokenIBCDenom) + s.Require().Equal(tc.initialBalance.Sub(tc.validMsg.Amount).Int64(), userLsmBalance.Amount.Int64(), + "lsm token balance of user account") + + // Confirm stToken was sent to the user + userStTokenBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom) + s.Require().Equal(tc.validMsg.Amount.Int64(), userStTokenBalance.Amount.Int64(), "user stToken balance") + + // Confirm an LSMDeposit was created + expectedDepositId := keeper.GetLSMTokenDepositId(s.Ctx.BlockHeight(), HostChainId, tc.validMsg.Creator, LSMTokenBaseDenom) + expectedDeposit := recordstypes.LSMTokenDeposit{ + DepositId: expectedDepositId, + ChainId: HostChainId, + Denom: LSMTokenBaseDenom, + StakerAddress: s.TestAccs[0].String(), + IbcDenom: tc.lsmTokenIBCDenom, + ValidatorAddress: ValAddress, + Amount: tc.validMsg.Amount, + Status: recordstypes.LSMTokenDeposit_TRANSFER_QUEUE, + StToken: sdk.NewCoin(StAtom, tc.validMsg.Amount), + } + actualDeposit, found := s.App.RecordsKeeper.GetLSMTokenDeposit(s.Ctx, HostChainId, LSMTokenBaseDenom) + s.Require().True(found, "lsm token deposit should have been found after LSM liquid stake") + s.Require().Equal(expectedDeposit, actualDeposit) + + // Confirm slash query progress was incremented + hostZone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) + expectedQueryProgress := tc.initialQueryProgress.Add(tc.validMsg.Amount) + s.Require().True(found, "host zone should have been found") + s.Require().Equal(expectedQueryProgress.Int64(), hostZone.Validators[0].SlashQueryProgressTracker.Int64(), "slash query progress") +} + +func (s *KeeperTestSuite) TestLSMLiquidStake_Successful_WithSharesToTokensRateQuery() { + tc := s.SetupTestLSMLiquidStake() + + // Increase the liquid stake size so that it breaks the query checkpoint + // queryProgressSlack is the remaining amount that can be staked in one message before a slash query is issued + queryProgressSlack := tc.queryCheckpoint.Sub(tc.initialQueryProgress) + tc.validMsg.Amount = queryProgressSlack.Add(sdk.NewInt(1000)) + + // Call LSM Liquid stake + msgResponse, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) + s.Require().NoError(err, "no error expected when calling lsm liquid stake") + s.Require().False(msgResponse.TransactionComplete, "transaction should still be pending") + + // Confirm stToken was NOT sent to the user + userStTokenBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom) + s.Require().True(userStTokenBalance.Amount.IsZero(), "user stToken balance") + + // Confirm query was submitted + allQueries := s.App.InterchainqueryKeeper.AllQueries(s.Ctx) + s.Require().Len(allQueries, 1) + + // Confirm query metadata + actualQuery := allQueries[0] + s.Require().Equal(HostChainId, actualQuery.ChainId, "query chain-id") + s.Require().Equal(ibctesting.FirstConnectionID, actualQuery.ConnectionId, "query connection-id") + s.Require().Equal(icqtypes.STAKING_STORE_QUERY_WITH_PROOF, actualQuery.QueryType, "query types") + + s.Require().Equal(types.ModuleName, actualQuery.CallbackModule, "callback module") + s.Require().Equal(keeper.ICQCallbackID_Validator, actualQuery.CallbackId, "callback-id") + + expectedTimeout := uint64(s.Ctx.BlockTime().UnixNano() + (keeper.LSMSlashQueryTimeout).Nanoseconds()) + s.Require().Equal(keeper.LSMSlashQueryTimeout, actualQuery.TimeoutDuration, "timeout duration") + s.Require().Equal(int64(expectedTimeout), int64(actualQuery.TimeoutTimestamp), "timeout timestamp") + + // Confirm query callback data + s.Require().True(len(actualQuery.CallbackData) > 0, "callback data exists") + + expectedStToken := sdk.NewCoin(StAtom, tc.validMsg.Amount) + expectedDepositId := keeper.GetLSMTokenDepositId(s.Ctx.BlockHeight(), HostChainId, tc.validMsg.Creator, LSMTokenBaseDenom) + expectedLSMTokenDeposit := recordstypes.LSMTokenDeposit{ + DepositId: expectedDepositId, + ChainId: HostChainId, + Denom: LSMTokenBaseDenom, + IbcDenom: tc.lsmTokenIBCDenom, + StakerAddress: tc.validMsg.Creator, + ValidatorAddress: ValAddress, + Amount: tc.validMsg.Amount, + StToken: expectedStToken, + Status: recordstypes.LSMTokenDeposit_DEPOSIT_PENDING, + } + + var actualCallbackData types.ValidatorSharesToTokensQueryCallback + err = proto.Unmarshal(actualQuery.CallbackData, &actualCallbackData) + s.Require().NoError(err, "no error expected when unmarshalling query callback data") + + lsmLiquidStake := actualCallbackData.LsmLiquidStake + s.Require().Equal(HostChainId, lsmLiquidStake.HostZone.ChainId, "callback data - host zone") + s.Require().Equal(ValAddress, lsmLiquidStake.Validator.Address, "callback data - validator") + + s.Require().Equal(expectedLSMTokenDeposit, *lsmLiquidStake.Deposit, "callback data - deposit") +} + +func (s *KeeperTestSuite) TestLSMLiquidStake_DifferentRedemptionRates() { + tc := s.SetupTestLSMLiquidStake() + tc.validMsg.Amount = sdk.NewInt(100) // reduce the stake amount to prevent insufficient balance error + + // Loop over sharesToTokens rates: {0.92, 0.94, ..., 1.2} + interval := sdk.MustNewDecFromStr("0.01") + for i := -8; i <= 10; i += 2 { + redemptionDelta := interval.Mul(sdk.NewDec(int64(i))) // i = 2 => delta = 0.02 + newRedemptionRate := sdk.NewDec(1.0).Add(redemptionDelta) + redemptionRateFloat := newRedemptionRate + + // Update rate in host zone + hz := tc.hostZone + hz.RedemptionRate = newRedemptionRate + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hz) + + // Liquid stake for each balance and confirm stAtom minted + startingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom).Amount + _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) + s.Require().NoError(err) + endingStAtomBalance := s.App.BankKeeper.GetBalance(s.Ctx, tc.liquidStakerAddress, StAtom).Amount + actualStAtomMinted := endingStAtomBalance.Sub(startingStAtomBalance) + + expectedStAtomMinted := sdk.NewDecFromInt(tc.validMsg.Amount).Quo(redemptionRateFloat).TruncateInt() + testDescription := fmt.Sprintf("st atom balance for redemption rate: %v", redemptionRateFloat) + s.Require().Equal(expectedStAtomMinted, actualStAtomMinted, testDescription) + + // Cleanup the LSMTokenDeposit record to prevent an error on the next run + s.App.RecordsKeeper.RemoveLSMTokenDeposit(s.Ctx, HostChainId, LSMTokenBaseDenom) + } +} + +// ---------------------------------------------------- +// PrepareDelegation +// ---------------------------------------------------- + +func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_NotIBCDenom() { + tc := s.SetupTestLSMLiquidStake() + + // Change the message so that the denom is not an IBC token + invalidMsg := tc.validMsg + invalidMsg.LsmTokenIbcDenom = "fake_ibc_denom" + + _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), invalidMsg) + s.Require().ErrorContains(err, "lsm token is not an IBC token (fake_ibc_denom)") +} + +func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_HostZoneNotFound() { + tc := s.SetupTestLSMLiquidStake() + + // Change the message so that the denom is an IBC denom from a channel that is not supported + sourcePrefix := transfertypes.GetDenomPrefix(transfertypes.PortID, "channel-1") + prefixedDenom := sourcePrefix + LSMTokenBaseDenom + lsmTokenDenomTrace := transfertypes.ParseDenomTrace(prefixedDenom) + s.App.TransferKeeper.SetDenomTrace(s.Ctx, lsmTokenDenomTrace) + + invalidMsg := tc.validMsg + invalidMsg.LsmTokenIbcDenom = lsmTokenDenomTrace.IBCDenom() + + _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), invalidMsg) + s.Require().ErrorContains(err, "transfer channel-id from LSM token (channel-1) does not match any registered host zone") +} + +func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_ValidatorNotFound() { + tc := s.SetupTestLSMLiquidStake() + + // Change the message so that the base denom is from a non-existent validator + sourcePrefix := transfertypes.GetDenomPrefix(transfertypes.PortID, ibctesting.FirstChannelID) + prefixedDenom := sourcePrefix + "cosmosvaloperXXX/42" + lsmTokenDenomTrace := transfertypes.ParseDenomTrace(prefixedDenom) + s.App.TransferKeeper.SetDenomTrace(s.Ctx, lsmTokenDenomTrace) + + invalidMsg := tc.validMsg + invalidMsg.LsmTokenIbcDenom = lsmTokenDenomTrace.IBCDenom() + + _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), invalidMsg) + s.Require().ErrorContains(err, "validator (cosmosvaloperXXX) is not registered in the Stride validator set") +} + +func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_DepositAlreadyExists() { + tc := s.SetupTestLSMLiquidStake() + + // Set a deposit with the same chainID and denom in the store + s.App.RecordsKeeper.SetLSMTokenDeposit(s.Ctx, recordstypes.LSMTokenDeposit{ + ChainId: HostChainId, + Denom: LSMTokenBaseDenom, + }) + + _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) + s.Require().ErrorContains(err, "there is already a previous record with this denom being processed") +} + +func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_InvalidDepositAddress() { + tc := s.SetupTestLSMLiquidStake() + + // Remove the host zones address from the store + invalidHostZone := tc.hostZone + invalidHostZone.DepositAddress = "" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, invalidHostZone) + + _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) + s.Require().ErrorContains(err, "host zone address is invalid") +} + +func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_InsufficientBalance() { + tc := s.SetupTestLSMLiquidStake() + + // Send out all the user's coins so that they have an insufficient balance of LSM tokens + initialBalanceCoin := sdk.NewCoins(sdk.NewCoin(tc.lsmTokenIBCDenom, tc.initialBalance)) + err := s.App.BankKeeper.SendCoins(s.Ctx, tc.liquidStakerAddress, s.TestAccs[1], initialBalanceCoin) + s.Require().NoError(err) + + _, err = s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) + s.Require().ErrorContains(err, "insufficient funds") +} + +func (s *KeeperTestSuite) TestLSMLiquidStakeFailed_ZeroStTokens() { + tc := s.SetupTestLSMLiquidStake() + + // Adjust redemption rate and liquid stake amount so that the number of stTokens would be zero + // stTokens = 1(amount) / 1.1(RR) = rounds down to 0 + hostZone := tc.hostZone + hostZone.RedemptionRate = sdk.NewDecWithPrec(11, 1) + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + tc.validMsg.Amount = sdkmath.NewInt(1) + + // The liquid stake should fail + _, err := s.GetMsgServer().LSMLiquidStake(sdk.WrapSDKContext(s.Ctx), tc.validMsg) + s.Require().EqualError(err, "Liquid stake of 1uatom would return 0 stTokens: Liquid staked amount is too small") +} + +// ---------------------------------------------------- +// DeleteTradeRoute +// ---------------------------------------------------- + +func (s *KeeperTestSuite) TestDeleteTradeRoute() { + initialRoute := types.TradeRoute{ + RewardDenomOnRewardZone: RewardDenom, + HostDenomOnHostZone: HostDenom, + } + s.App.StakeibcKeeper.SetTradeRoute(s.Ctx, initialRoute) + + msg := types.MsgDeleteTradeRoute{ + Authority: Authority, + RewardDenom: RewardDenom, + HostDenom: HostDenom, + } + + // Confirm the route is present before attepmting to delete was deleted + _, found := s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, RewardDenom, HostDenom) + s.Require().True(found, "trade route should have been found before delete message") + + // Delete the trade route + _, err := s.GetMsgServer().DeleteTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err, "no error expected when deleting trade route") + + // Confirm it was deleted + _, found = s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, RewardDenom, HostDenom) + s.Require().False(found, "trade route should have been deleted") + + // Attempt to delete it again, it should fail since it doesn't exist + _, err = s.GetMsgServer().DeleteTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "trade route not found") + + // Attempt to delete with the wrong authority - it should fail + invalidMsg := msg + invalidMsg.Authority = "not-gov-address" + + _, err = s.GetMsgServer().DeleteTradeRoute(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().ErrorContains(err, "invalid authority") +} + +// ---------------------------------------------------- +// CreateTradeRoute +// ---------------------------------------------------- + +func (s *KeeperTestSuite) SetupTestCreateTradeRoute() (msg types.MsgCreateTradeRoute, expectedTradeRoute types.TradeRoute) { + rewardChainId := "reward-0" + tradeChainId := "trade-0" + + hostConnectionId := "connection-0" + rewardConnectionId := "connection-1" + tradeConnectionId := "connection-2" + + hostToRewardChannelId := "channel-100" + rewardToTradeChannelId := "channel-200" + tradeToHostChannelId := "channel-300" + + rewardDenomOnHost := "ibc/reward-on-host" + rewardDenomOnReward := RewardDenom + rewardDenomOnTrade := "ibc/reward-on-trade" + hostDenomOnTrade := "ibc/host-on-trade" + hostDenomOnHost := HostDenom + + withdrawalAddress := "withdrawal-address" + unwindAddress := "unwind-address" + + poolId := uint64(100) + maxAllowedSwapLossRate := "0.05" + minSwapAmount := sdkmath.NewInt(100) + maxSwapAmount := sdkmath.NewInt(1_000) + + // Mock out connections for the reward an trade chain so that an ICA registration can be submitted + s.MockClientAndConnection(rewardChainId, "07-tendermint-0", rewardConnectionId) + s.MockClientAndConnection(tradeChainId, "07-tendermint-1", tradeConnectionId) + + // Register an exisiting ICA account for the unwind ICA to test that + // existing accounts are re-used + owner := types.FormatTradeRouteICAOwner(rewardChainId, RewardDenom, HostDenom, types.ICAAccountType_CONVERTER_UNWIND) + s.MockICAChannel(rewardConnectionId, "channel-0", owner, unwindAddress) + + // Create a host zone with an exisiting withdrawal address + hostZone := types.HostZone{ + ChainId: HostChainId, + ConnectionId: hostConnectionId, + WithdrawalIcaAddress: withdrawalAddress, + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + // Define a valid message given the parameters above + msg = types.MsgCreateTradeRoute{ + Authority: Authority, + HostChainId: HostChainId, + + StrideToRewardConnectionId: rewardConnectionId, + StrideToTradeConnectionId: tradeConnectionId, + + HostToRewardTransferChannelId: hostToRewardChannelId, + RewardToTradeTransferChannelId: rewardToTradeChannelId, + TradeToHostTransferChannelId: tradeToHostChannelId, + + RewardDenomOnHost: rewardDenomOnHost, + RewardDenomOnReward: rewardDenomOnReward, + RewardDenomOnTrade: rewardDenomOnTrade, + HostDenomOnTrade: hostDenomOnTrade, + HostDenomOnHost: hostDenomOnHost, + + PoolId: poolId, + MaxAllowedSwapLossRate: maxAllowedSwapLossRate, + MinSwapAmount: minSwapAmount, + MaxSwapAmount: maxSwapAmount, + } + + // Build out the expected trade route given the above + expectedTradeRoute = types.TradeRoute{ + RewardDenomOnHostZone: rewardDenomOnHost, + RewardDenomOnRewardZone: rewardDenomOnReward, + RewardDenomOnTradeZone: rewardDenomOnTrade, + HostDenomOnTradeZone: hostDenomOnTrade, + HostDenomOnHostZone: hostDenomOnHost, + + HostAccount: types.ICAAccount{ + ChainId: HostChainId, + Type: types.ICAAccountType_WITHDRAWAL, + ConnectionId: hostConnectionId, + Address: withdrawalAddress, + }, + RewardAccount: types.ICAAccount{ + ChainId: rewardChainId, + Type: types.ICAAccountType_CONVERTER_UNWIND, + ConnectionId: rewardConnectionId, + Address: unwindAddress, + }, + TradeAccount: types.ICAAccount{ + ChainId: tradeChainId, + Type: types.ICAAccountType_CONVERTER_TRADE, + ConnectionId: tradeConnectionId, + }, + + HostToRewardChannelId: hostToRewardChannelId, + RewardToTradeChannelId: rewardToTradeChannelId, + TradeToHostChannelId: tradeToHostChannelId, + + TradeConfig: types.TradeConfig{ + PoolId: poolId, + SwapPrice: sdk.ZeroDec(), + PriceUpdateTimestamp: 0, + + MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), + MinSwapAmount: minSwapAmount, + MaxSwapAmount: maxSwapAmount, + }, + } + + return msg, expectedTradeRoute +} + +// Helper function to create a trade route and check the created route matched expectations +func (s *KeeperTestSuite) submitCreateTradeRouteAndValidate(msg types.MsgCreateTradeRoute, expectedRoute types.TradeRoute) { + _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err, "no error expected when creating trade route") + + actualRoute, found := s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, msg.RewardDenomOnReward, msg.HostDenomOnHost) + s.Require().True(found, "trade route should have been created") + s.Require().Equal(expectedRoute, actualRoute, "trade route") +} + +// Tests a successful trade route creation +func (s *KeeperTestSuite) TestCreateTradeRoute_Success() { + msg, expectedRoute := s.SetupTestCreateTradeRoute() + s.submitCreateTradeRouteAndValidate(msg, expectedRoute) +} + +// Tests creating a trade route that uses the default pool config values +func (s *KeeperTestSuite) TestCreateTradeRoute_Success_DefaultPoolConfig() { + msg, expectedRoute := s.SetupTestCreateTradeRoute() + + // Update the message and remove some trade config parameters + // so that the defaults are used + msg.MaxSwapAmount = sdk.ZeroInt() + msg.MaxAllowedSwapLossRate = "" + + expectedRoute.TradeConfig.MaxAllowedSwapLossRate = sdk.MustNewDecFromStr(keeper.DefaultMaxAllowedSwapLossRate) + expectedRoute.TradeConfig.MaxSwapAmount = keeper.DefaultMaxSwapAmount + + s.submitCreateTradeRouteAndValidate(msg, expectedRoute) +} + +// Tests trying to create a route from an invalid authority +func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_Authority() { + msg, _ := s.SetupTestCreateTradeRoute() + + msg.Authority = "not-gov-address" + + _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "invalid authority") +} + +// Tests creating a duplicate trade route +func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_DuplicateTradeRoute() { + msg, _ := s.SetupTestCreateTradeRoute() + + // Store down a trade route so the tx hits a duplicate trade route error + s.App.StakeibcKeeper.SetTradeRoute(s.Ctx, types.TradeRoute{ + RewardDenomOnRewardZone: RewardDenom, + HostDenomOnHostZone: HostDenom, + }) + + _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "Trade route already exists") +} + +// Tests creating a trade route when the host zone or withdrawal address does not exist +func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_HostZoneNotRegistered() { + msg, _ := s.SetupTestCreateTradeRoute() + + // Remove the host zone withdrawal address and confirm it fails + invalidHostZone := s.MustGetHostZone(HostChainId) + invalidHostZone.WithdrawalIcaAddress = "" + s.App.StakeibcKeeper.SetHostZone(s.Ctx, invalidHostZone) + + _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "withdrawal account not initialized on host zone") + + // Remove the host zone completely and check that that also fails + s.App.StakeibcKeeper.RemoveHostZone(s.Ctx, HostChainId) + + _, err = s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "host zone not found") +} + +// Tests creating a trade route where the ICA channels cannot be created +// because the ICA connections do not exist +func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_ConnectionNotFound() { + // Test with non-existent reward connection + msg, _ := s.SetupTestCreateTradeRoute() + msg.StrideToRewardConnectionId = "connection-X" + + // Remove the host zone completely and check that that also fails + _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "unable to register the unwind ICA account: connection connection-X not found") + + // Setup again, but this time use a non-existent trade connection + msg, _ = s.SetupTestCreateTradeRoute() + msg.StrideToTradeConnectionId = "connection-Y" + + _, err = s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "unable to register the trade ICA account: connection connection-Y not found") +} + +// Tests creating a trade route where the ICA registration step fails +func (s *KeeperTestSuite) TestCreateTradeRoute_Failure_UnableToRegisterICA() { + msg, expectedRoute := s.SetupTestCreateTradeRoute() + + // Disable ICA middleware for the trade channel so the ICA fails + tradeAccount := expectedRoute.TradeAccount + tradeOwner := types.FormatTradeRouteICAOwner(tradeAccount.ChainId, RewardDenom, HostDenom, types.ICAAccountType_CONVERTER_TRADE) + tradePortId, _ := icatypes.NewControllerPortID(tradeOwner) + s.App.ICAControllerKeeper.SetMiddlewareDisabled(s.Ctx, tradePortId, tradeAccount.ConnectionId) + + _, err := s.GetMsgServer().CreateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "unable to register the trade ICA account") +} + +// ---------------------------------------------------- +// UpdateTradeRoute +// ---------------------------------------------------- + +// Helper function to update a trade route and check the updated route matched expectations +func (s *KeeperTestSuite) submitUpdateTradeRouteAndValidate(msg types.MsgUpdateTradeRoute, expectedRoute types.TradeRoute) { + _, err := s.GetMsgServer().UpdateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err, "no error expected when updating trade route") + + actualRoute, found := s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, RewardDenom, HostDenom) + s.Require().True(found, "trade route should have been updated") + s.Require().Equal(expectedRoute, actualRoute, "trade route") +} + +func (s *KeeperTestSuite) TestUpdateTradeRoute() { + poolId := uint64(100) + maxAllowedSwapLossRate := "0.05" + minSwapAmount := sdkmath.NewInt(100) + maxSwapAmount := sdkmath.NewInt(1_000) + + // Create a trade route with no parameters + initialRoute := types.TradeRoute{ + RewardDenomOnRewardZone: RewardDenom, + HostDenomOnHostZone: HostDenom, + } + s.App.StakeibcKeeper.SetTradeRoute(s.Ctx, initialRoute) + + // Define a valid message given the parameters above + msg := types.MsgUpdateTradeRoute{ + Authority: Authority, + + RewardDenom: RewardDenom, + HostDenom: HostDenom, + + PoolId: poolId, + MaxAllowedSwapLossRate: maxAllowedSwapLossRate, + MinSwapAmount: minSwapAmount, + MaxSwapAmount: maxSwapAmount, + } + + // Build out the expected trade route given the above + expectedRoute := initialRoute + expectedRoute.TradeConfig = types.TradeConfig{ + PoolId: poolId, + SwapPrice: sdk.ZeroDec(), + PriceUpdateTimestamp: 0, + + MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), + MinSwapAmount: minSwapAmount, + MaxSwapAmount: maxSwapAmount, + } + + // Update the route and confirm the changes persisted + s.submitUpdateTradeRouteAndValidate(msg, expectedRoute) + + // Update it again, this time using default args + defaultMsg := msg + defaultMsg.MaxAllowedSwapLossRate = "" + defaultMsg.MaxSwapAmount = sdkmath.ZeroInt() + + expectedRoute.TradeConfig.MaxAllowedSwapLossRate = sdk.MustNewDecFromStr(keeper.DefaultMaxAllowedSwapLossRate) + expectedRoute.TradeConfig.MaxSwapAmount = keeper.DefaultMaxSwapAmount + + s.submitUpdateTradeRouteAndValidate(defaultMsg, expectedRoute) + + // Test that an error is thrown if the correct authority is not specified + invalidMsg := msg + invalidMsg.Authority = "not-gov-address" + + _, err := s.GetMsgServer().UpdateTradeRoute(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().ErrorContains(err, "invalid authority") + + // Test that an error is thrown if the route doesn't exist + invalidMsg = msg + invalidMsg.RewardDenom = "invalid-reward-denom" + + _, err = s.GetMsgServer().UpdateTradeRoute(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().ErrorContains(err, "trade route not found") +} + +// ---------------------------------------------------- +// RestoreInterchainAccount +// ---------------------------------------------------- + +type DepositRecordStatusUpdate struct { + chainId string + initialStatus recordtypes.DepositRecord_Status + revertedStatus recordtypes.DepositRecord_Status +} + +type HostZoneUnbondingStatusUpdate struct { + initialStatus recordtypes.HostZoneUnbonding_Status + revertedStatus recordtypes.HostZoneUnbonding_Status +} + +type LSMTokenDepositStatusUpdate struct { + chainId string + denom string + initialStatus recordtypes.LSMTokenDeposit_Status + revertedStatus recordtypes.LSMTokenDeposit_Status +} + +type RestoreInterchainAccountTestCase struct { + validMsg types.MsgRestoreInterchainAccount + depositRecordStatusUpdates []DepositRecordStatusUpdate + unbondingRecordStatusUpdate []HostZoneUnbondingStatusUpdate + lsmTokenDepositStatusUpdate []LSMTokenDepositStatusUpdate + delegationChannelID string + delegationPortID string +} + +func (s *KeeperTestSuite) SetupRestoreInterchainAccount(createDelegationICAChannel bool) RestoreInterchainAccountTestCase { + s.CreateTransferChannel(HostChainId) + + // We have to setup the ICA channel before the LSM Token is stored, + // otherwise when the EndBlocker runs in the channel setup, the LSM Token + // statuses will get updated + var channelID, portID string + if createDelegationICAChannel { + owner := "GAIA.DELEGATION" + channelID, portID = s.CreateICAChannel(owner) + } + + hostZone := types.HostZone{ + ChainId: HostChainId, + ConnectionId: ibctesting.FirstConnectionID, + RedemptionRate: sdk.OneDec(), // if not set, the beginblocker invariant panics + Validators: []*types.Validator{ + {Address: "valA", DelegationChangesInProgress: 1}, + {Address: "valB", DelegationChangesInProgress: 2}, + {Address: "valC", DelegationChangesInProgress: 3}, + }, + } + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + // Store deposit records with some in state pending + depositRecords := []DepositRecordStatusUpdate{ + { + // Status doesn't change + chainId: HostChainId, + initialStatus: recordtypes.DepositRecord_TRANSFER_IN_PROGRESS, + revertedStatus: recordtypes.DepositRecord_TRANSFER_IN_PROGRESS, + }, + { + // Status gets reverted from IN_PROGRESS to QUEUE + chainId: HostChainId, + initialStatus: recordtypes.DepositRecord_DELEGATION_IN_PROGRESS, + revertedStatus: recordtypes.DepositRecord_DELEGATION_QUEUE, + }, + { + // Status doesn't get reveted because it's a different host zone + chainId: "different_host_zone", + initialStatus: recordtypes.DepositRecord_DELEGATION_IN_PROGRESS, + revertedStatus: recordtypes.DepositRecord_DELEGATION_IN_PROGRESS, + }, + } + for i, depositRecord := range depositRecords { + s.App.RecordsKeeper.SetDepositRecord(s.Ctx, recordtypes.DepositRecord{ + Id: uint64(i), + HostZoneId: depositRecord.chainId, + Status: depositRecord.initialStatus, + }) + } + + // Store epoch unbonding records with some in state pending + hostZoneUnbondingRecords := []HostZoneUnbondingStatusUpdate{ + { + // Status doesn't change + initialStatus: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + revertedStatus: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + }, + { + // Status gets reverted from IN_PROGRESS to QUEUE + initialStatus: recordtypes.HostZoneUnbonding_UNBONDING_IN_PROGRESS, + revertedStatus: recordtypes.HostZoneUnbonding_UNBONDING_QUEUE, + }, + { + // Status doesn't change + initialStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, + revertedStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, + }, + { + // Status gets reverted from IN_PROGRESS to QUEUE + initialStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_IN_PROGRESS, + revertedStatus: recordtypes.HostZoneUnbonding_EXIT_TRANSFER_QUEUE, + }, + } + for i, hostZoneUnbonding := range hostZoneUnbondingRecords { + s.App.RecordsKeeper.SetEpochUnbondingRecord(s.Ctx, recordtypes.EpochUnbondingRecord{ + EpochNumber: uint64(i), + HostZoneUnbondings: []*recordtypes.HostZoneUnbonding{ + // The first unbonding record will get reverted, the other one will not + { + HostZoneId: HostChainId, + Status: hostZoneUnbonding.initialStatus, + }, + { + HostZoneId: "different_host_zone", + Status: hostZoneUnbonding.initialStatus, + }, + }, + }) + } + + // Store LSM Token Deposits with some state pending + lsmTokenDeposits := []LSMTokenDepositStatusUpdate{ + { + // Status doesn't change + chainId: HostChainId, + denom: "denom-1", + initialStatus: recordtypes.LSMTokenDeposit_TRANSFER_IN_PROGRESS, + revertedStatus: recordtypes.LSMTokenDeposit_TRANSFER_IN_PROGRESS, + }, + { + // Status gets reverted from IN_PROGRESS to QUEUE + chainId: HostChainId, + denom: "denom-2", + initialStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS, + revertedStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE, + }, + { + // Status doesn't change + chainId: HostChainId, + denom: "denom-3", + initialStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE, + revertedStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_QUEUE, + }, + { + // Status doesn't change (different host zone) + chainId: "different_host_zone", + denom: "denom-4", + initialStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS, + revertedStatus: recordtypes.LSMTokenDeposit_DETOKENIZATION_IN_PROGRESS, + }, + } + for _, lsmTokenDeposit := range lsmTokenDeposits { + s.App.RecordsKeeper.SetLSMTokenDeposit(s.Ctx, recordtypes.LSMTokenDeposit{ + ChainId: lsmTokenDeposit.chainId, + Status: lsmTokenDeposit.initialStatus, + Denom: lsmTokenDeposit.denom, + }) + } + + defaultMsg := types.MsgRestoreInterchainAccount{ + Creator: "creatoraddress", + ChainId: HostChainId, + ConnectionId: ibctesting.FirstConnectionID, + AccountOwner: types.FormatHostZoneICAOwner(HostChainId, types.ICAAccountType_DELEGATION), + } + + return RestoreInterchainAccountTestCase{ + validMsg: defaultMsg, + depositRecordStatusUpdates: depositRecords, + unbondingRecordStatusUpdate: hostZoneUnbondingRecords, + lsmTokenDepositStatusUpdate: lsmTokenDeposits, + delegationChannelID: channelID, + delegationPortID: portID, + } +} + +// Helper function to close an ICA channel +func (s *KeeperTestSuite) closeICAChannel(portId, channelID string) { + channel, found := s.App.IBCKeeper.ChannelKeeper.GetChannel(s.Ctx, portId, channelID) + s.Require().True(found, "unable to close channel because channel was not found") + channel.State = channeltypes.CLOSED + s.App.IBCKeeper.ChannelKeeper.SetChannel(s.Ctx, portId, channelID, channel) +} + +// Helper function to call RestoreChannel and check that a new channel was created and opened +func (s *KeeperTestSuite) restoreChannelAndVerifySuccess(msg types.MsgRestoreInterchainAccount, portID string, channelID string) { + // Restore the channel + _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().NoError(err, "registered ica account successfully") + + // Confirm channel was created + channels := s.App.IBCKeeper.ChannelKeeper.GetAllChannels(s.Ctx) + s.Require().Len(channels, 3, "there should be 3 channels after restoring") + + // Confirm the new channel is in state INIT + newChannelActive := false + for _, channel := range channels { + // The new channel should have the same port, a new channel ID and be in state INIT + if channel.PortId == portID && channel.ChannelId != channelID && channel.State == channeltypes.INIT { + newChannelActive = true + } + } + s.Require().True(newChannelActive, "a new channel should have been created") +} + +// Helper function to check that each DepositRecord's status was either left alone or reverted to it's prior status +func (s *KeeperTestSuite) verifyDepositRecordsStatus(expectedDepositRecords []DepositRecordStatusUpdate, revert bool) { + for i, expectedDepositRecord := range expectedDepositRecords { + actualDepositRecord, found := s.App.RecordsKeeper.GetDepositRecord(s.Ctx, uint64(i)) + s.Require().True(found, "deposit record found") + + // Only revert records if the revert option is passed and the host zone matches + expectedStatus := expectedDepositRecord.initialStatus + if revert && actualDepositRecord.HostZoneId == HostChainId { + expectedStatus = expectedDepositRecord.revertedStatus + } + s.Require().Equal(expectedStatus.String(), actualDepositRecord.Status.String(), "deposit record %d status", i) + } +} + +// Helper function to check that each HostZoneUnbonding's status was either left alone or reverted to it's prior status +func (s *KeeperTestSuite) verifyHostZoneUnbondingStatus(expectedUnbondingRecords []HostZoneUnbondingStatusUpdate, revert bool) { + for i, expectedUnbonding := range expectedUnbondingRecords { + epochUnbondingRecord, found := s.App.RecordsKeeper.GetEpochUnbondingRecord(s.Ctx, uint64(i)) + s.Require().True(found, "epoch unbonding record found") + + for _, actualUnbonding := range epochUnbondingRecord.HostZoneUnbondings { + // Only revert records if the revert option is passed and the host zone matches + expectedStatus := expectedUnbonding.initialStatus + if revert && actualUnbonding.HostZoneId == HostChainId { + expectedStatus = expectedUnbonding.revertedStatus + } + s.Require().Equal(expectedStatus.String(), actualUnbonding.Status.String(), "host zone unbonding for epoch %d record status", i) + } + } +} + +// Helper function to check that each LSMTokenDepoit's status was either left alone or reverted to it's prior status +func (s *KeeperTestSuite) verifyLSMDepositStatus(expectedLSMDeposits []LSMTokenDepositStatusUpdate, revert bool) { + for i, expectedLSMDeposit := range expectedLSMDeposits { + actualLSMDeposit, found := s.App.RecordsKeeper.GetLSMTokenDeposit(s.Ctx, expectedLSMDeposit.chainId, expectedLSMDeposit.denom) + s.Require().True(found, "lsm deposit found") + + // Only revert record if the revert option is passed and the host zone matches + expectedStatus := expectedLSMDeposit.initialStatus + if revert && actualLSMDeposit.ChainId == HostChainId { + expectedStatus = expectedLSMDeposit.revertedStatus + } + s.Require().Equal(expectedStatus.String(), actualLSMDeposit.Status.String(), "lsm deposit %d", i) + } +} + +// Helper function to check that the delegation changes in progress field was reset to 0 for each validator +func (s *KeeperTestSuite) verifyDelegationChangeInProgressReset() { + hostZone := s.MustGetHostZone(HostChainId) + s.Require().Len(hostZone.Validators, 3, "there should be 3 validators on this host zone") + + for _, validator := range hostZone.Validators { + s.Require().Zero(validator.DelegationChangesInProgress, + "delegation change in progress should have been reset for validator %s", validator.Address) + } +} + +func (s *KeeperTestSuite) TestRestoreInterchainAccount_Success() { + tc := s.SetupRestoreInterchainAccount(true) + + // Confirm there are two channels originally + channels := s.App.IBCKeeper.ChannelKeeper.GetAllChannels(s.Ctx) + s.Require().Len(channels, 2, "there should be 2 channels initially (transfer + delegate)") + + // Close the delegation channel + s.closeICAChannel(tc.delegationPortID, tc.delegationChannelID) + + // Confirm the new channel was created + s.restoreChannelAndVerifySuccess(tc.validMsg, tc.delegationPortID, tc.delegationChannelID) + + // Verify the record status' were reverted + s.verifyDepositRecordsStatus(tc.depositRecordStatusUpdates, true) + s.verifyHostZoneUnbondingStatus(tc.unbondingRecordStatusUpdate, true) + s.verifyLSMDepositStatus(tc.lsmTokenDepositStatusUpdate, true) + s.verifyDelegationChangeInProgressReset() +} + +func (s *KeeperTestSuite) TestRestoreInterchainAccount_InvalidConnectionId() { + tc := s.SetupRestoreInterchainAccount(false) + + // Update the connectionId on the host zone so that it doesn't exist + invalidMsg := tc.validMsg + invalidMsg.ConnectionId = "fake_connection" + + _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &invalidMsg) + s.Require().ErrorContains(err, "connection fake_connection not found") +} + +func (s *KeeperTestSuite) TestRestoreInterchainAccount_CannotRestoreNonExistentAcct() { + tc := s.SetupRestoreInterchainAccount(false) + + // Attempt to restore an account that does not exist + msg := tc.validMsg + msg.AccountOwner = types.FormatHostZoneICAOwner(HostChainId, types.ICAAccountType_WITHDRAWAL) + + _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &msg) + s.Require().ErrorContains(err, "ICA controller account address not found: GAIA.WITHDRAWAL") +} + +func (s *KeeperTestSuite) TestRestoreInterchainAccount_HostZoneNotFound() { + tc := s.SetupRestoreInterchainAccount(true) + s.closeICAChannel(tc.delegationPortID, tc.delegationChannelID) + + // Delete the host zone so the lookup fails + // (this check only runs for the delegation channel) + s.App.StakeibcKeeper.RemoveHostZone(s.Ctx, HostChainId) + + _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().ErrorContains(err, "delegation ICA supplied, but no associated host zone") +} + +func (s *KeeperTestSuite) TestRestoreInterchainAccount_RevertDepositRecords_Failure() { + tc := s.SetupRestoreInterchainAccount(true) + + _, err := s.GetMsgServer().RestoreInterchainAccount(sdk.WrapSDKContext(s.Ctx), &tc.validMsg) + s.Require().ErrorContains(err, "existing active channel channel-1 for portID icacontroller-GAIA.DELEGATION") + + // Verify the record status' were NOT reverted + s.verifyDepositRecordsStatus(tc.depositRecordStatusUpdates, false) + s.verifyHostZoneUnbondingStatus(tc.unbondingRecordStatusUpdate, false) + s.verifyLSMDepositStatus(tc.lsmTokenDepositStatusUpdate, false) +} + +func (s *KeeperTestSuite) TestRestoreInterchainAccount_NoRecordChange_Success() { + // Here, we're closing and restoring the withdrawal channel so records should not be reverted + tc := s.SetupRestoreInterchainAccount(false) + owner := "GAIA.WITHDRAWAL" + channelID, portID := s.CreateICAChannel(owner) + + // Confirm there are two channels originally + channels := s.App.IBCKeeper.ChannelKeeper.GetAllChannels(s.Ctx) + s.Require().Len(channels, 2, "there should be 2 channels initially (transfer + withdrawal)") + + // Close the withdrawal channel + s.closeICAChannel(portID, channelID) + + // Restore the channel + msg := tc.validMsg + msg.AccountOwner = types.FormatHostZoneICAOwner(HostChainId, types.ICAAccountType_WITHDRAWAL) + s.restoreChannelAndVerifySuccess(msg, portID, channelID) + + // Verify the record status' were NOT reverted + s.verifyDepositRecordsStatus(tc.depositRecordStatusUpdates, false) + s.verifyHostZoneUnbondingStatus(tc.unbondingRecordStatusUpdate, false) + s.verifyLSMDepositStatus(tc.lsmTokenDepositStatusUpdate, false) +} + +// ---------------------------------------------------- +// UpdateInnerRedemptionRateBounds +// ---------------------------------------------------- + +type UpdateInnerRedemptionRateBoundsTestCase struct { + validMsg stakeibctypes.MsgUpdateInnerRedemptionRateBounds + zone stakeibctypes.HostZone +} + +func (s *KeeperTestSuite) SetupUpdateInnerRedemptionRateBounds() UpdateInnerRedemptionRateBoundsTestCase { + // Register a host zone + hostZone := stakeibctypes.HostZone{ + ChainId: HostChainId, + HostDenom: Atom, + IbcDenom: IbcAtom, + RedemptionRate: sdk.NewDec(1.0), + MinRedemptionRate: sdk.NewDec(9).Quo(sdk.NewDec(10)), + MaxRedemptionRate: sdk.NewDec(15).Quo(sdk.NewDec(10)), + } + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + defaultMsg := stakeibctypes.MsgUpdateInnerRedemptionRateBounds{ + // TODO: does this need to be the admin address? + Creator: s.TestAccs[0].String(), + ChainId: HostChainId, + MinInnerRedemptionRate: sdk.NewDec(1), + MaxInnerRedemptionRate: sdk.NewDec(11).Quo(sdk.NewDec(10)), + } + + return UpdateInnerRedemptionRateBoundsTestCase{ + validMsg: defaultMsg, + zone: hostZone, + } +} + +// Verify that bounds can be set successfully +func (s *KeeperTestSuite) TestUpdateInnerRedemptionRateBounds_Success() { + tc := s.SetupUpdateInnerRedemptionRateBounds() + + // Set the inner bounds on the host zone + _, err := s.GetMsgServer().UpdateInnerRedemptionRateBounds(s.Ctx, &tc.validMsg) + s.Require().NoError(err, "should not throw an error") + + // Confirm the inner bounds were set + zone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) + s.Require().True(found, "host zone should be in the store") + s.Require().Equal(tc.validMsg.MinInnerRedemptionRate, zone.MinInnerRedemptionRate, "min inner redemption rate should be set") + s.Require().Equal(tc.validMsg.MaxInnerRedemptionRate, zone.MaxInnerRedemptionRate, "max inner redemption rate should be set") +} + +// Setting inner bounds outside of outer bounds should throw an error +func (s *KeeperTestSuite) TestUpdateInnerRedemptionRateBounds_OutOfBounds() { + tc := s.SetupUpdateInnerRedemptionRateBounds() + + // Set the min inner bound to be less than the min outer bound + tc.validMsg.MinInnerRedemptionRate = sdk.NewDec(0) + + // Set the inner bounds on the host zone + _, err := s.GetMsgServer().UpdateInnerRedemptionRateBounds(s.Ctx, &tc.validMsg) + // verify it throws an error + errMsg := fmt.Sprintf("inner min safety threshold (%s) is less than outer min safety threshold (%s)", tc.validMsg.MinInnerRedemptionRate, sdk.NewDec(9).Quo(sdk.NewDec(10))) + s.Require().ErrorContains(err, errMsg) + + // Set the min inner bound to be valid, but the max inner bound to be greater than the max outer bound + tc.validMsg.MinInnerRedemptionRate = sdk.NewDec(1) + tc.validMsg.MaxInnerRedemptionRate = sdk.NewDec(3) + // Set the inner bounds on the host zone + _, err = s.GetMsgServer().UpdateInnerRedemptionRateBounds(s.Ctx, &tc.validMsg) + // verify it throws an error + errMsg = fmt.Sprintf("inner max safety threshold (%s) is greater than outer max safety threshold (%s)", tc.validMsg.MaxInnerRedemptionRate, sdk.NewDec(15).Quo(sdk.NewDec(10))) + s.Require().ErrorContains(err, errMsg) +} + +// Validate basic tests +func (s *KeeperTestSuite) TestUpdateInnerRedemptionRateBounds_InvalidMsg() { + tc := s.SetupUpdateInnerRedemptionRateBounds() + + // Set the min inner bound to be greater than than the max inner bound + invalidMsg := tc.validMsg + invalidMsg.MinInnerRedemptionRate = sdk.NewDec(2) + + err := invalidMsg.ValidateBasic() + + // Verify the error + errMsg := fmt.Sprintf("Inner max safety threshold (%s) is less than inner min safety threshold (%s)", invalidMsg.MaxInnerRedemptionRate, invalidMsg.MinInnerRedemptionRate) + s.Require().ErrorContains(err, errMsg) +} + +// Verify that if inner bounds end up outside of outer bounds (somehow), the outer bounds are returned +func (s *KeeperTestSuite) TestGetInnerSafetyBounds() { + tc := s.SetupUpdateInnerRedemptionRateBounds() + + // Set the inner bounds outside the outer bounds on the host zone directly + tc.zone.MinInnerRedemptionRate = sdk.NewDec(0) + tc.zone.MaxInnerRedemptionRate = sdk.NewDec(3) + // Set the host zone + s.App.StakeibcKeeper.SetHostZone(s.Ctx, tc.zone) + + // Get the inner bounds and verify the outer bounds are used + innerMinSafetyThreshold, innerMaxSafetyThreshold := s.App.StakeibcKeeper.GetInnerSafetyBounds(s.Ctx, tc.zone) + s.Require().Equal(tc.zone.MinRedemptionRate, innerMinSafetyThreshold, "min inner redemption rate should be set") + s.Require().Equal(tc.zone.MaxRedemptionRate, innerMaxSafetyThreshold, "max inner redemption rate should be set") +} + +// ---------------------------------------------------- +// ResumeHostZone +// ---------------------------------------------------- + +type ResumeHostZoneTestCase struct { + validMsg stakeibctypes.MsgResumeHostZone + zone stakeibctypes.HostZone +} + +func (s *KeeperTestSuite) SetupResumeHostZone() ResumeHostZoneTestCase { + // Register a host zone + hostZone := stakeibctypes.HostZone{ + ChainId: HostChainId, + HostDenom: Atom, + IbcDenom: IbcAtom, + RedemptionRate: sdk.NewDec(1.0), + MinRedemptionRate: sdk.NewDec(9).Quo(sdk.NewDec(10)), + MaxRedemptionRate: sdk.NewDec(15).Quo(sdk.NewDec(10)), + Halted: true, + } + + s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) + + defaultMsg := stakeibctypes.MsgResumeHostZone{ + Creator: s.TestAccs[0].String(), + ChainId: HostChainId, + } + + return ResumeHostZoneTestCase{ + validMsg: defaultMsg, + zone: hostZone, + } +} + +// Verify that bounds can be set successfully +func (s *KeeperTestSuite) TestResumeHostZone_Success() { + tc := s.SetupResumeHostZone() + + // Set the inner bounds on the host zone + _, err := s.GetMsgServer().ResumeHostZone(s.Ctx, &tc.validMsg) + s.Require().NoError(err, "should not throw an error") + + // Confirm the inner bounds were set + zone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) + s.Require().True(found, "host zone should be in the store") + + s.Require().False(zone.Halted, "host zone should not be halted") +} + +// verify that non-admins can't call the tx +func (s *KeeperTestSuite) TestResumeHostZone_NonAdmin() { + tc := s.SetupResumeHostZone() + + invalidMsg := tc.validMsg + invalidMsg.Creator = s.TestAccs[1].String() + + err := invalidMsg.ValidateBasic() + s.Require().Error(err, "nonadmins shouldn't be able to call this tx") +} + +// verify that the function can't be called on missing zones +func (s *KeeperTestSuite) TestResumeHostZone_MissingZones() { + tc := s.SetupResumeHostZone() + + invalidMsg := tc.validMsg + invalidChainId := "invalid-chain" + invalidMsg.ChainId = invalidChainId + + // Set the inner bounds on the host zone + _, err := s.GetMsgServer().ResumeHostZone(s.Ctx, &invalidMsg) + + s.Require().Error(err, "shouldn't be able to call tx on missing zones") + expectedErrorMsg := fmt.Sprintf("invalid chain id, zone for %s not found: host zone not found", invalidChainId) + s.Require().Equal(expectedErrorMsg, err.Error(), "should return correct error msg") +} + +// verify that the function can't be called on unhalted zones +func (s *KeeperTestSuite) TestResumeHostZone_UnhaltedZones() { + tc := s.SetupResumeHostZone() + + zone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) + s.Require().True(found, "host zone should be in the store") + s.Require().True(zone.Halted, "host zone should be halted") + zone.Halted = false + s.App.StakeibcKeeper.SetHostZone(s.Ctx, zone) + + // Set the inner bounds on the host zone + _, err := s.GetMsgServer().ResumeHostZone(s.Ctx, &tc.validMsg) + s.Require().Error(err, "shouldn't be able to call tx on unhalted zones") + expectedErrorMsg := fmt.Sprintf("invalid chain id, zone for %s not halted: host zone is not halted", HostChainId) + s.Require().Equal(expectedErrorMsg, err.Error(), "should return correct error msg") +} 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 deleted file mode 100644 index 44aef7a116..0000000000 --- a/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds.go +++ /dev/null @@ -1,49 +0,0 @@ -package keeper - -import ( - "context" - "fmt" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -func (k msgServer) UpdateInnerRedemptionRateBounds(goCtx context.Context, msg *types.MsgUpdateInnerRedemptionRateBounds) (*types.MsgUpdateInnerRedemptionRateBoundsResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - // 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)) - return nil, types.ErrInvalidHostZone - } - - // Get the wide bounds - outerMinSafetyThreshold, outerMaxSafetyThreshold := k.GetOuterSafetyBounds(ctx, zone) - - innerMinSafetyThreshold := msg.MinInnerRedemptionRate - innerMaxSafetyThreshold := msg.MaxInnerRedemptionRate - - // Confirm the inner bounds are within the outer bounds - if innerMinSafetyThreshold.LT(outerMinSafetyThreshold) { - errMsg := fmt.Sprintf("inner min safety threshold (%s) is less than outer min safety threshold (%s)", innerMinSafetyThreshold, outerMinSafetyThreshold) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrInvalidBounds, errMsg) - } - - if innerMaxSafetyThreshold.GT(outerMaxSafetyThreshold) { - errMsg := fmt.Sprintf("inner max safety threshold (%s) is greater than outer max safety threshold (%s)", innerMaxSafetyThreshold, outerMaxSafetyThreshold) - k.Logger(ctx).Error(errMsg) - return nil, errorsmod.Wrapf(types.ErrInvalidBounds, errMsg) - } - - // Set the inner bounds on the host zone - zone.MinInnerRedemptionRate = innerMinSafetyThreshold - zone.MaxInnerRedemptionRate = innerMaxSafetyThreshold - - k.SetHostZone(ctx, zone) - - return &types.MsgUpdateInnerRedemptionRateBoundsResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds_test.go b/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds_test.go deleted file mode 100644 index 4db9eb3c23..0000000000 --- a/x/stakeibc/keeper/msg_server_update_inner_redemption_rate_bounds_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package keeper_test - -import ( - "fmt" - - sdk "github.com/cosmos/cosmos-sdk/types" - _ "github.com/stretchr/testify/suite" - - stakeibctypes "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -type UpdateInnerRedemptionRateBoundsTestCase struct { - validMsg stakeibctypes.MsgUpdateInnerRedemptionRateBounds - zone stakeibctypes.HostZone -} - -func (s *KeeperTestSuite) SetupUpdateInnerRedemptionRateBounds() UpdateInnerRedemptionRateBoundsTestCase { - // Register a host zone - hostZone := stakeibctypes.HostZone{ - ChainId: HostChainId, - HostDenom: Atom, - IbcDenom: IbcAtom, - RedemptionRate: sdk.NewDec(1.0), - MinRedemptionRate: sdk.NewDec(9).Quo(sdk.NewDec(10)), - MaxRedemptionRate: sdk.NewDec(15).Quo(sdk.NewDec(10)), - } - - s.App.StakeibcKeeper.SetHostZone(s.Ctx, hostZone) - - defaultMsg := stakeibctypes.MsgUpdateInnerRedemptionRateBounds{ - // TODO: does this need to be the admin address? - Creator: s.TestAccs[0].String(), - ChainId: HostChainId, - MinInnerRedemptionRate: sdk.NewDec(1), - MaxInnerRedemptionRate: sdk.NewDec(11).Quo(sdk.NewDec(10)), - } - - return UpdateInnerRedemptionRateBoundsTestCase{ - validMsg: defaultMsg, - zone: hostZone, - } -} - -// Verify that bounds can be set successfully -func (s *KeeperTestSuite) TestUpdateInnerRedemptionRateBounds_Success() { - tc := s.SetupUpdateInnerRedemptionRateBounds() - - // Set the inner bounds on the host zone - _, err := s.GetMsgServer().UpdateInnerRedemptionRateBounds(s.Ctx, &tc.validMsg) - s.Require().NoError(err, "should not throw an error") - - // Confirm the inner bounds were set - zone, found := s.App.StakeibcKeeper.GetHostZone(s.Ctx, HostChainId) - s.Require().True(found, "host zone should be in the store") - s.Require().Equal(tc.validMsg.MinInnerRedemptionRate, zone.MinInnerRedemptionRate, "min inner redemption rate should be set") - s.Require().Equal(tc.validMsg.MaxInnerRedemptionRate, zone.MaxInnerRedemptionRate, "max inner redemption rate should be set") -} - -// Setting inner bounds outside of outer bounds should throw an error -func (s *KeeperTestSuite) TestUpdateInnerRedemptionRateBounds_OutOfBounds() { - tc := s.SetupUpdateInnerRedemptionRateBounds() - - // Set the min inner bound to be less than the min outer bound - tc.validMsg.MinInnerRedemptionRate = sdk.NewDec(0) - - // Set the inner bounds on the host zone - _, err := s.GetMsgServer().UpdateInnerRedemptionRateBounds(s.Ctx, &tc.validMsg) - // verify it throws an error - errMsg := fmt.Sprintf("inner min safety threshold (%s) is less than outer min safety threshold (%s)", tc.validMsg.MinInnerRedemptionRate, sdk.NewDec(9).Quo(sdk.NewDec(10))) - s.Require().ErrorContains(err, errMsg) - - // Set the min inner bound to be valid, but the max inner bound to be greater than the max outer bound - tc.validMsg.MinInnerRedemptionRate = sdk.NewDec(1) - tc.validMsg.MaxInnerRedemptionRate = sdk.NewDec(3) - // Set the inner bounds on the host zone - _, err = s.GetMsgServer().UpdateInnerRedemptionRateBounds(s.Ctx, &tc.validMsg) - // verify it throws an error - errMsg = fmt.Sprintf("inner max safety threshold (%s) is greater than outer max safety threshold (%s)", tc.validMsg.MaxInnerRedemptionRate, sdk.NewDec(15).Quo(sdk.NewDec(10))) - s.Require().ErrorContains(err, errMsg) -} - -// Validate basic tests -func (s *KeeperTestSuite) TestUpdateInnerRedemptionRateBounds_InvalidMsg() { - tc := s.SetupUpdateInnerRedemptionRateBounds() - - // Set the min inner bound to be greater than than the max inner bound - invalidMsg := tc.validMsg - invalidMsg.MinInnerRedemptionRate = sdk.NewDec(2) - - err := invalidMsg.ValidateBasic() - - // Verify the error - errMsg := fmt.Sprintf("Inner max safety threshold (%s) is less than inner min safety threshold (%s)", invalidMsg.MaxInnerRedemptionRate, invalidMsg.MinInnerRedemptionRate) - s.Require().ErrorContains(err, errMsg) -} - -// Verify that if inner bounds end up outside of outer bounds (somehow), the outer bounds are returned -func (s *KeeperTestSuite) TestGetInnerSafetyBounds() { - tc := s.SetupUpdateInnerRedemptionRateBounds() - - // Set the inner bounds outside the outer bounds on the host zone directly - tc.zone.MinInnerRedemptionRate = sdk.NewDec(0) - tc.zone.MaxInnerRedemptionRate = sdk.NewDec(3) - // Set the host zone - s.App.StakeibcKeeper.SetHostZone(s.Ctx, tc.zone) - - // Get the inner bounds and verify the outer bounds are used - innerMinSafetyThreshold, innerMaxSafetyThreshold := s.App.StakeibcKeeper.GetInnerSafetyBounds(s.Ctx, tc.zone) - s.Require().Equal(tc.zone.MinRedemptionRate, innerMinSafetyThreshold, "min inner redemption rate should be set") - s.Require().Equal(tc.zone.MaxRedemptionRate, innerMaxSafetyThreshold, "max inner redemption rate should be set") -} diff --git a/x/stakeibc/keeper/msg_server_update_trade_route.go b/x/stakeibc/keeper/msg_server_update_trade_route.go deleted file mode 100644 index 0cd77aff29..0000000000 --- a/x/stakeibc/keeper/msg_server_update_trade_route.go +++ /dev/null @@ -1,73 +0,0 @@ -package keeper - -import ( - "context" - - govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - errorsmod "cosmossdk.io/errors" - sdk "github.com/cosmos/cosmos-sdk/types" -) - -// Gov tx to update the trade config of a trade route -// -// Example proposal: -// -// { -// "title": "Update a the trade config for host chain X", -// "metadata": "Update a the trade config for host chain X", -// "summary": "Update a the trade config for host chain X", -// "messages":[ -// { -// "@type": "/stride.stakeibc.MsgUpdateTradeRoute", -// "authority": "stride10d07y265gmmuvt4z0w9aw880jnsr700jefnezl", -// -// "pool_id": 1, -// "max_allowed_swap_loss_rate": "0.05", -// "min_swap_amount": "10000000", -// "max_swap_amount": "1000000000" -// } -// ], -// "deposit": "2000000000ustrd" -// } -// -// >>> strided tx gov submit-proposal {proposal_file.json} --from wallet -func (ms msgServer) UpdateTradeRoute(goCtx context.Context, msg *types.MsgUpdateTradeRoute) (*types.MsgUpdateTradeRouteResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - if ms.authority != msg.Authority { - return nil, errorsmod.Wrapf(govtypes.ErrInvalidSigner, "invalid authority; expected %s, got %s", ms.authority, msg.Authority) - } - - route, found := ms.Keeper.GetTradeRoute(ctx, msg.RewardDenom, msg.HostDenom) - if !found { - return nil, errorsmod.Wrapf(types.ErrTradeRouteNotFound, - "no trade route for rewardDenom %s and hostDenom %s", msg.RewardDenom, msg.HostDenom) - } - - maxAllowedSwapLossRate := msg.MaxAllowedSwapLossRate - if maxAllowedSwapLossRate == "" { - maxAllowedSwapLossRate = DefaultMaxAllowedSwapLossRate - } - maxSwapAmount := msg.MaxSwapAmount - if maxSwapAmount.IsZero() { - maxSwapAmount = DefaultMaxSwapAmount - } - - updatedConfig := types.TradeConfig{ - PoolId: msg.PoolId, - - SwapPrice: sdk.ZeroDec(), - PriceUpdateTimestamp: 0, - - MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), - MinSwapAmount: msg.MinSwapAmount, - MaxSwapAmount: maxSwapAmount, - } - - route.TradeConfig = updatedConfig - ms.Keeper.SetTradeRoute(ctx, route) - - return &types.MsgUpdateTradeRouteResponse{}, nil -} diff --git a/x/stakeibc/keeper/msg_server_update_trade_route_test.go b/x/stakeibc/keeper/msg_server_update_trade_route_test.go deleted file mode 100644 index 03c7eddf8d..0000000000 --- a/x/stakeibc/keeper/msg_server_update_trade_route_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package keeper_test - -import ( - sdkmath "cosmossdk.io/math" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/keeper" - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" - - sdk "github.com/cosmos/cosmos-sdk/types" -) - -// Helper function to update a trade route and check the updated route matched expectations -func (s *KeeperTestSuite) submitUpdateTradeRouteAndValidate(msg types.MsgUpdateTradeRoute, expectedRoute types.TradeRoute) { - _, err := s.GetMsgServer().UpdateTradeRoute(sdk.WrapSDKContext(s.Ctx), &msg) - s.Require().NoError(err, "no error expected when updating trade route") - - actualRoute, found := s.App.StakeibcKeeper.GetTradeRoute(s.Ctx, RewardDenom, HostDenom) - s.Require().True(found, "trade route should have been updated") - s.Require().Equal(expectedRoute, actualRoute, "trade route") -} - -func (s *KeeperTestSuite) TestUpdateTradeRoute() { - poolId := uint64(100) - maxAllowedSwapLossRate := "0.05" - minSwapAmount := sdkmath.NewInt(100) - maxSwapAmount := sdkmath.NewInt(1_000) - - // Create a trade route with no parameters - initialRoute := types.TradeRoute{ - RewardDenomOnRewardZone: RewardDenom, - HostDenomOnHostZone: HostDenom, - } - s.App.StakeibcKeeper.SetTradeRoute(s.Ctx, initialRoute) - - // Define a valid message given the parameters above - msg := types.MsgUpdateTradeRoute{ - Authority: Authority, - - RewardDenom: RewardDenom, - HostDenom: HostDenom, - - PoolId: poolId, - MaxAllowedSwapLossRate: maxAllowedSwapLossRate, - MinSwapAmount: minSwapAmount, - MaxSwapAmount: maxSwapAmount, - } - - // Build out the expected trade route given the above - expectedRoute := initialRoute - expectedRoute.TradeConfig = types.TradeConfig{ - PoolId: poolId, - SwapPrice: sdk.ZeroDec(), - PriceUpdateTimestamp: 0, - - MaxAllowedSwapLossRate: sdk.MustNewDecFromStr(maxAllowedSwapLossRate), - MinSwapAmount: minSwapAmount, - MaxSwapAmount: maxSwapAmount, - } - - // Update the route and confirm the changes persisted - s.submitUpdateTradeRouteAndValidate(msg, expectedRoute) - - // Update it again, this time using default args - defaultMsg := msg - defaultMsg.MaxAllowedSwapLossRate = "" - defaultMsg.MaxSwapAmount = sdkmath.ZeroInt() - - expectedRoute.TradeConfig.MaxAllowedSwapLossRate = sdk.MustNewDecFromStr(keeper.DefaultMaxAllowedSwapLossRate) - expectedRoute.TradeConfig.MaxSwapAmount = keeper.DefaultMaxSwapAmount - - s.submitUpdateTradeRouteAndValidate(defaultMsg, expectedRoute) - - // Test that an error is thrown if the correct authority is not specified - invalidMsg := msg - invalidMsg.Authority = "not-gov-address" - - _, err := s.GetMsgServer().UpdateTradeRoute(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().ErrorContains(err, "invalid authority") - - // Test that an error is thrown if the route doesn't exist - invalidMsg = msg - invalidMsg.RewardDenom = "invalid-reward-denom" - - _, err = s.GetMsgServer().UpdateTradeRoute(sdk.WrapSDKContext(s.Ctx), &invalidMsg) - s.Require().ErrorContains(err, "trade route not found") -} diff --git a/x/stakeibc/keeper/msg_server_update_validator_shares_exch_rate.go b/x/stakeibc/keeper/msg_server_update_validator_shares_exch_rate.go deleted file mode 100644 index de32f00a9f..0000000000 --- a/x/stakeibc/keeper/msg_server_update_validator_shares_exch_rate.go +++ /dev/null @@ -1,23 +0,0 @@ -package keeper - -import ( - "context" - - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/Stride-Labs/stride/v18/x/stakeibc/types" -) - -// This kicks off two ICQs, each with a callback, that will update the number of tokens on a validator -// after being slashed. The flow is: -// 1. QueryValidatorSharesToTokensRate (ICQ) -// 2. ValidatorSharesToTokensRate (CALLBACK) -// 3. SubmitDelegationICQ (ICQ) -// 4. DelegatorSharesCallback (CALLBACK) -func (k msgServer) UpdateValidatorSharesExchRate(goCtx context.Context, msg *types.MsgUpdateValidatorSharesExchRate) (*types.MsgUpdateValidatorSharesExchRateResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - if err := k.QueryValidatorSharesToTokensRate(ctx, msg.ChainId, msg.Valoper); err != nil { - return nil, err - } - return &types.MsgUpdateValidatorSharesExchRateResponse{}, nil -}