From fe21ad3831b8465f2d4c59b4b8f40a470d471b59 Mon Sep 17 00:00:00 2001 From: sampocs Date: Mon, 4 Dec 2023 15:44:22 -0600 Subject: [PATCH] reward collector - ICA message unit tests (#1005) --- app/apptesting/test_helpers.go | 7 + x/stakeibc/keeper/keeper_test.go | 19 +- x/stakeibc/keeper/reward_converter.go | 112 ++++--- x/stakeibc/keeper/reward_converter_test.go | 346 +++++++++++++++++++++ 4 files changed, 436 insertions(+), 48 deletions(-) create mode 100644 x/stakeibc/keeper/reward_converter_test.go diff --git a/app/apptesting/test_helpers.go b/app/apptesting/test_helpers.go index ee7d95495..7153505ab 100644 --- a/app/apptesting/test_helpers.go +++ b/app/apptesting/test_helpers.go @@ -409,6 +409,13 @@ func (s *AppTestHelper) GetIBCDenomTrace(denom string) transfertypes.DenomTrace return transfertypes.ParseDenomTrace(prefixedDenom) } +// Helper function to get the next sequence number for testing when an ICA was submitted +func (s *AppTestHelper) MustGetNextSequenceNumber(portId, channelId string) uint64 { + sequence, found := s.App.StakeibcKeeper.IBCKeeper.ChannelKeeper.GetNextSequenceSend(s.Ctx, portId, channelId) + s.Require().True(found, "sequence number for port %s and channel %s was not found", portId, channelId) + return sequence +} + // Creates and stores an IBC denom from a base denom on transfer channel-0 // This is only required for tests that use the transfer keeper and require that the IBC // denom is present in the store diff --git a/x/stakeibc/keeper/keeper_test.go b/x/stakeibc/keeper/keeper_test.go index 134c990c3..bc27326ab 100644 --- a/x/stakeibc/keeper/keeper_test.go +++ b/x/stakeibc/keeper/keeper_test.go @@ -2,11 +2,13 @@ package keeper_test import ( "testing" + "time" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/suite" "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" ) @@ -25,16 +27,9 @@ const ( OsmoPrefix = "osmo" OsmoChainId = "OSMO" - HostDenom = "udenom" - RewardDenom = "ureward" - ValAddress = "cosmosvaloper1uk4ze0x4nvh4fk0xm4jdud58eqn4yxhrdt795p" HostICAAddress = "cosmos1gcx4yeplccq9nk6awzmm0gq8jf7yet80qj70tkwy0mz7pg87nepswn2dj8" LSMTokenBaseDenom = ValAddress + "/32" - - DepositAddress = "deposit" - CommunityPoolStakeHoldingAddress = "staking-holding" - CommunityPoolRedeemHoldingAddress = "redeem-holding" ) type KeeperTestSuite struct { @@ -65,6 +60,16 @@ func (s *KeeperTestSuite) MustGetHostZone(chainId string) types.HostZone { return hostZone } +// Helper function to create an stride epoch tracker that dictates the timeout +func (s *KeeperTestSuite) CreateStrideEpochForICATimeout(timeoutDuration time.Duration) { + epochEndTime := uint64(s.Ctx.BlockTime().Add(timeoutDuration).UnixNano()) + epochTracker := types.EpochTracker{ + EpochIdentifier: epochtypes.STRIDE_EPOCH, + NextEpochStartTime: epochEndTime, + } + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, epochTracker) +} + func (s *KeeperTestSuite) TestIsRedemptionRateWithinSafetyBounds() { params := s.App.StakeibcKeeper.GetParams(s.Ctx) params.DefaultMinRedemptionRateThreshold = 75 diff --git a/x/stakeibc/keeper/reward_converter.go b/x/stakeibc/keeper/reward_converter.go index aab26eecb..6cc4eb59c 100644 --- a/x/stakeibc/keeper/reward_converter.go +++ b/x/stakeibc/keeper/reward_converter.go @@ -25,11 +25,11 @@ type PacketForwardMetadata struct { Forward *ForwardMetadata `json:"forward"` } type ForwardMetadata struct { - Receiver string `json:"receiver,omitempty"` - Port string `json:"port,omitempty"` - Channel string `json:"channel,omitempty"` - Timeout string `json:"timeout,omitempty"` - Retries uint8 `json:"retries,omitempty"` + Receiver string `json:"receiver"` + Port string `json:"port"` + Channel string `json:"channel"` + Timeout string `json:"timeout"` + Retries int64 `json:"retries"` } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -51,22 +51,17 @@ type ForwardMetadata struct { // and the normal staking and distribution flow will continue from there. ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// ICA tx will kick off transfering the reward tokens from the hostZone withdrawl ICA to the tradeZone trade ICA -// This will be two hops to unwind the ibc denom through the rewardZone using pfm in the transfer memo if possible -// -// msgs with packet forwarding memos can unwind through the reward zone and chain two transfer hops without callbacks -func (k Keeper) TransferRewardTokensHostToTrade(ctx sdk.Context, amount sdkmath.Int, route types.TradeRoute) error { - // If the min swap amount was not set it would be ZeroInt, if positive we need to compare to the amount given - // then if the min swap amount is greater than the current amount, do nothing this epoch to avoid small transfers - // Particularly important for the PFM hop if the reward chain has frictional transfer fees (like noble chain) - if route.TradeConfig.MinSwapAmount.IsPositive() && route.TradeConfig.MinSwapAmount.GT(amount) { - return nil - } - +// Builds a PFM transfer message to send reward tokens from the host zone, +// through the reward zone (to unwind) and finally to the trade zone +func (k Keeper) BuildHostToTradeTransferMsg( + ctx sdk.Context, + amount sdkmath.Int, + route types.TradeRoute, +) (msg transfertypes.MsgTransfer, err error) { // Get the epoch tracker to determine the timeouts strideEpochTracker, found := k.GetEpochTracker(ctx, epochstypes.STRIDE_EPOCH) if !found { - return errorsmod.Wrapf(types.ErrEpochNotFound, epochstypes.STRIDE_EPOCH) + return msg, errorsmod.Wrapf(types.ErrEpochNotFound, epochstypes.STRIDE_EPOCH) } // Timeout the first transfer halfway through the epoch, and the second transfer at the end of the epoch @@ -96,11 +91,10 @@ func (k Keeper) TransferRewardTokensHostToTrade(ctx sdk.Context, amount sdkmath. } memoJSON, err := json.Marshal(memo) if err != nil { - return err + return msg, err } - var msgs []proto.Message - msgs = append(msgs, &transfertypes.MsgTransfer{ + msg = transfertypes.MsgTransfer{ SourcePort: transfertypes.PortID, SourceChannel: route.HostToRewardChannelId, // channel on hostZone for transfers to rewardZone Token: sendTokens, @@ -108,17 +102,37 @@ func (k Keeper) TransferRewardTokensHostToTrade(ctx sdk.Context, amount sdkmath. Receiver: unwindIcaAddress, // could be "pfm" or a real address depending on version TimeoutTimestamp: transfer1TimeoutTimestamp, Memo: string(memoJSON), - }) + } + + return msg, nil +} + +// ICA tx will kick off transfering the reward tokens from the hostZone withdrawl ICA to the tradeZone trade ICA +// This will be two hops to unwind the ibc denom through the rewardZone using pfm in the transfer memo +func (k Keeper) TransferRewardTokensHostToTrade(ctx sdk.Context, amount sdkmath.Int, route types.TradeRoute) error { + // If the min swap amount was not set it would be ZeroInt, if positive we need to compare to the amount given + // then if the min swap amount is greater than the current amount, do nothing this epoch to avoid small transfers + // Particularly important for the PFM hop if the reward chain has frictional transfer fees (like noble chain) + if route.TradeConfig.MinSwapAmount.IsPositive() && route.TradeConfig.MinSwapAmount.GT(amount) { + return nil + } + + // Build the PFM transfer message from host to trade zone + msg, err := k.BuildHostToTradeTransferMsg(ctx, amount, route) + if err != nil { + return err + } + msgs := []proto.Message{&msg} hostZoneId := route.HostAccount.ChainId rewardZoneId := route.RewardAccount.ChainId tradeZoneId := route.TradeAccount.ChainId k.Logger(ctx).Info(utils.LogWithHostZone(hostZoneId, - "Preparing MsgTransfer of %+v from %s to %s to %s", sendTokens, hostZoneId, rewardZoneId, tradeZoneId)) + "Preparing MsgTransfer of %+v from %s to %s to %s", msg.Token, hostZoneId, rewardZoneId, tradeZoneId)) // Send the ICA tx to kick off transfer from hostZone through rewardZone to the tradeZone (no callbacks) hostZoneConnectionId := route.HostAccount.ConnectionId - err = k.SubmitICATxWithoutCallback(ctx, hostZoneConnectionId, types.ICAAccountType_WITHDRAWAL, msgs, transfer1TimeoutTimestamp) + err = k.SubmitICATxWithoutCallback(ctx, hostZoneConnectionId, types.ICAAccountType_WITHDRAWAL, msgs, msg.TimeoutTimestamp) if err != nil { return errorsmod.Wrapf(err, "Failed to submit ICA tx, Messages: %+v", msgs) } @@ -167,19 +181,13 @@ func (k Keeper) TransferConvertedTokensTradeToHost(ctx sdk.Context, amount sdkma return nil } -// Trade reward tokens in the Trade ICA for the host denom tokens using ICA remote tx on trade zone -// The amount represents the total amount of the reward token in the trade ICA found by the calling ICQ +// Builds the Osmosis swap message to trade reward tokens for host tokens // Depending on min and max swap amounts set in the route, it is possible not the full amount given will swap -func (k Keeper) SwapRewardTokens(ctx sdk.Context, rewardAmount sdkmath.Int, route types.TradeRoute) error { - // If the min swap amount was not set it would be ZeroInt, if positive we need to compare to the amount given - // then if the min swap amount is greater than the current amount, do nothing this epoch to avoid small swaps - tradeConfig := route.TradeConfig - if tradeConfig.MinSwapAmount.IsPositive() && tradeConfig.MinSwapAmount.GT(rewardAmount) { - return nil - } - +// The minimum amount of tokens that can come out of the trade is calculated using a price from the pool +func (k Keeper) BuildSwapMsg(rewardAmount sdkmath.Int, route types.TradeRoute) (msg types.MsgSwapExactAmountIn, err error) { // If the max swap amount was not set it would be ZeroInt, if positive we need to compare to the amount given // then if max swap amount is LTE to amount full swap is possible so amount is fine, otherwise set amount to max + tradeConfig := route.TradeConfig if tradeConfig.MaxSwapAmount.IsPositive() && rewardAmount.GT(tradeConfig.MaxSwapAmount) { rewardAmount = tradeConfig.MaxSwapAmount } @@ -188,7 +196,7 @@ func (k Keeper) SwapRewardTokens(ctx sdk.Context, rewardAmount sdkmath.Int, rout // The only time this should not be set is right after the pool is added, // before an ICQ has been submitted for the price if tradeConfig.SwapPrice.IsZero() { - return fmt.Errorf("Price not found for pool %d", tradeConfig.PoolId) + return msg, fmt.Errorf("Price not found for pool %d", tradeConfig.PoolId) } // If there is a valid price, use it to set a floor for the acceptable minimum output tokens @@ -205,7 +213,6 @@ func (k Keeper) SwapRewardTokens(ctx sdk.Context, rewardAmount sdkmath.Int, rout minOutPercentage := sdk.OneDec().Sub(tradeConfig.MaxAllowedSwapLossRate) minOut := rewardAmountConverted.Mul(minOutPercentage).TruncateInt() - tradeIcaAccount := route.TradeAccount tradeTokens := sdk.NewCoin(route.RewardDenomOnTradeZone, rewardAmount) // Prepare Osmosis GAMM module MsgSwapExactAmountIn from the trade account to perform the trade @@ -215,15 +222,38 @@ func (k Keeper) SwapRewardTokens(ctx sdk.Context, rewardAmount sdkmath.Int, rout PoolId: tradeConfig.PoolId, TokenOutDenom: route.HostDenomOnTradeZone, }} - msgs := []proto.Message{&types.MsgSwapExactAmountIn{ - Sender: tradeIcaAccount.Address, + msg = types.MsgSwapExactAmountIn{ + Sender: route.TradeAccount.Address, Routes: routes, TokenIn: tradeTokens, TokenOutMinAmount: minOut, - }} + } + + return msg, nil +} + +// Trade reward tokens in the Trade ICA for the host denom tokens using ICA remote tx on trade zone +// The amount represents the total amount of the reward token in the trade ICA found by the calling ICQ +func (k Keeper) SwapRewardTokens(ctx sdk.Context, rewardAmount sdkmath.Int, route types.TradeRoute) error { + // If the min swap amount was not set it would be ZeroInt, if positive we need to compare to the amount given + // then if the min swap amount is greater than the current amount, do nothing this epoch to avoid small swaps + tradeConfig := route.TradeConfig + if tradeConfig.MinSwapAmount.IsPositive() && tradeConfig.MinSwapAmount.GT(rewardAmount) { + return nil + } + + // Build the Osmosis swap message to convert reward tokens to host tokens + msg, err := k.BuildSwapMsg(rewardAmount, route) + if err != nil { + return err + } + msgs := []proto.Message{&msg} + + tradeIcaAccount := route.TradeAccount k.Logger(ctx).Info(utils.LogWithHostZone(tradeIcaAccount.ChainId, - "Preparing MsgSwapExactAmountIn of %+v from the trade account", tradeTokens)) + "Preparing MsgSwapExactAmountIn of %+v from the trade account", msg.TokenIn)) + // Timeout the swap at the end of the epoch strideEpochTracker, found := k.GetEpochTracker(ctx, epochstypes.STRIDE_EPOCH) if !found { return errorsmod.Wrapf(types.ErrEpochNotFound, epochstypes.STRIDE_EPOCH) @@ -231,7 +261,7 @@ func (k Keeper) SwapRewardTokens(ctx sdk.Context, rewardAmount sdkmath.Int, rout timeout := uint64(strideEpochTracker.NextEpochStartTime) // Send the ICA tx to perform the swap on the tradeZone - err := k.SubmitICATxWithoutCallback(ctx, tradeIcaAccount.ConnectionId, types.ICAAccountType_CONVERTER_TRADE, msgs, timeout) + err = k.SubmitICATxWithoutCallback(ctx, tradeIcaAccount.ConnectionId, types.ICAAccountType_CONVERTER_TRADE, msgs, timeout) if err != nil { return errorsmod.Wrapf(err, "Failed to submit ICA tx for the swap, Messages: %v", msgs) } diff --git a/x/stakeibc/keeper/reward_converter_test.go b/x/stakeibc/keeper/reward_converter_test.go new file mode 100644 index 000000000..f6f510822 --- /dev/null +++ b/x/stakeibc/keeper/reward_converter_test.go @@ -0,0 +1,346 @@ +package keeper_test + +import ( + "fmt" + "time" + + 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" + + epochtypes "github.com/Stride-Labs/stride/v16/x/epochs/types" + "github.com/Stride-Labs/stride/v16/x/stakeibc/types" +) + +// Tests TransferRewardTokensHostToTrade and BuildHostToTradeTransferMsg +func (s *KeeperTestSuite) TestTransferRewardTokensHostToTrade() { + // Create an ICA channel for the transfer submission + owner := types.FormatICAAccountOwner(HostChainId, types.ICAAccountType_WITHDRAWAL) + channelId, portId := s.CreateICAChannel(owner) + + // Define components of transfer message + hostToRewardChannelId := "channel-0" + rewardToTradeChannelId := "channel-1" + + rewardDenomOnHostZone := "ibc/reward_on_host" + rewardDenomOnRewardZone := "reward_on_reward" + + withdrawalAddress := "withdrawal_address" + unwindAddress := "unwind_address" + tradeAddress := "trade_address" + + transferAmount := sdk.NewInt(1000) + transferToken := sdk.NewCoin(rewardDenomOnHostZone, transferAmount) + minSwapAmount := sdk.NewInt(500) + + currentTime := s.Ctx.BlockTime() + epochLength := time.Second * 10 // 10 seconds + epochEndTime := currentTime.Add(time.Second * 10) // 10 seconds from now + transfer1TimeoutTimestamp := currentTime.Add(time.Second * 5) // 5 seconds from now (halfway through) + transfer2TimeoutDuration := "5s" + + // Create a trade route with the relevant addresses and transfer channels + route := types.TradeRoute{ + HostToRewardChannelId: hostToRewardChannelId, + RewardToTradeChannelId: rewardToTradeChannelId, + + RewardDenomOnHostZone: rewardDenomOnHostZone, + RewardDenomOnRewardZone: rewardDenomOnRewardZone, + + HostAccount: types.ICAAccount{ + Address: withdrawalAddress, + ConnectionId: ibctesting.FirstConnectionID, + }, + RewardAccount: types.ICAAccount{ + Address: unwindAddress, + }, + TradeAccount: types.ICAAccount{ + Address: tradeAddress, + }, + + TradeConfig: types.TradeConfig{ + MinSwapAmount: minSwapAmount, + }, + } + + // Create an epoch tracker to dictate the timeout + s.App.StakeibcKeeper.SetEpochTracker(s.Ctx, types.EpochTracker{ + EpochIdentifier: epochtypes.STRIDE_EPOCH, + NextEpochStartTime: uint64(epochEndTime.UnixNano()), + Duration: uint64(epochLength.Nanoseconds()), + }) + + // Define the expected transfer message using all the above + memoJSON := fmt.Sprintf(`{"forward":{"receiver":"%s","port":"transfer","channel":"%s","timeout":"%s","retries":0}}`, + tradeAddress, rewardToTradeChannelId, transfer2TimeoutDuration) + + expectedMsg := transfertypes.MsgTransfer{ + SourcePort: transfertypes.PortID, + SourceChannel: hostToRewardChannelId, + Token: transferToken, + Sender: withdrawalAddress, + Receiver: unwindAddress, + TimeoutTimestamp: uint64(transfer1TimeoutTimestamp.UnixNano()), + Memo: memoJSON, + } + + // Confirm the generated message matches expectations + actualMsg, err := s.App.StakeibcKeeper.BuildHostToTradeTransferMsg(s.Ctx, transferAmount, route) + s.Require().NoError(err, "no error expected when building transfer message") + s.Require().Equal(expectedMsg, actualMsg, "transfer message should have matched") + + // Call the main transfer function and confirm the sequence number increments + startSequence := s.MustGetNextSequenceNumber(portId, channelId) + + err = s.App.StakeibcKeeper.TransferRewardTokensHostToTrade(s.Ctx, transferAmount, route) + s.Require().NoError(err, "no error expected when submitting transfer") + + sequenceAfterTransfer := s.MustGetNextSequenceNumber(portId, channelId) + s.Require().Equal(startSequence+1, sequenceAfterTransfer, "sequence number should have incremented") + + // Attempt to call the function again with an transfer amount below the min, + // it should not submit an ICA + invalidTransferAmount := minSwapAmount.Sub(sdkmath.OneInt()) + err = s.App.StakeibcKeeper.TransferRewardTokensHostToTrade(s.Ctx, invalidTransferAmount, route) + s.Require().NoError(err, "no error expected when submitting transfer with amount below minimum") + + endSequence := s.MustGetNextSequenceNumber(portId, channelId) + s.Require().Equal(sequenceAfterTransfer, endSequence, "sequence number should NOT have incremented") + + // Remove the connection ID and confirm the ICA fails + invalidRoute := route + invalidRoute.HostAccount.ConnectionId = "" + err = s.App.StakeibcKeeper.TransferRewardTokensHostToTrade(s.Ctx, transferAmount, invalidRoute) + s.Require().ErrorContains(err, "Failed to submit ICA tx") + + // Delete the epoch tracker and call each function, confirming they both fail + s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.STRIDE_EPOCH) + + _, err = s.App.StakeibcKeeper.BuildHostToTradeTransferMsg(s.Ctx, transferAmount, route) + s.Require().ErrorContains(err, "epoch not found") + err = s.App.StakeibcKeeper.TransferRewardTokensHostToTrade(s.Ctx, transferAmount, route) + s.Require().ErrorContains(err, "epoch not found") +} + +func (s *KeeperTestSuite) TestBuildSwapMsg() { + poolId := uint64(100) + tradeAddress := "trade_address" + + rewardDenom := "ibc/reward_on_trade" + hostDenom := "ibc/host_on_trade" + + baseTradeRoute := types.TradeRoute{ + RewardDenomOnTradeZone: rewardDenom, + HostDenomOnTradeZone: hostDenom, + + TradeAccount: types.ICAAccount{ + Address: tradeAddress, + }, + + TradeConfig: types.TradeConfig{ + PoolId: poolId, + }, + } + + testCases := []struct { + name string + price sdk.Dec + maxAllowedSwapLoss sdk.Dec + minSwapAmount sdkmath.Int + maxSwapAmount sdkmath.Int + rewardAmount sdkmath.Int + expectedTradeAmount sdkmath.Int + expectedMinOut sdkmath.Int + expectedError string + }{ + { + // Reward Amount: 100, Min: 0, Max: 200 => Trade Amount: 100 + // Price: 1, Slippage: 5% => Min Out: 95 + name: "swap 1", + price: sdk.MustNewDecFromStr("1.0"), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0.05"), + + maxSwapAmount: sdkmath.NewInt(200), + rewardAmount: sdkmath.NewInt(100), + expectedTradeAmount: sdkmath.NewInt(100), + + expectedMinOut: sdkmath.NewInt(95), + }, + { + // Reward Amount: 100, Min: 0, Max: 200 => Trade Amount: 100 + // Price: 0.70, Slippage: 10% => Min Out: 100 * 0.70 * 0.9 = 63 + name: "swap 2", + price: sdk.MustNewDecFromStr("0.70"), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0.10"), + + maxSwapAmount: sdkmath.NewInt(200), + rewardAmount: sdkmath.NewInt(100), + expectedTradeAmount: sdkmath.NewInt(100), + + expectedMinOut: sdkmath.NewInt(63), + }, + { + // Reward Amount: 100, Min: 0, Max: 200 => Trade Amount: 100 + // Price: 1.80, Slippage: 15% => Min Out: 100 * 1.8 * 0.85 = 153 + name: "swap 3", + price: sdk.MustNewDecFromStr("1.8"), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0.15"), + + maxSwapAmount: sdkmath.NewInt(200), + rewardAmount: sdkmath.NewInt(100), + expectedTradeAmount: sdkmath.NewInt(100), + + expectedMinOut: sdkmath.NewInt(153), + }, + { + // Reward Amount: 200, Min: 0, Max: 100 => Trade Amount: 100 + // Price: 1, Slippage: 5% => Min Out: 95 + name: "capped by max swap amount", + price: sdk.MustNewDecFromStr("1.0"), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0.05"), + + maxSwapAmount: sdkmath.NewInt(100), + rewardAmount: sdkmath.NewInt(200), + expectedTradeAmount: sdkmath.NewInt(100), + + expectedMinOut: sdkmath.NewInt(95), + }, + { + // Reward Amount: 100, Min: 0, Max: 200 => Trade Amount: 100 + // Price: 1, Slippage: 5.001% => Min Out: 94.999 => truncated to 94 + name: "int truncation in min out caused by decimal max swap allowed", + price: sdk.MustNewDecFromStr("1.0"), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0.05001"), + + maxSwapAmount: sdkmath.NewInt(200), + rewardAmount: sdkmath.NewInt(100), + expectedTradeAmount: sdkmath.NewInt(100), + + expectedMinOut: sdkmath.NewInt(94), + }, + { + // Reward Amount: 100, Min: 0, Max: 200 => Trade Amount: 100 + // Price: 0.9998, Slippage: 10% => Min Out: 89.991 => truncated to 89 + name: "int truncation in min out caused by decimal price", + price: sdk.MustNewDecFromStr("0.9998"), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0.10"), + + maxSwapAmount: sdkmath.NewInt(200), + rewardAmount: sdkmath.NewInt(100), + expectedTradeAmount: sdkmath.NewInt(100), + + expectedMinOut: sdkmath.NewInt(89), + }, + { + // Reward Amount: 89234, Min: 0, Max: 23424 => Trade Amount: 23424 + // Price: 15.234323, Slippage: 9.234329% + // => Min Out: 23424 * 15.234323 * 0.90765671 = 323896.19 => truncates to 323896 + name: "int truncation from random numbers", + price: sdk.MustNewDecFromStr("15.234323"), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0.09234329"), + + maxSwapAmount: sdkmath.NewInt(23424), + rewardAmount: sdkmath.NewInt(89234), + expectedTradeAmount: sdkmath.NewInt(23424), + + expectedMinOut: sdkmath.NewInt(323896), + }, + { + // Missing price + name: "missing price error", + price: sdk.ZeroDec(), + maxAllowedSwapLoss: sdk.MustNewDecFromStr("0"), + + maxSwapAmount: sdkmath.NewInt(0), + rewardAmount: sdkmath.NewInt(0), + expectedTradeAmount: sdkmath.NewInt(0), + expectedMinOut: sdkmath.NewInt(0), + + expectedError: "Price not found for pool", + }, + } + + for _, tc := range testCases { + route := baseTradeRoute + + route.TradeConfig.SwapPrice = tc.price + route.TradeConfig.MinSwapAmount = tc.minSwapAmount + route.TradeConfig.MaxSwapAmount = tc.maxSwapAmount + route.TradeConfig.MaxAllowedSwapLossRate = tc.maxAllowedSwapLoss + + msg, err := s.App.StakeibcKeeper.BuildSwapMsg(tc.rewardAmount, route) + + if tc.expectedError != "" { + s.Require().ErrorContains(err, tc.expectedError, "%s - error expected", tc.name) + continue + } + s.Require().Equal(tradeAddress, msg.Sender, "%s - sender", tc.name) + s.Require().Equal(poolId, msg.Routes[0].PoolId, "%s - pool id", tc.name) + + s.Require().Equal(hostDenom, msg.Routes[0].TokenOutDenom, "%s - token out denom", tc.name) + s.Require().Equal(rewardDenom, msg.TokenIn.Denom, "%s - token in denom", tc.name) + + s.Require().Equal(tc.expectedTradeAmount.Int64(), msg.TokenIn.Amount.Int64(), "%s - token in amount", tc.name) + s.Require().Equal(tc.expectedMinOut.Int64(), msg.TokenOutMinAmount.Int64(), "%s - min token out", tc.name) + } +} + +func (s *KeeperTestSuite) TestSwapRewardTokens() { + // Create an ICA channel for the transfer submission + owner := types.FormatICAAccountOwner(HostChainId, types.ICAAccountType_CONVERTER_TRADE) + channelId, portId := s.CreateICAChannel(owner) + + minSwapAmount := sdkmath.NewInt(10) + rewardAmount := sdkmath.NewInt(100) + + route := types.TradeRoute{ + RewardDenomOnTradeZone: "ibc/reward_on_trade", + HostDenomOnTradeZone: "ibc/host_on_trade", + + TradeAccount: types.ICAAccount{ + Address: "trade_address", + ConnectionId: ibctesting.FirstConnectionID, + }, + + TradeConfig: types.TradeConfig{ + PoolId: 100, + SwapPrice: sdk.OneDec(), + MinSwapAmount: minSwapAmount, + MaxSwapAmount: sdkmath.NewInt(1000), + MaxAllowedSwapLossRate: sdk.MustNewDecFromStr("0.1"), + }, + } + + // Create an epoch tracker to dictate the timeout + s.CreateStrideEpochForICATimeout(time.Minute) // arbitrary timeout time + + // Execute the swap and confirm the sequence number increments + startSequence := s.MustGetNextSequenceNumber(portId, channelId) + + err := s.App.StakeibcKeeper.SwapRewardTokens(s.Ctx, rewardAmount, route) + s.Require().NoError(err, "no error expected when submitting swap") + + sequenceAfterSwap := s.MustGetNextSequenceNumber(portId, channelId) + s.Require().Equal(startSequence+1, sequenceAfterSwap, "sequence number should have incremented") + + // Attempt to call the function again with an swap amount below the min, + // it should not submit an ICA + invalidSwapAmount := minSwapAmount.Sub(sdkmath.OneInt()) + err = s.App.StakeibcKeeper.SwapRewardTokens(s.Ctx, invalidSwapAmount, route) + s.Require().NoError(err, "no error expected when submitting transfer with amount below minimum") + + endSequence := s.MustGetNextSequenceNumber(portId, channelId) + s.Require().Equal(sequenceAfterSwap, endSequence, "sequence number should NOT have incremented") + + // Remove the connection ID so the ICA fails + invalidRoute := route + invalidRoute.TradeAccount.ConnectionId = "" + err = s.App.StakeibcKeeper.SwapRewardTokens(s.Ctx, rewardAmount, invalidRoute) + s.Require().ErrorContains(err, "Failed to submit ICA tx") + + // Delete the epoch tracker and confirm the swap fails + s.App.StakeibcKeeper.RemoveEpochTracker(s.Ctx, epochtypes.STRIDE_EPOCH) + err = s.App.StakeibcKeeper.SwapRewardTokens(s.Ctx, rewardAmount, route) + s.Require().ErrorContains(err, "epoch not found") +}