diff --git a/app/apptesting/test_helpers.go b/app/apptesting/test_helpers.go index 7153505ab..65b92d7ba 100644 --- a/app/apptesting/test_helpers.go +++ b/app/apptesting/test_helpers.go @@ -446,18 +446,44 @@ func (s *AppTestHelper) MockClientLatestHeight(height uint64) { // Helper function to mock out a client and connection to test // mapping from connection ID back to chain ID +// This also mocks out the consensus state to enable testing registering interchain accounts func (s *AppTestHelper) MockClientAndConnection(chainId, clientId, connectionId string) { + clientHeight := clienttypes.Height{ + RevisionHeight: uint64(s.Ctx.BlockHeight()), + } clientState := tendermint.ClientState{ - ChainId: chainId, + ChainId: chainId, + LatestHeight: clientHeight, + TrustingPeriod: time.Minute * 10, } s.App.IBCKeeper.ClientKeeper.SetClientState(s.Ctx, clientId, &clientState) + consensusState := tendermint.ConsensusState{ + Timestamp: s.Ctx.BlockTime(), + } + s.App.IBCKeeper.ClientKeeper.SetClientConsensusState(s.Ctx, clientId, clientHeight, &consensusState) + connection := connectiontypes.ConnectionEnd{ ClientId: clientId, + Versions: []*connectiontypes.Version{connectiontypes.DefaultIBCVersion}, } s.App.IBCKeeper.ConnectionKeeper.SetConnection(s.Ctx, connectionId, connection) } +// Helper function to mock out an ICA address +func (s *AppTestHelper) MockICAChannel(connectionId, channelId, owner, address string) { + // Create an open channel with the ICA port + portId, _ := icatypes.NewControllerPortID(owner) + channel := channeltypes.Channel{ + State: channeltypes.OPEN, + } + s.App.IBCKeeper.ChannelKeeper.SetChannel(s.Ctx, portId, channelId, channel) + + // Then set the address and make the channel active + s.App.ICAControllerKeeper.SetInterchainAccountAddress(s.Ctx, connectionId, portId, address) + s.App.ICAControllerKeeper.SetActiveChannelID(s.Ctx, connectionId, portId, channelId) +} + func (s *AppTestHelper) ConfirmUpgradeSucceededs(upgradeName string, upgradeHeight int64) { s.Ctx = s.Ctx.WithBlockHeight(upgradeHeight - 1) plan := upgradetypes.Plan{Name: upgradeName, Height: upgradeHeight} diff --git a/x/stakeibc/keeper/keeper_test.go b/x/stakeibc/keeper/keeper_test.go index 94976b8b1..ff427b7e5 100644 --- a/x/stakeibc/keeper/keeper_test.go +++ b/x/stakeibc/keeper/keeper_test.go @@ -7,13 +7,16 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/suite" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + "github.com/Stride-Labs/stride/v16/app/apptesting" epochtypes "github.com/Stride-Labs/stride/v16/x/epochs/types" "github.com/Stride-Labs/stride/v16/x/stakeibc/keeper" "github.com/Stride-Labs/stride/v16/x/stakeibc/types" ) -const ( +var ( Atom = "uatom" StAtom = "stuatom" IbcAtom = "ibc/uatom" @@ -37,6 +40,8 @@ const ( DepositAddress = "deposit" CommunityPoolStakeHoldingAddress = "staking-holding" CommunityPoolRedeemHoldingAddress = "redeem-holding" + + Authority = authtypes.NewModuleAddress(govtypes.ModuleName).String() ) type KeeperTestSuite struct { diff --git a/x/stakeibc/keeper/msg_server_create_trade_route.go b/x/stakeibc/keeper/msg_server_create_trade_route.go index 73654d7f0..c1a9c24d1 100644 --- a/x/stakeibc/keeper/msg_server_create_trade_route.go +++ b/x/stakeibc/keeper/msg_server_create_trade_route.go @@ -66,7 +66,7 @@ func (ms msgServer) CreateTradeRoute(goCtx context.Context, msg *types.MsgCreate _, found := ms.Keeper.GetTradeRoute(ctx, msg.RewardDenomOnReward, msg.HostDenomOnHost) if found { return nil, errorsmod.Wrapf(types.ErrTradeRouteAlreadyExists, - "startDenom: %s, endDenom: %s", msg.RewardDenomOnReward, msg.HostDenomOnHost) + "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 diff --git a/x/stakeibc/keeper/msg_server_create_trade_route_test.go b/x/stakeibc/keeper/msg_server_create_trade_route_test.go new file mode 100644 index 000000000..694777aac --- /dev/null +++ b/x/stakeibc/keeper/msg_server_create_trade_route_test.go @@ -0,0 +1,231 @@ +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/v16/x/stakeibc/keeper" + "github.com/Stride-Labs/stride/v16/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 + // TODO [DYDX]: Replace with trade route owner + owner := types.FormatICAAccountOwner(rewardChainId, 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 + // TODO [DYDX]: Replace with new formatter + tradeAccount := expectedRoute.TradeAccount + tradeOwner := types.FormatICAAccountOwner(tradeAccount.ChainId, 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_test.go b/x/stakeibc/keeper/msg_server_delete_trade_route_test.go new file mode 100644 index 000000000..7b7c5cecc --- /dev/null +++ b/x/stakeibc/keeper/msg_server_delete_trade_route_test.go @@ -0,0 +1,44 @@ +package keeper_test + +import ( + "github.com/Stride-Labs/stride/v16/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_update_trade_route_test.go b/x/stakeibc/keeper/msg_server_update_trade_route_test.go new file mode 100644 index 000000000..75c666023 --- /dev/null +++ b/x/stakeibc/keeper/msg_server_update_trade_route_test.go @@ -0,0 +1,86 @@ +package keeper_test + +import ( + sdkmath "cosmossdk.io/math" + + "github.com/Stride-Labs/stride/v16/x/stakeibc/keeper" + "github.com/Stride-Labs/stride/v16/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") +}