diff --git a/x/swap/alias.go b/x/swap/alias.go index bff602eb..a0fce94f 100644 --- a/x/swap/alias.go +++ b/x/swap/alias.go @@ -8,20 +8,26 @@ import ( ) const ( - AttributeKeyDepositor = types.AttributeKeyDepositor - AttributeKeyOwner = types.AttributeKeyOwner - AttributeKeyPoolID = types.AttributeKeyPoolID - AttributeKeyShares = types.AttributeKeyShares - AttributeValueCategory = types.AttributeValueCategory - DefaultParamspace = types.DefaultParamspace - EventTypeSwapDeposit = types.EventTypeSwapDeposit - EventTypeSwapWithdraw = types.EventTypeSwapWithdraw - ModuleAccountName = types.ModuleAccountName - ModuleName = types.ModuleName - QuerierRoute = types.QuerierRoute - QueryGetParams = types.QueryGetParams - RouterKey = types.RouterKey - StoreKey = types.StoreKey + AttributeKeyDepositor = types.AttributeKeyDepositor + AttributeKeyExactDirection = types.AttributeKeyExactDirection + AttributeKeyFeePaid = types.AttributeKeyFeePaid + AttributeKeyOwner = types.AttributeKeyOwner + AttributeKeyPoolID = types.AttributeKeyPoolID + AttributeKeyRequester = types.AttributeKeyRequester + AttributeKeyShares = types.AttributeKeyShares + AttributeKeySwapInput = types.AttributeKeySwapInput + AttributeKeySwapOutput = types.AttributeKeySwapOutput + AttributeValueCategory = types.AttributeValueCategory + DefaultParamspace = types.DefaultParamspace + EventTypeSwapDeposit = types.EventTypeSwapDeposit + EventTypeSwapTrade = types.EventTypeSwapTrade + EventTypeSwapWithdraw = types.EventTypeSwapWithdraw + ModuleAccountName = types.ModuleAccountName + ModuleName = types.ModuleName + QuerierRoute = types.QuerierRoute + QueryGetParams = types.QueryGetParams + RouterKey = types.RouterKey + StoreKey = types.StoreKey ) var ( @@ -39,6 +45,8 @@ var ( NewDenominatedPoolWithExistingShares = types.NewDenominatedPoolWithExistingShares NewGenesisState = types.NewGenesisState NewMsgDeposit = types.NewMsgDeposit + NewMsgSwapExactForTokens = types.NewMsgSwapExactForTokens + NewMsgSwapForExactTokens = types.NewMsgSwapForExactTokens NewMsgWithdraw = types.NewMsgWithdraw NewParams = types.NewParams NewPoolRecord = types.NewPoolRecord @@ -72,18 +80,20 @@ var ( ) type ( - Keeper = keeper.Keeper - AccountKeeper = types.AccountKeeper - AllowedPool = types.AllowedPool - AllowedPools = types.AllowedPools - BasePool = types.BasePool - DenominatedPool = types.DenominatedPool - GenesisState = types.GenesisState - MsgDeposit = types.MsgDeposit - MsgWithDeadline = types.MsgWithDeadline - MsgWithdraw = types.MsgWithdraw - Params = types.Params - PoolRecord = types.PoolRecord - ShareRecord = types.ShareRecord - SupplyKeeper = types.SupplyKeeper + Keeper = keeper.Keeper + AccountKeeper = types.AccountKeeper + AllowedPool = types.AllowedPool + AllowedPools = types.AllowedPools + BasePool = types.BasePool + DenominatedPool = types.DenominatedPool + GenesisState = types.GenesisState + MsgDeposit = types.MsgDeposit + MsgSwapExactForTokens = types.MsgSwapExactForTokens + MsgSwapForExactTokens = types.MsgSwapForExactTokens + MsgWithDeadline = types.MsgWithDeadline + MsgWithdraw = types.MsgWithdraw + Params = types.Params + PoolRecord = types.PoolRecord + ShareRecord = types.ShareRecord + SupplyKeeper = types.SupplyKeeper ) diff --git a/x/swap/client/cli/tx.go b/x/swap/client/cli/tx.go index 42768be0..0127912f 100644 --- a/x/swap/client/cli/tx.go +++ b/x/swap/client/cli/tx.go @@ -32,6 +32,8 @@ func GetTxCmd(cdc *codec.Codec) *cobra.Command { swapTxCmd.AddCommand(flags.PostCommands( getCmdDeposit(cdc), getCmdWithdraw(cdc), + getCmdSwapExactForTokens(cdc), + getCmdSwapForExactTokens(cdc), )...) return swapTxCmd @@ -123,3 +125,89 @@ func getCmdWithdraw(cdc *codec.Codec) *cobra.Command { }, } } + +func getCmdSwapExactForTokens(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "swap-exact-for-tokens [exactCoinA] [coinB] [slippage] [deadline]", + Short: "swap an exact amount of token a for token b", + Example: fmt.Sprintf( + `%s tx %s swap-exact-for-tokens 1000000ukava 5000000usdx 0.01 1624224736 --from `, version.ClientName, types.ModuleName, + ), + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + + exactTokenA, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + tokenB, err := sdk.ParseCoin(args[1]) + if err != nil { + return err + } + + slippage, err := sdk.NewDecFromStr(args[2]) + if err != nil { + return err + } + + deadline, err := strconv.ParseInt(args[3], 10, 64) + if err != nil { + return err + } + + msg := types.NewMsgSwapExactForTokens(cliCtx.GetFromAddress(), exactTokenA, tokenB, slippage, deadline) + if err := msg.ValidateBasic(); err != nil { + return err + } + + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} + +func getCmdSwapForExactTokens(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "swap-for-exact-tokens [coinA] [exactCoinB] [slippage] [deadline]", + Short: "swap token a for exact amount of token b", + Example: fmt.Sprintf( + `%s tx %s swap-for-exact-tokens 1000000ukava 5000000usdx 0.01 1624224736 --from `, version.ClientName, types.ModuleName, + ), + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + + tokenA, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + exactTokenB, err := sdk.ParseCoin(args[1]) + if err != nil { + return err + } + + slippage, err := sdk.NewDecFromStr(args[2]) + if err != nil { + return err + } + + deadline, err := strconv.ParseInt(args[3], 10, 64) + if err != nil { + return err + } + + msg := types.NewMsgSwapForExactTokens(cliCtx.GetFromAddress(), tokenA, exactTokenB, slippage, deadline) + if err := msg.ValidateBasic(); err != nil { + return err + } + + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/swap/client/rest/query.go b/x/swap/client/rest/query.go index 037bcfeb..0158140e 100644 --- a/x/swap/client/rest/query.go +++ b/x/swap/client/rest/query.go @@ -104,7 +104,7 @@ func queryPoolHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { poolName = strings.TrimSpace(x) } if len(poolName) == 0 { - rest.WriteErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("must specify pool param")) + rest.WriteErrorResponse(w, http.StatusBadRequest, "must specify pool param") return } diff --git a/x/swap/client/rest/rest.go b/x/swap/client/rest/rest.go index 96a5a2b9..ff3144a8 100644 --- a/x/swap/client/rest/rest.go +++ b/x/swap/client/rest/rest.go @@ -40,3 +40,23 @@ type PostCreateWithdrawReq struct { MinTokenB sdk.Coin `json:"token_b" yaml:"token_b"` Deadline int64 `json:"deadline" yaml:"deadline"` } + +// PostCreateSwapExactForTokensReq trades an exact coinA for coinB +type PostCreateSwapExactForTokensReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Requester sdk.AccAddress `json:"requester" yaml:"requester"` + ExactTokenA sdk.Coin `json:"exact_token_a" yaml:"exact_token_a"` + TokenB sdk.Coin `json:"token_b" yaml:"token_b"` + Slippage sdk.Dec `json:"slippage" yaml:"slippage"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} + +// PostCreateSwapForExactTokensReq trades an exact coinA for coinB +type PostCreateSwapForExactTokensReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Requester sdk.AccAddress `json:"requester" yaml:"requester"` + TokenA sdk.Coin `json:"exact_token_a" yaml:"exact_token_a"` + ExactTokenB sdk.Coin `json:"token_b" yaml:"token_b"` + Slippage sdk.Dec `json:"slippage" yaml:"slippage"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} diff --git a/x/swap/client/rest/tx.go b/x/swap/client/rest/tx.go index 580e6255..f8a5d475 100644 --- a/x/swap/client/rest/tx.go +++ b/x/swap/client/rest/tx.go @@ -17,6 +17,8 @@ import ( func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { r.HandleFunc(fmt.Sprintf("/%s/deposit", types.ModuleName), postDepositHandlerFn(cliCtx)).Methods("POST") r.HandleFunc(fmt.Sprintf("/%s/withdraw", types.ModuleName), postWithdrawHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/%s/swapExactForTokens", types.ModuleName), postSwapExactForTokensHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/%s/swapForExactTokens", types.ModuleName), postSwapForExactTokensHandlerFn(cliCtx)).Methods("POST") } func postDepositHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { @@ -60,3 +62,45 @@ func postWithdrawHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) } } + +func postSwapExactForTokensHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Decode POST request body + var req PostCreateSwapExactForTokensReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + msg := types.NewMsgSwapExactForTokens(req.Requester, req.ExactTokenA, req.TokenB, req.Slippage, req.Deadline) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} + +func postSwapForExactTokensHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Decode POST request body + var req PostCreateSwapForExactTokensReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + msg := types.NewMsgSwapForExactTokens(req.Requester, req.TokenA, req.ExactTokenB, req.Slippage, req.Deadline) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} diff --git a/x/swap/handler.go b/x/swap/handler.go index 8776c421..b1243b14 100644 --- a/x/swap/handler.go +++ b/x/swap/handler.go @@ -24,6 +24,10 @@ func NewHandler(k Keeper) sdk.Handler { return handleMsgDeposit(ctx, k, msg) case types.MsgWithdraw: return handleMsgWithdraw(ctx, k, msg) + case types.MsgSwapExactForTokens: + return handleMsgSwapExactForTokens(ctx, k, msg) + case types.MsgSwapForExactTokens: + return handleMsgSwapForExactTokens(ctx, k, msg) default: return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unrecognized %s message type: %T", ModuleName, msg) } @@ -35,17 +39,7 @@ func handleMsgDeposit(ctx sdk.Context, k keeper.Keeper, msg types.MsgDeposit) (* return nil, err } - ctx.EventManager().EmitEvent( - sdk.NewEvent( - sdk.EventTypeMessage, - sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), - sdk.NewAttribute(sdk.AttributeKeySender, msg.Depositor.String()), - ), - ) - - return &sdk.Result{ - Events: ctx.EventManager().Events(), - }, nil + return resultWithMsgSender(ctx, msg.Depositor), nil } func handleMsgWithdraw(ctx sdk.Context, k keeper.Keeper, msg types.MsgWithdraw) (*sdk.Result, error) { @@ -53,15 +47,35 @@ func handleMsgWithdraw(ctx sdk.Context, k keeper.Keeper, msg types.MsgWithdraw) return nil, err } + return resultWithMsgSender(ctx, msg.From), nil +} + +func handleMsgSwapExactForTokens(ctx sdk.Context, k keeper.Keeper, msg types.MsgSwapExactForTokens) (*sdk.Result, error) { + if err := k.SwapExactForTokens(ctx, msg.Requester, msg.ExactTokenA, msg.TokenB, msg.Slippage); err != nil { + return nil, err + } + + return resultWithMsgSender(ctx, msg.Requester), nil +} + +func handleMsgSwapForExactTokens(ctx sdk.Context, k keeper.Keeper, msg types.MsgSwapForExactTokens) (*sdk.Result, error) { + if err := k.SwapForExactTokens(ctx, msg.Requester, msg.TokenA, msg.ExactTokenB, msg.Slippage); err != nil { + return nil, err + } + + return resultWithMsgSender(ctx, msg.Requester), nil +} + +func resultWithMsgSender(ctx sdk.Context, sender sdk.AccAddress) *sdk.Result { ctx.EventManager().EmitEvent( sdk.NewEvent( sdk.EventTypeMessage, sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), - sdk.NewAttribute(sdk.AttributeKeySender, msg.From.String()), + sdk.NewAttribute(sdk.AttributeKeySender, sender.String()), ), ) return &sdk.Result{ Events: ctx.EventManager().Events(), - }, nil + } } diff --git a/x/swap/handler_test.go b/x/swap/handler_test.go index 149f1133..00ffc8a4 100644 --- a/x/swap/handler_test.go +++ b/x/swap/handler_test.go @@ -349,6 +349,226 @@ func (suite *handlerTestSuite) TestWithdraw_DeadlineExceeded() { suite.Nil(res) } +func (suite *handlerTestSuite) TestSwapExactForTokens() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + swapInput := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + swapMsg := swap.NewMsgSwapExactForTokens( + requester.GetAddress(), + swapInput, + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + sdk.MustNewDecFromStr("0.01"), + time.Now().Add(10*time.Minute).Unix(), + ) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + res, err := suite.handler(ctx, swapMsg) + suite.Require().NoError(err) + + expectedSwapOutput := sdk.NewCoin("usdx", sdk.NewInt(4980034)) + + suite.AccountBalanceEqual(requester, balance.Sub(sdk.NewCoins(swapInput)).Add(expectedSwapOutput)) + suite.ModuleAccountBalanceEqual(reserves.Add(swapInput).Sub(sdk.NewCoins(expectedSwapOutput))) + suite.PoolLiquidityEqual(reserves.Add(swapInput).Sub(sdk.NewCoins(expectedSwapOutput))) + + suite.EventsContains(res.Events, sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, swap.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, requester.GetAddress().String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, swapModuleAccountAddress.String()), + sdk.NewAttribute(bank.AttributeKeySender, requester.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, swapInput.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, requester.GetAddress().String()), + sdk.NewAttribute(bank.AttributeKeySender, swapModuleAccountAddress.String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, expectedSwapOutput.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + swap.EventTypeSwapTrade, + sdk.NewAttribute(swap.AttributeKeyPoolID, swap.PoolID("ukava", "usdx")), + sdk.NewAttribute(swap.AttributeKeyRequester, requester.GetAddress().String()), + sdk.NewAttribute(swap.AttributeKeySwapInput, swapInput.String()), + sdk.NewAttribute(swap.AttributeKeySwapOutput, expectedSwapOutput.String()), + sdk.NewAttribute(swap.AttributeKeyFeePaid, "3000ukava"), + sdk.NewAttribute(swap.AttributeKeyExactDirection, "input"), + )) +} + +func (suite *handlerTestSuite) TestSwapExactForTokens_SlippageFailure() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + swapInput := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + swapMsg := swap.NewMsgSwapExactForTokens( + requester.GetAddress(), + swapInput, + sdk.NewCoin("usdx", sdk.NewInt(5030338)), + sdk.MustNewDecFromStr("0.01"), + time.Now().Add(10*time.Minute).Unix(), + ) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + res, err := suite.handler(ctx, swapMsg) + suite.EqualError(err, "slippage exceeded: slippage 0.010000123252155223 > limit 0.010000000000000000") + suite.Nil(res) +} + +func (suite *handlerTestSuite) TestSwapExactForTokens_DeadlineExceeded() { + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + ) + requester := suite.CreateAccount(balance) + + swapMsg := swap.NewMsgSwapExactForTokens( + requester.GetAddress(), + sdk.NewCoin("ukava", sdk.NewInt(5e6)), + sdk.NewCoin("usdx", sdk.NewInt(25e5)), + sdk.MustNewDecFromStr("0.01"), + suite.Ctx.BlockTime().Add(-1*time.Second).Unix(), + ) + + res, err := suite.handler(suite.Ctx, swapMsg) + suite.EqualError(err, fmt.Sprintf("deadline exceeded: block time %d >= deadline %d", suite.Ctx.BlockTime().Unix(), swapMsg.GetDeadline().Unix())) + suite.Nil(res) +} + +func (suite *handlerTestSuite) TestSwapForExactTokens() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + swapOutput := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + swapMsg := swap.NewMsgSwapForExactTokens( + requester.GetAddress(), + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + swapOutput, + sdk.MustNewDecFromStr("0.01"), + time.Now().Add(10*time.Minute).Unix(), + ) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + res, err := suite.handler(ctx, swapMsg) + suite.Require().NoError(err) + + expectedSwapInput := sdk.NewCoin("ukava", sdk.NewInt(1004015)) + + suite.AccountBalanceEqual(requester, balance.Sub(sdk.NewCoins(expectedSwapInput)).Add(swapOutput)) + suite.ModuleAccountBalanceEqual(reserves.Add(expectedSwapInput).Sub(sdk.NewCoins(swapOutput))) + suite.PoolLiquidityEqual(reserves.Add(expectedSwapInput).Sub(sdk.NewCoins(swapOutput))) + + suite.EventsContains(res.Events, sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, swap.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, requester.GetAddress().String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, swapModuleAccountAddress.String()), + sdk.NewAttribute(bank.AttributeKeySender, requester.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, expectedSwapInput.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, requester.GetAddress().String()), + sdk.NewAttribute(bank.AttributeKeySender, swapModuleAccountAddress.String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, swapOutput.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + swap.EventTypeSwapTrade, + sdk.NewAttribute(swap.AttributeKeyPoolID, swap.PoolID("ukava", "usdx")), + sdk.NewAttribute(swap.AttributeKeyRequester, requester.GetAddress().String()), + sdk.NewAttribute(swap.AttributeKeySwapInput, expectedSwapInput.String()), + sdk.NewAttribute(swap.AttributeKeySwapOutput, swapOutput.String()), + sdk.NewAttribute(swap.AttributeKeyFeePaid, "3013ukava"), + sdk.NewAttribute(swap.AttributeKeyExactDirection, "output"), + )) +} + +func (suite *handlerTestSuite) TestSwapForExactTokens_SlippageFailure() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + swapOutput := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + swapMsg := swap.NewMsgSwapForExactTokens( + requester.GetAddress(), + sdk.NewCoin("ukava", sdk.NewInt(990991)), + swapOutput, + sdk.MustNewDecFromStr("0.01"), + time.Now().Add(10*time.Minute).Unix(), + ) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + res, err := suite.handler(ctx, swapMsg) + suite.EqualError(err, "slippage exceeded: slippage 0.010000979019022939 > limit 0.010000000000000000") + suite.Nil(res) +} + +func (suite *handlerTestSuite) TestSwapForExactTokens_DeadlineExceeded() { + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + ) + requester := suite.CreateAccount(balance) + + swapMsg := swap.NewMsgSwapForExactTokens( + requester.GetAddress(), + sdk.NewCoin("ukava", sdk.NewInt(5e6)), + sdk.NewCoin("usdx", sdk.NewInt(25e5)), + sdk.MustNewDecFromStr("0.01"), + suite.Ctx.BlockTime().Add(-1*time.Second).Unix(), + ) + + res, err := suite.handler(suite.Ctx, swapMsg) + suite.EqualError(err, fmt.Sprintf("deadline exceeded: block time %d >= deadline %d", suite.Ctx.BlockTime().Unix(), swapMsg.GetDeadline().Unix())) + suite.Nil(res) +} + func (suite *handlerTestSuite) TestInvalidMsg() { res, err := suite.handler(suite.Ctx, sdk.NewTestMsg()) suite.Nil(res) diff --git a/x/swap/keeper/keeper.go b/x/swap/keeper/keeper.go index 5384fb0b..e115f9ee 100644 --- a/x/swap/keeper/keeper.go +++ b/x/swap/keeper/keeper.go @@ -61,6 +61,11 @@ func (k Keeper) SetParams(ctx sdk.Context, params types.Params) { k.paramSubspace.SetParamSet(ctx, ¶ms) } +// GetSwapFee returns the swap fee set in the module parameters +func (k Keeper) GetSwapFee(ctx sdk.Context) sdk.Dec { + return k.GetParams(ctx).SwapFee +} + // GetPool retrieves a pool record from the store func (k Keeper) GetPool(ctx sdk.Context, poolID string) (types.PoolRecord, bool) { store := prefix.NewStore(ctx.KVStore(k.key), types.PoolKeyPrefix) @@ -174,7 +179,7 @@ func (k Keeper) IterateDepositorSharesByOwner(ctx sdk.Context, owner sdk.AccAddr } } -// GetAllDepositorShares returns all depositor share records from the store for a specific address +// GetAllDepositorSharesByOwner returns all depositor share records from the store for a specific address func (k Keeper) GetAllDepositorSharesByOwner(ctx sdk.Context, owner sdk.AccAddress) (records types.ShareRecords) { k.IterateDepositorSharesByOwner(ctx, owner, func(record types.ShareRecord) bool { records = append(records, record) diff --git a/x/swap/keeper/keeper_test.go b/x/swap/keeper/keeper_test.go index 1405e321..90df6d7c 100644 --- a/x/swap/keeper/keeper_test.go +++ b/x/swap/keeper/keeper_test.go @@ -69,6 +69,17 @@ func (suite keeperTestSuite) TestParams_Persistance() { suite.Equal(keeper.GetParams(suite.Ctx), params) } +func (suite keeperTestSuite) TestParams_GetSwapFee() { + keeper := suite.Keeper + + params := types.Params{ + SwapFee: sdk.MustNewDecFromStr("0.00333"), + } + keeper.SetParams(suite.Ctx, params) + + suite.Equal(keeper.GetSwapFee(suite.Ctx), params.SwapFee) +} + func (suite *keeperTestSuite) TestPool_Persistance() { reserves := sdk.NewCoins( sdk.NewCoin("ukava", sdk.NewInt(10e6)), diff --git a/x/swap/keeper/swap.go b/x/swap/keeper/swap.go new file mode 100644 index 00000000..bae0f3c1 --- /dev/null +++ b/x/swap/keeper/swap.go @@ -0,0 +1,122 @@ +package keeper + +import ( + "fmt" + + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// SwapExactForTokens swaps an exact coin a input for a coin b output +func (k *Keeper) SwapExactForTokens(ctx sdk.Context, requester sdk.AccAddress, exactCoinA, coinB sdk.Coin, slippageLimit sdk.Dec) error { + poolID, pool, err := k.loadPool(ctx, exactCoinA.Denom, coinB.Denom) + if err != nil { + return err + } + + swapOutput, feePaid := pool.SwapWithExactInput(exactCoinA, k.GetSwapFee(ctx)) + if swapOutput.IsZero() { + return sdkerrors.Wrapf(types.ErrInsufficientLiquidity, "swap output rounds to zero, increase input amount") + } + + priceChange := swapOutput.Amount.ToDec().Quo(coinB.Amount.ToDec()) + if err := k.assertSlippageWithinLimit(priceChange, slippageLimit); err != nil { + return err + } + + if err := k.commitSwap(ctx, poolID, pool, requester, exactCoinA, swapOutput, feePaid, "input"); err != nil { + return err + } + + return nil +} + +// SwapForExactTokens swaps a coin a input for an exact coin b output +func (k *Keeper) SwapForExactTokens(ctx sdk.Context, requester sdk.AccAddress, coinA, exactCoinB sdk.Coin, slippageLimit sdk.Dec) error { + poolID, pool, err := k.loadPool(ctx, coinA.Denom, exactCoinB.Denom) + if err != nil { + return err + } + + if exactCoinB.Amount.GTE(pool.Reserves().AmountOf(exactCoinB.Denom)) { + return sdkerrors.Wrapf( + types.ErrInsufficientLiquidity, + "output %s >= pool reserves %s", exactCoinB.Amount.String(), pool.Reserves().AmountOf(exactCoinB.Denom).String(), + ) + } + + swapInput, feePaid := pool.SwapWithExactOutput(exactCoinB, k.GetSwapFee(ctx)) + + priceChange := coinA.Amount.ToDec().Quo(swapInput.Sub(feePaid).Amount.ToDec()) + if err := k.assertSlippageWithinLimit(priceChange, slippageLimit); err != nil { + return err + } + + if err := k.commitSwap(ctx, poolID, pool, requester, swapInput, exactCoinB, feePaid, "output"); err != nil { + return err + } + + return nil +} + +func (k Keeper) loadPool(ctx sdk.Context, denomA string, denomB string) (string, *types.DenominatedPool, error) { + poolID := types.PoolID(denomA, denomB) + + poolRecord, found := k.GetPool(ctx, poolID) + if !found { + return poolID, nil, sdkerrors.Wrapf(types.ErrInvalidPool, "pool %s not found", poolID) + } + + pool, err := types.NewDenominatedPoolWithExistingShares(poolRecord.Reserves(), poolRecord.TotalShares) + if err != nil { + panic(fmt.Sprintf("invalid pool %s: %s", poolID, err)) + } + + return poolID, pool, nil +} + +func (k Keeper) assertSlippageWithinLimit(priceChange sdk.Dec, slippageLimit sdk.Dec) error { + slippage := sdk.OneDec().Sub(priceChange) + if slippage.GT(slippageLimit) { + return sdkerrors.Wrapf(types.ErrSlippageExceeded, "slippage %s > limit %s", slippage, slippageLimit) + } + + return nil +} + +func (k Keeper) commitSwap( + ctx sdk.Context, + poolID string, + pool *types.DenominatedPool, + requester sdk.AccAddress, + swapInput sdk.Coin, + swapOutput sdk.Coin, + feePaid sdk.Coin, + exactDirection string, +) error { + k.SetPool(ctx, types.NewPoolRecord(pool)) + + if err := k.supplyKeeper.SendCoinsFromAccountToModule(ctx, requester, types.ModuleAccountName, sdk.NewCoins(swapInput)); err != nil { + return err + } + + if err := k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, requester, sdk.NewCoins(swapOutput)); err != nil { + panic(err) + } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeSwapTrade, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyRequester, requester.String()), + sdk.NewAttribute(types.AttributeKeySwapInput, swapInput.String()), + sdk.NewAttribute(types.AttributeKeySwapOutput, swapOutput.String()), + sdk.NewAttribute(types.AttributeKeyFeePaid, feePaid.String()), + sdk.NewAttribute(types.AttributeKeyExactDirection, exactDirection), + ), + ) + + return nil +} diff --git a/x/swap/keeper/swap_test.go b/x/swap/keeper/swap_test.go new file mode 100644 index 00000000..947605c8 --- /dev/null +++ b/x/swap/keeper/swap_test.go @@ -0,0 +1,630 @@ +package keeper_test + +import ( + "errors" + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/kava-labs/kava/x/swap/types" + abci "github.com/tendermint/tendermint/abci/types" + tmtime "github.com/tendermint/tendermint/types/time" +) + +func (suite *keeperTestSuite) TestSwapExactForTokens() { + suite.Keeper.SetParams(suite.Ctx, types.Params{ + SwapFee: sdk.MustNewDecFromStr("0.0025"), + }) + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + err := suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + suite.Require().NoError(err) + + expectedOutput := sdk.NewCoin("usdx", sdk.NewInt(4982529)) + + suite.AccountBalanceEqual(requester, balance.Sub(sdk.NewCoins(coinA)).Add(expectedOutput)) + suite.ModuleAccountBalanceEqual(reserves.Add(coinA).Sub(sdk.NewCoins(expectedOutput))) + suite.PoolLiquidityEqual(reserves.Add(coinA).Sub(sdk.NewCoins(expectedOutput))) + + suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent( + types.EventTypeSwapTrade, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyRequester, requester.GetAddress().String()), + sdk.NewAttribute(types.AttributeKeySwapInput, coinA.String()), + sdk.NewAttribute(types.AttributeKeySwapOutput, expectedOutput.String()), + sdk.NewAttribute(types.AttributeKeyFeePaid, "2500ukava"), + sdk.NewAttribute(types.AttributeKeyExactDirection, "input"), + )) +} + +func (suite *keeperTestSuite) TestSwapExactForTokens_OutputGreaterThanZero() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + balance := sdk.NewCoins( + sdk.NewCoin("usdx", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("usdx", sdk.NewInt(5)) + coinB := sdk.NewCoin("ukava", sdk.NewInt(1)) + + err := suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("1")) + suite.EqualError(err, "insufficient liquidity: swap output rounds to zero, increase input amount") +} + +func (suite *keeperTestSuite) TestSwapExactForTokens_Slippage() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(500e6)), + ) + totalShares := sdk.NewInt(30e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + testCases := []struct { + coinA sdk.Coin + coinB sdk.Coin + slippage sdk.Dec + fee sdk.Dec + shouldFail bool + }{ + // positive slippage OK + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(2e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(50e6)), sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(50e6)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + // positive slippage with zero slippage OK + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(2e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(50e6)), sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(50e6)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + // exact zero slippage OK + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4950495)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4935790)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4705299)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(990099)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(987158)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(941059)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), false}, + // slippage failure, zero slippage tolerance + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4950496)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4935793)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4705300)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(990100)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(987159)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(941060)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), true}, + // slippage failure, 1 percent slippage + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(5000501)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4985647)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4752828)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(1000101)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(997130)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(950565)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), true}, + // slippage OK, 1 percent slippage + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(5000500)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4985646)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4752827)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(1000100)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(997129)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(950564)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), false}, + } + + for _, tc := range testCases { + suite.Run(fmt.Sprintf("coinA=%s coinB=%s slippage=%s fee=%s", tc.coinA, tc.coinB, tc.slippage, tc.fee), func() { + suite.SetupTest() + suite.Keeper.SetParams(suite.Ctx, types.Params{ + SwapFee: tc.fee, + }) + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(500e6)), + ) + totalShares := sdk.NewInt(30e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(100e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + err := suite.Keeper.SwapExactForTokens(ctx, requester.GetAddress(), tc.coinA, tc.coinB, tc.slippage) + + if tc.shouldFail { + suite.Require().Error(err) + suite.Contains(err.Error(), "slippage exceeded") + } else { + suite.NoError(err) + } + }) + } +} + +func (suite *keeperTestSuite) TestSwapExactForTokens_InsufficientFunds() { + testCases := []struct { + name string + balanceA sdk.Coin + coinA sdk.Coin + coinB sdk.Coin + }{ + {"no ukava balance", sdk.NewCoin("ukava", sdk.ZeroInt()), sdk.NewCoin("ukava", sdk.NewInt(100)), sdk.NewCoin("usdx", sdk.NewInt(500))}, + {"no usdx balance", sdk.NewCoin("usdx", sdk.ZeroInt()), sdk.NewCoin("usdx", sdk.NewInt(500)), sdk.NewCoin("ukava", sdk.NewInt(100))}, + {"low ukava balance", sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("ukava", sdk.NewInt(1000001)), sdk.NewCoin("usdx", sdk.NewInt(5000000))}, + {"low ukava balance", sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("usdx", sdk.NewInt(5000001)), sdk.NewCoin("ukava", sdk.NewInt(1000000))}, + {"large ukava balance difference", sdk.NewCoin("ukava", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6))}, + {"large usdx balance difference", sdk.NewCoin("usdx", sdk.NewInt(500e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6))}, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100000e6)), + sdk.NewCoin("usdx", sdk.NewInt(500000e6)), + ) + totalShares := sdk.NewInt(30000e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + balance := sdk.NewCoins(tc.balanceA) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + err := suite.Keeper.SwapExactForTokens(ctx, requester.GetAddress(), tc.coinA, tc.coinB, sdk.MustNewDecFromStr("0.1")) + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds), fmt.Sprintf("got err %s", err)) + }) + } +} + +func (suite *keeperTestSuite) TestSwapExactForTokens_InsufficientFunds_Vesting() { + testCases := []struct { + name string + balanceA sdk.Coin + vestingA sdk.Coin + coinA sdk.Coin + coinB sdk.Coin + }{ + {"no ukava balance, vesting only", sdk.NewCoin("ukava", sdk.ZeroInt()), sdk.NewCoin("ukava", sdk.NewInt(100)), sdk.NewCoin("ukava", sdk.NewInt(100)), sdk.NewCoin("usdx", sdk.NewInt(500))}, + {"no usdx balance, vesting only", sdk.NewCoin("usdx", sdk.ZeroInt()), sdk.NewCoin("usdx", sdk.NewInt(500)), sdk.NewCoin("usdx", sdk.NewInt(500)), sdk.NewCoin("ukava", sdk.NewInt(100))}, + {"low ukava balance, vesting matches exact", sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("ukava", sdk.NewInt(1)), sdk.NewCoin("ukava", sdk.NewInt(1000001)), sdk.NewCoin("usdx", sdk.NewInt(5000000))}, + {"low ukava balance, vesting matches exact", sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("usdx", sdk.NewInt(1)), sdk.NewCoin("usdx", sdk.NewInt(5000001)), sdk.NewCoin("ukava", sdk.NewInt(1000000))}, + {"large ukava balance difference, vesting covers difference", sdk.NewCoin("ukava", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6))}, + {"large usdx balance difference, vesting covers difference", sdk.NewCoin("usdx", sdk.NewInt(500e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6))}, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100000e6)), + sdk.NewCoin("usdx", sdk.NewInt(500000e6)), + ) + totalShares := sdk.NewInt(30000e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + balance := sdk.NewCoins(tc.balanceA) + vesting := sdk.NewCoins(tc.vestingA) + requester := suite.CreateVestingAccount(balance, vesting) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + err := suite.Keeper.SwapExactForTokens(ctx, requester.GetAddress(), tc.coinA, tc.coinB, sdk.MustNewDecFromStr("0.1")) + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds), fmt.Sprintf("got err %s", err)) + }) + } +} + +func (suite *keeperTestSuite) TestSwapExactForTokens_PoolNotFound() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(3000e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + suite.Keeper.DeletePool(suite.Ctx, poolID) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + err := suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + suite.EqualError(err, "invalid pool: pool ukava/usdx not found") + + err = suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinB, coinA, sdk.MustNewDecFromStr("0.01")) + suite.EqualError(err, "invalid pool: pool ukava/usdx not found") +} + +func (suite *keeperTestSuite) TestSwapExactForTokens_PanicOnInvalidPool() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(3000e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + poolRecord, found := suite.Keeper.GetPool(suite.Ctx, poolID) + suite.Require().True(found, "expected pool record to exist") + + poolRecord.TotalShares = sdk.ZeroInt() + suite.Keeper.SetPool(suite.Ctx, poolRecord) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + suite.PanicsWithValue("invalid pool ukava/usdx: invalid pool: total shares must be greater than zero", func() { + _ = suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + }, "expected invalid pool record to panic") + + suite.PanicsWithValue("invalid pool ukava/usdx: invalid pool: total shares must be greater than zero", func() { + _ = suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinB, coinA, sdk.MustNewDecFromStr("0.01")) + }, "expected invalid pool record to panic") +} + +func (suite *keeperTestSuite) TestSwapExactForTokens_PanicOnInsufficientModuleAccFunds() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(3000e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + suite.RemoveCoinsFromModule(sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + )) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + suite.Panics(func() { + _ = suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + }, "expected panic when module account does not have enough funds") + + suite.Panics(func() { + _ = suite.Keeper.SwapExactForTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + }, "expected panic when module account does not have enough funds") +} + +func (suite *keeperTestSuite) TestSwapForExactTokens() { + suite.Keeper.SetParams(suite.Ctx, types.Params{ + SwapFee: sdk.MustNewDecFromStr("0.0025"), + }) + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + err := suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + suite.Require().NoError(err) + + expectedInput := sdk.NewCoin("ukava", sdk.NewInt(1003511)) + + suite.AccountBalanceEqual(requester, balance.Sub(sdk.NewCoins(expectedInput)).Add(coinB)) + suite.ModuleAccountBalanceEqual(reserves.Add(expectedInput).Sub(sdk.NewCoins(coinB))) + suite.PoolLiquidityEqual(reserves.Add(expectedInput).Sub(sdk.NewCoins(coinB))) + + suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent( + types.EventTypeSwapTrade, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyRequester, requester.GetAddress().String()), + sdk.NewAttribute(types.AttributeKeySwapInput, expectedInput.String()), + sdk.NewAttribute(types.AttributeKeySwapOutput, coinB.String()), + sdk.NewAttribute(types.AttributeKeyFeePaid, "2509ukava"), + sdk.NewAttribute(types.AttributeKeyExactDirection, "output"), + )) +} + +func (suite *keeperTestSuite) TestSwapForExactTokens_OutputLessThanPoolReserves() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(500e6)), + ) + totalShares := sdk.NewInt(300e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + + coinB := sdk.NewCoin("usdx", sdk.NewInt(500e6).Add(sdk.OneInt())) + err := suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + suite.EqualError(err, "insufficient liquidity: output 500000001 >= pool reserves 500000000") + + coinB = sdk.NewCoin("usdx", sdk.NewInt(500e6)) + err = suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + suite.EqualError(err, "insufficient liquidity: output 500000000 >= pool reserves 500000000") +} + +func (suite *keeperTestSuite) TestSwapForExactTokens_Slippage() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(500e6)), + ) + totalShares := sdk.NewInt(30e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + testCases := []struct { + coinA sdk.Coin + coinB sdk.Coin + slippage sdk.Dec + fee sdk.Dec + shouldFail bool + }{ + // positive slippage OK + {sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.0025"), false}, + // positive slippage with zero slippage OK + {sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.0025"), false}, + // exact zero slippage OK + {sdk.NewCoin("ukava", sdk.NewInt(1010102)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1010102)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1010102)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5050506)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5050506)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5050506)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), false}, + // slippage failure, zero slippage tolerance + {sdk.NewCoin("ukava", sdk.NewInt(1010101)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1010101)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1010101)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5050505)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5050505)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5050505)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.ZeroDec(), sdk.MustNewDecFromStr("0.05"), true}, + // slippage failure, 1 percent slippage + {sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), true}, + // slippage OK, 1 percent slippage + {sdk.NewCoin("ukava", sdk.NewInt(1000001)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1000001)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1000001)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5000001)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5000001)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.003"), false}, + {sdk.NewCoin("usdx", sdk.NewInt(5000001)), sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.MustNewDecFromStr("0.01"), sdk.MustNewDecFromStr("0.05"), false}, + } + + for _, tc := range testCases { + suite.Run(fmt.Sprintf("coinA=%s coinB=%s slippage=%s fee=%s", tc.coinA, tc.coinB, tc.slippage, tc.fee), func() { + suite.SetupTest() + suite.Keeper.SetParams(suite.Ctx, types.Params{ + SwapFee: tc.fee, + }) + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(500e6)), + ) + totalShares := sdk.NewInt(30e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(100e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + err := suite.Keeper.SwapForExactTokens(ctx, requester.GetAddress(), tc.coinA, tc.coinB, tc.slippage) + + if tc.shouldFail { + suite.Require().Error(err) + suite.Contains(err.Error(), "slippage exceeded") + } else { + suite.NoError(err) + } + }) + } +} + +func (suite *keeperTestSuite) TestSwapForExactTokens_InsufficientFunds() { + testCases := []struct { + name string + balanceA sdk.Coin + coinA sdk.Coin + coinB sdk.Coin + }{ + {"no ukava balance", sdk.NewCoin("ukava", sdk.ZeroInt()), sdk.NewCoin("ukava", sdk.NewInt(100)), sdk.NewCoin("usdx", sdk.NewInt(500))}, + {"no usdx balance", sdk.NewCoin("usdx", sdk.ZeroInt()), sdk.NewCoin("usdx", sdk.NewInt(500)), sdk.NewCoin("ukava", sdk.NewInt(100))}, + {"low ukava balance", sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("usdx", sdk.NewInt(5000000))}, + {"low ukava balance", sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("ukava", sdk.NewInt(1000000))}, + {"large ukava balance difference", sdk.NewCoin("ukava", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6))}, + {"large usdx balance difference", sdk.NewCoin("usdx", sdk.NewInt(500e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6))}, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100000e6)), + sdk.NewCoin("usdx", sdk.NewInt(500000e6)), + ) + totalShares := sdk.NewInt(30000e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + balance := sdk.NewCoins(tc.balanceA) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + err := suite.Keeper.SwapForExactTokens(ctx, requester.GetAddress(), tc.coinA, tc.coinB, sdk.MustNewDecFromStr("0.1")) + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds), fmt.Sprintf("got err %s", err)) + }) + } +} + +func (suite *keeperTestSuite) TestSwapForExactTokens_InsufficientFunds_Vesting() { + testCases := []struct { + name string + balanceA sdk.Coin + vestingA sdk.Coin + coinA sdk.Coin + coinB sdk.Coin + }{ + {"no ukava balance, vesting only", sdk.NewCoin("ukava", sdk.ZeroInt()), sdk.NewCoin("ukava", sdk.NewInt(100)), sdk.NewCoin("ukava", sdk.NewInt(1000)), sdk.NewCoin("usdx", sdk.NewInt(500))}, + {"no usdx balance, vesting only", sdk.NewCoin("usdx", sdk.ZeroInt()), sdk.NewCoin("usdx", sdk.NewInt(500)), sdk.NewCoin("usdx", sdk.NewInt(5000)), sdk.NewCoin("ukava", sdk.NewInt(100))}, + {"low ukava balance, vesting matches exact", sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("ukava", sdk.NewInt(100000)), sdk.NewCoin("ukava", sdk.NewInt(1000000)), sdk.NewCoin("usdx", sdk.NewInt(5000000))}, + {"low ukava balance, vesting matches exact", sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("usdx", sdk.NewInt(500000)), sdk.NewCoin("usdx", sdk.NewInt(5000000)), sdk.NewCoin("ukava", sdk.NewInt(1000000))}, + {"large ukava balance difference, vesting covers difference", sdk.NewCoin("ukava", sdk.NewInt(100e6)), sdk.NewCoin("ukava", sdk.NewInt(10000e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6))}, + {"large usdx balance difference, vesting covers difference", sdk.NewCoin("usdx", sdk.NewInt(500e6)), sdk.NewCoin("usdx", sdk.NewInt(500000e6)), sdk.NewCoin("usdx", sdk.NewInt(5000e6)), sdk.NewCoin("ukava", sdk.NewInt(1000e6))}, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100000e6)), + sdk.NewCoin("usdx", sdk.NewInt(500000e6)), + ) + totalShares := sdk.NewInt(30000e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + balance := sdk.NewCoins(tc.balanceA) + vesting := sdk.NewCoins(tc.vestingA) + requester := suite.CreateVestingAccount(balance, vesting) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + err := suite.Keeper.SwapForExactTokens(ctx, requester.GetAddress(), tc.coinA, tc.coinB, sdk.MustNewDecFromStr("0.1")) + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds), fmt.Sprintf("got err %s", err)) + }) + } +} + +func (suite *keeperTestSuite) TestSwapForExactTokens_PoolNotFound() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(3000e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + suite.Keeper.DeletePool(suite.Ctx, poolID) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + err := suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + suite.EqualError(err, "invalid pool: pool ukava/usdx not found") + + err = suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinB, coinA, sdk.MustNewDecFromStr("0.01")) + suite.EqualError(err, "invalid pool: pool ukava/usdx not found") +} + +func (suite *keeperTestSuite) TestSwapForExactTokens_PanicOnInvalidPool() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(3000e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + poolRecord, found := suite.Keeper.GetPool(suite.Ctx, poolID) + suite.Require().True(found, "expected pool record to exist") + + poolRecord.TotalShares = sdk.ZeroInt() + suite.Keeper.SetPool(suite.Ctx, poolRecord) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + suite.PanicsWithValue("invalid pool ukava/usdx: invalid pool: total shares must be greater than zero", func() { + _ = suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + }, "expected invalid pool record to panic") + + suite.PanicsWithValue("invalid pool ukava/usdx: invalid pool: total shares must be greater than zero", func() { + _ = suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinB, coinA, sdk.MustNewDecFromStr("0.01")) + }, "expected invalid pool record to panic") +} + +func (suite *keeperTestSuite) TestSwapForExactTokens_PanicOnInsufficientModuleAccFunds() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + ) + totalShares := sdk.NewInt(3000e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + suite.RemoveCoinsFromModule(sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + )) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(10e6)), + ) + requester := suite.NewAccountFromAddr(sdk.AccAddress("requester"), balance) + coinA := sdk.NewCoin("ukava", sdk.NewInt(1e6)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(5e6)) + + suite.Panics(func() { + _ = suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + }, "expected panic when module account does not have enough funds") + + suite.Panics(func() { + _ = suite.Keeper.SwapForExactTokens(suite.Ctx, requester.GetAddress(), coinA, coinB, sdk.MustNewDecFromStr("0.01")) + }, "expected panic when module account does not have enough funds") +} diff --git a/x/swap/testutil/suite.go b/x/swap/testutil/suite.go index 6eead59d..39b0fbbd 100644 --- a/x/swap/testutil/suite.go +++ b/x/swap/testutil/suite.go @@ -20,6 +20,8 @@ import ( tmtime "github.com/tendermint/tendermint/types/time" ) +var defaultSwapFee = sdk.MustNewDecFromStr("0.003") + // Suite implements a test suite for the swap module integration tests type Suite struct { suite.Suite @@ -111,7 +113,7 @@ func (suite *Suite) CreatePool(reserves sdk.Coins) error { depositor := suite.CreateAccount(reserves) pool := swap.NewAllowedPool(reserves[0].Denom, reserves[1].Denom) suite.Require().NoError(pool.Validate()) - suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), swap.DefaultSwapFee)) + suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), defaultSwapFee)) return suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), reserves[0], reserves[1], sdk.MustNewDecFromStr("1")) } diff --git a/x/swap/types/codec.go b/x/swap/types/codec.go index d52f25bc..345149dc 100644 --- a/x/swap/types/codec.go +++ b/x/swap/types/codec.go @@ -16,4 +16,6 @@ func init() { func RegisterCodec(cdc *codec.Codec) { cdc.RegisterConcrete(MsgDeposit{}, "swap/MsgDeposit", nil) cdc.RegisterConcrete(MsgWithdraw{}, "swap/MsgWithdraw", nil) + cdc.RegisterConcrete(MsgSwapExactForTokens{}, "swap/MsgSwapExactForTokens", nil) + cdc.RegisterConcrete(MsgSwapForExactTokens{}, "swap/MsgSwapForExactTokens", nil) } diff --git a/x/swap/types/events.go b/x/swap/types/events.go index 303672f2..8bb9519d 100644 --- a/x/swap/types/events.go +++ b/x/swap/types/events.go @@ -2,11 +2,17 @@ package types // Event types for swap module const ( - AttributeValueCategory = ModuleName - EventTypeSwapDeposit = "swap_deposit" - EventTypeSwapWithdraw = "swap_withdraw" - AttributeKeyPoolID = "pool_id" - AttributeKeyDepositor = "depositor" - AttributeKeyShares = "shares" - AttributeKeyOwner = "owner" + AttributeValueCategory = ModuleName + EventTypeSwapDeposit = "swap_deposit" + EventTypeSwapWithdraw = "swap_withdraw" + EventTypeSwapTrade = "swap_trade" + AttributeKeyPoolID = "pool_id" + AttributeKeyDepositor = "depositor" + AttributeKeyShares = "shares" + AttributeKeyOwner = "owner" + AttributeKeyRequester = "requester" + AttributeKeySwapInput = "input" + AttributeKeySwapOutput = "output" + AttributeKeyFeePaid = "fee" + AttributeKeyExactDirection = "exact" ) diff --git a/x/swap/types/msg.go b/x/swap/types/msg.go index a0f3c239..4f0e6f5a 100644 --- a/x/swap/types/msg.go +++ b/x/swap/types/msg.go @@ -12,6 +12,10 @@ var ( _ MsgWithDeadline = &MsgDeposit{} _ sdk.Msg = &MsgWithdraw{} _ MsgWithDeadline = &MsgWithdraw{} + _ sdk.Msg = &MsgSwapExactForTokens{} + _ MsgWithDeadline = &MsgSwapExactForTokens{} + _ sdk.Msg = &MsgSwapForExactTokens{} + _ MsgWithDeadline = &MsgSwapForExactTokens{} ) // MsgWithDeadline allows messages to define a deadline of when they are considered invalid @@ -179,3 +183,163 @@ func (msg MsgWithdraw) GetDeadline() time.Time { func (msg MsgWithdraw) DeadlineExceeded(blockTime time.Time) bool { return blockTime.Unix() >= msg.Deadline } + +// MsgSwapExactForTokens trades an exact coinA for coinB +type MsgSwapExactForTokens struct { + Requester sdk.AccAddress `json:"requester" yaml:"requester"` + ExactTokenA sdk.Coin `json:"exact_token_a" yaml:"exact_token_a"` + TokenB sdk.Coin `json:"token_b" yaml:"token_b"` + Slippage sdk.Dec `json:"slippage" yaml:"slippage"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} + +// NewMsgSwapExactForTokens returns a new MsgSwapExactForTokens +func NewMsgSwapExactForTokens(requester sdk.AccAddress, exactTokenA sdk.Coin, tokenB sdk.Coin, slippage sdk.Dec, deadline int64) MsgSwapExactForTokens { + return MsgSwapExactForTokens{ + Requester: requester, + ExactTokenA: exactTokenA, + TokenB: tokenB, + Slippage: slippage, + Deadline: deadline, + } +} + +// Route return the message type used for routing the message. +func (msg MsgSwapExactForTokens) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgSwapExactForTokens) Type() string { return "swap_exact_for_tokens" } + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgSwapExactForTokens) ValidateBasic() error { + if msg.Requester.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "requester address cannot be empty") + } + + if !msg.ExactTokenA.IsValid() || msg.ExactTokenA.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "exact token a deposit amount %s", msg.ExactTokenA) + } + + if !msg.TokenB.IsValid() || msg.TokenB.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "token b deposit amount %s", msg.TokenB) + } + + if msg.ExactTokenA.Denom == msg.TokenB.Denom { + return sdkerrors.Wrap(sdkerrors.ErrInvalidCoins, "denominations can not be equal") + } + + if msg.Slippage.IsNil() { + return sdkerrors.Wrapf(ErrInvalidSlippage, "slippage must be set") + } + + if msg.Slippage.IsNegative() { + return sdkerrors.Wrapf(ErrInvalidSlippage, "slippage can not be negative") + } + + if msg.Deadline <= 0 { + return sdkerrors.Wrapf(ErrInvalidDeadline, "deadline %d", msg.Deadline) + } + + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgSwapExactForTokens) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgSwapExactForTokens) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Requester} +} + +// GetDeadline returns the time at which the msg is considered invalid +func (msg MsgSwapExactForTokens) GetDeadline() time.Time { + return time.Unix(msg.Deadline, 0) +} + +// DeadlineExceeded returns if the msg has exceeded it's deadline +func (msg MsgSwapExactForTokens) DeadlineExceeded(blockTime time.Time) bool { + return blockTime.Unix() >= msg.Deadline +} + +// MsgSwapForExactTokens trades coinA for an exact coinB +type MsgSwapForExactTokens struct { + Requester sdk.AccAddress `json:"requester" yaml:"requester"` + TokenA sdk.Coin `json:"token_a" yaml:"token_a"` + ExactTokenB sdk.Coin `json:"exact_token_b" yaml:"exact_token_b"` + Slippage sdk.Dec `json:"slippage" yaml:"slippage"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} + +// NewMsgSwapForExactTokens returns a new MsgSwapForExactTokens +func NewMsgSwapForExactTokens(requester sdk.AccAddress, tokenA sdk.Coin, exactTokenB sdk.Coin, slippage sdk.Dec, deadline int64) MsgSwapForExactTokens { + return MsgSwapForExactTokens{ + Requester: requester, + TokenA: tokenA, + ExactTokenB: exactTokenB, + Slippage: slippage, + Deadline: deadline, + } +} + +// Route return the message type used for routing the message. +func (msg MsgSwapForExactTokens) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgSwapForExactTokens) Type() string { return "swap_for_exact_tokens" } + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgSwapForExactTokens) ValidateBasic() error { + if msg.Requester.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "requester address cannot be empty") + } + + if !msg.TokenA.IsValid() || msg.TokenA.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "token a deposit amount %s", msg.TokenA) + } + + if !msg.ExactTokenB.IsValid() || msg.ExactTokenB.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "exact token b deposit amount %s", msg.ExactTokenB) + } + + if msg.TokenA.Denom == msg.ExactTokenB.Denom { + return sdkerrors.Wrap(sdkerrors.ErrInvalidCoins, "denominations can not be equal") + } + + if msg.Slippage.IsNil() { + return sdkerrors.Wrapf(ErrInvalidSlippage, "slippage must be set") + } + + if msg.Slippage.IsNegative() { + return sdkerrors.Wrapf(ErrInvalidSlippage, "slippage can not be negative") + } + + if msg.Deadline <= 0 { + return sdkerrors.Wrapf(ErrInvalidDeadline, "deadline %d", msg.Deadline) + } + + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgSwapForExactTokens) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgSwapForExactTokens) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Requester} +} + +// GetDeadline returns the time at which the msg is considered invalid +func (msg MsgSwapForExactTokens) GetDeadline() time.Time { + return time.Unix(msg.Deadline, 0) +} + +// DeadlineExceeded returns if the msg has exceeded it's deadline +func (msg MsgSwapForExactTokens) DeadlineExceeded(blockTime time.Time) bool { + return blockTime.Unix() >= msg.Deadline +} diff --git a/x/swap/types/msg_test.go b/x/swap/types/msg_test.go index 4e0402f5..37501fb5 100644 --- a/x/swap/types/msg_test.go +++ b/x/swap/types/msg_test.go @@ -406,3 +406,393 @@ func TestMsgWithdraw_Deadline(t *testing.T) { assert.Equal(t, time.Unix(tc.deadline, 0), msg.GetDeadline()) } } + +func TestMsgSwapExactForTokens_Attributes(t *testing.T) { + msg := types.MsgSwapExactForTokens{} + assert.Equal(t, "swap", msg.Route()) + assert.Equal(t, "swap_exact_for_tokens", msg.Type()) +} + +func TestMsgSwapExactForTokens_Signing(t *testing.T) { + signData := `{"type":"swap/MsgSwapExactForTokens","value":{"deadline":"1623606299","exact_token_a":{"amount":"1000000","denom":"ukava"},"requester":"kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d","slippage":"0.010000000000000000","token_b":{"amount":"5000000","denom":"usdx"}}}` + signBytes := []byte(signData) + + addr, err := sdk.AccAddressFromBech32("kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d") + require.NoError(t, err) + + msg := types.NewMsgSwapExactForTokens(addr, sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), 1623606299) + assert.Equal(t, []sdk.AccAddress{addr}, msg.GetSigners()) + assert.Equal(t, signBytes, msg.GetSignBytes()) +} + +func TestMsgSwapExactForTokens_Validation(t *testing.T) { + validMsg := types.NewMsgSwapExactForTokens( + sdk.AccAddress("test1"), + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + sdk.MustNewDecFromStr("0.01"), + 1623606299, + ) + require.NoError(t, validMsg.ValidateBasic()) + + testCases := []struct { + name string + requester sdk.AccAddress + exactTokenA sdk.Coin + tokenB sdk.Coin + slippage sdk.Dec + deadline int64 + expectedErr string + }{ + { + name: "empty address", + requester: sdk.AccAddress(""), + exactTokenA: validMsg.ExactTokenA, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid address: requester address cannot be empty", + }, + { + name: "negative token a", + requester: validMsg.Requester, + exactTokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: exact token a deposit amount -1ukava", + }, + { + name: "zero token a", + requester: validMsg.Requester, + exactTokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: exact token a deposit amount 0ukava", + }, + { + name: "invalid denom token a", + requester: validMsg.Requester, + exactTokenA: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: exact token a deposit amount 1000000UKAVA", + }, + { + name: "negative token b", + requester: validMsg.Requester, + exactTokenA: validMsg.ExactTokenA, + tokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token b deposit amount -1ukava", + }, + { + name: "zero token b", + requester: validMsg.Requester, + exactTokenA: validMsg.ExactTokenA, + tokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token b deposit amount 0ukava", + }, + { + name: "invalid denom token b", + requester: validMsg.Requester, + exactTokenA: validMsg.ExactTokenA, + tokenB: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token b deposit amount 1000000UKAVA", + }, + { + name: "denoms can not be the same", + requester: validMsg.Requester, + exactTokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + tokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: denominations can not be equal", + }, + { + name: "zero deadline", + requester: validMsg.Requester, + exactTokenA: validMsg.ExactTokenA, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: 0, + expectedErr: "invalid deadline: deadline 0", + }, + { + name: "negative deadline", + requester: validMsg.Requester, + exactTokenA: validMsg.ExactTokenA, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: -1, + expectedErr: "invalid deadline: deadline -1", + }, + { + name: "negative slippage", + requester: validMsg.Requester, + exactTokenA: validMsg.ExactTokenA, + tokenB: validMsg.TokenB, + slippage: sdk.MustNewDecFromStr("-0.01"), + deadline: validMsg.Deadline, + expectedErr: "invalid slippage: slippage can not be negative", + }, + { + name: "nil slippage", + requester: validMsg.Requester, + exactTokenA: validMsg.ExactTokenA, + tokenB: validMsg.TokenB, + slippage: sdk.Dec{}, + deadline: validMsg.Deadline, + expectedErr: "invalid slippage: slippage must be set", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + msg := types.NewMsgSwapExactForTokens(tc.requester, tc.exactTokenA, tc.tokenB, tc.slippage, tc.deadline) + err := msg.ValidateBasic() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestMsgSwapExactForTokens_Deadline(t *testing.T) { + blockTime := time.Now() + + testCases := []struct { + name string + deadline int64 + isExceeded bool + }{ + { + name: "deadline in future", + deadline: blockTime.Add(1 * time.Second).Unix(), + isExceeded: false, + }, + { + name: "deadline in past", + deadline: blockTime.Add(-1 * time.Second).Unix(), + isExceeded: true, + }, + { + name: "deadline is equal", + deadline: blockTime.Unix(), + isExceeded: true, + }, + } + + for _, tc := range testCases { + msg := types.NewMsgSwapExactForTokens( + sdk.AccAddress("test1"), + sdk.NewCoin("ukava", sdk.NewInt(1000000)), + sdk.NewCoin("usdx", sdk.NewInt(2000000)), + sdk.MustNewDecFromStr("0.01"), + tc.deadline, + ) + require.NoError(t, msg.ValidateBasic()) + assert.Equal(t, tc.isExceeded, msg.DeadlineExceeded(blockTime)) + assert.Equal(t, time.Unix(tc.deadline, 0), msg.GetDeadline()) + } +} + +func TestMsgSwapForExactTokens_Attributes(t *testing.T) { + msg := types.MsgSwapForExactTokens{} + assert.Equal(t, "swap", msg.Route()) + assert.Equal(t, "swap_for_exact_tokens", msg.Type()) +} + +func TestMsgSwapForExactTokens_Signing(t *testing.T) { + signData := `{"type":"swap/MsgSwapForExactTokens","value":{"deadline":"1623606299","exact_token_b":{"amount":"5000000","denom":"usdx"},"requester":"kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d","slippage":"0.010000000000000000","token_a":{"amount":"1000000","denom":"ukava"}}}` + signBytes := []byte(signData) + + addr, err := sdk.AccAddressFromBech32("kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d") + require.NoError(t, err) + + msg := types.NewMsgSwapForExactTokens(addr, sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), 1623606299) + assert.Equal(t, []sdk.AccAddress{addr}, msg.GetSigners()) + assert.Equal(t, signBytes, msg.GetSignBytes()) +} + +func TestMsgSwapForExactTokens_Validation(t *testing.T) { + validMsg := types.NewMsgSwapForExactTokens( + sdk.AccAddress("test1"), + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + sdk.MustNewDecFromStr("0.01"), + 1623606299, + ) + require.NoError(t, validMsg.ValidateBasic()) + + testCases := []struct { + name string + requester sdk.AccAddress + tokenA sdk.Coin + exactTokenB sdk.Coin + slippage sdk.Dec + deadline int64 + expectedErr string + }{ + { + name: "empty address", + requester: sdk.AccAddress(""), + tokenA: validMsg.TokenA, + exactTokenB: validMsg.ExactTokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid address: requester address cannot be empty", + }, + { + name: "negative token a", + requester: validMsg.Requester, + tokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + exactTokenB: validMsg.ExactTokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token a deposit amount -1ukava", + }, + { + name: "zero token a", + requester: validMsg.Requester, + tokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + exactTokenB: validMsg.ExactTokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token a deposit amount 0ukava", + }, + { + name: "invalid denom token a", + requester: validMsg.Requester, + tokenA: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + exactTokenB: validMsg.ExactTokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token a deposit amount 1000000UKAVA", + }, + { + name: "negative token b", + requester: validMsg.Requester, + tokenA: validMsg.TokenA, + exactTokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: exact token b deposit amount -1ukava", + }, + { + name: "zero token b", + requester: validMsg.Requester, + tokenA: validMsg.TokenA, + exactTokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: exact token b deposit amount 0ukava", + }, + { + name: "invalid denom token b", + requester: validMsg.Requester, + tokenA: validMsg.TokenA, + exactTokenB: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: exact token b deposit amount 1000000UKAVA", + }, + { + name: "denoms can not be the same", + requester: validMsg.Requester, + tokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + exactTokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: denominations can not be equal", + }, + { + name: "zero deadline", + requester: validMsg.Requester, + tokenA: validMsg.TokenA, + exactTokenB: validMsg.ExactTokenB, + slippage: validMsg.Slippage, + deadline: 0, + expectedErr: "invalid deadline: deadline 0", + }, + { + name: "negative deadline", + requester: validMsg.Requester, + tokenA: validMsg.TokenA, + exactTokenB: validMsg.ExactTokenB, + slippage: validMsg.Slippage, + deadline: -1, + expectedErr: "invalid deadline: deadline -1", + }, + { + name: "negative slippage", + requester: validMsg.Requester, + tokenA: validMsg.TokenA, + exactTokenB: validMsg.ExactTokenB, + slippage: sdk.MustNewDecFromStr("-0.01"), + deadline: validMsg.Deadline, + expectedErr: "invalid slippage: slippage can not be negative", + }, + { + name: "nil slippage", + requester: validMsg.Requester, + tokenA: validMsg.TokenA, + exactTokenB: validMsg.ExactTokenB, + slippage: sdk.Dec{}, + deadline: validMsg.Deadline, + expectedErr: "invalid slippage: slippage must be set", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + msg := types.NewMsgSwapForExactTokens(tc.requester, tc.tokenA, tc.exactTokenB, tc.slippage, tc.deadline) + err := msg.ValidateBasic() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestMsgSwapForExactTokens_Deadline(t *testing.T) { + blockTime := time.Now() + + testCases := []struct { + name string + deadline int64 + isExceeded bool + }{ + { + name: "deadline in future", + deadline: blockTime.Add(1 * time.Second).Unix(), + isExceeded: false, + }, + { + name: "deadline in past", + deadline: blockTime.Add(-1 * time.Second).Unix(), + isExceeded: true, + }, + { + name: "deadline is equal", + deadline: blockTime.Unix(), + isExceeded: true, + }, + } + + for _, tc := range testCases { + msg := types.NewMsgSwapForExactTokens( + sdk.AccAddress("test1"), + sdk.NewCoin("ukava", sdk.NewInt(1000000)), + sdk.NewCoin("usdx", sdk.NewInt(2000000)), + sdk.MustNewDecFromStr("0.01"), + tc.deadline, + ) + require.NoError(t, msg.ValidateBasic()) + assert.Equal(t, tc.isExceeded, msg.DeadlineExceeded(blockTime)) + assert.Equal(t, time.Unix(tc.deadline, 0), msg.GetDeadline()) + } +}