diff --git a/x/coinswap/alias.go b/x/coinswap/alias.go new file mode 100644 index 000000000000..0e5233a69228 --- /dev/null +++ b/x/coinswap/alias.go @@ -0,0 +1,24 @@ +package coinswap + +import ( + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/keeper" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +type ( + Keeper = keeper.Keeper + MsgSwapOrder = types.MsgSwapOrder + MsgAddLiquidity = types.MsgAddLiquidity + MsgRemoveLiquidity = types.MsgRemoveLiquidity +) + +var ( + ErrInvalidDeadline = types.ErrInvalidDeadline + ErrNotPositive = types.ErrNotPositive + ErrConstraintNotMet = types.ErrConstraintNotMet +) + +const ( + DefaultCodespace = types.DefaultCodespace + ModuleName = types.ModuleName +) diff --git a/x/coinswap/client/cli/query.go b/x/coinswap/client/cli/query.go new file mode 100644 index 000000000000..be7290130bd9 --- /dev/null +++ b/x/coinswap/client/cli/query.go @@ -0,0 +1,111 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +const ( + nativeDenom = "atom" +) + +// GetQueryCmd returns the cli query commands for this module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + coinswapQueryCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "Querying commands for the coinswap module", + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + coinswapQueryCmd.AddCommand(client.GetCommands( + GetCmdQueryLiquidity(queryRoute, cdc), + GetCmdQueryParams(queryRoute, cdc))...) + + return coinswapQueryCmd +} + +// GetCmdQueryLiquidity implements the liquidity query command +func GetCmdQueryLiquidity(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "liquidity [denom]", + Short: "Query the current liquidity values", + Long: strings.TrimSpace( + fmt.Sprintf(`Query the liquidity of a specific trading pair stored in the reserve pool. + +Example: +$ %s query coinswap liquidity btc +`, + version.ClientName, + ), + ), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Added a check to ensure that input provided is not a native denom + if strings.Compare(strings.TrimSpace(args[0]), nativeDenom) == 0 { + return fmt.Errorf("%s is not a valid denom, please input a valid denom", args[0]) + } + + bz, err := cdc.MarshalJSON(types.NewQueryLiquidityParams(args[0])) + if err != nil { + return err + } + + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryLiquidity) + res, _, err := cliCtx.QueryWithData(route, bz) + if err != nil { + return err + } + + var liquidity sdk.Coins + if err := cdc.UnmarshalJSON(res, &liquidity); err != nil { + return err + } + return cliCtx.PrintOutput(liquidity) + }, + } +} + +// GetCmdQueryParams implements the params query command +func GetCmdQueryParams(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "params", + Short: "Query the parameters involved in the coinswap process", + Long: strings.TrimSpace( + fmt.Sprintf(`Query all the parameters for the coinswap process. + +Example: +$ %s query coinswap params +`, + version.ClientName, + ), + ), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryParameters) + bz, _, err := cliCtx.QueryWithData(route, nil) + if err != nil { + return err + } + + var params types.Params + if err := cdc.UnmarshalJSON(bz, ¶ms); err != nil { + return err + } + return cliCtx.PrintOutput(params) + }, + } +} diff --git a/x/coinswap/client/cli/tx.go b/x/coinswap/client/cli/tx.go new file mode 100644 index 000000000000..2bb68da09560 --- /dev/null +++ b/x/coinswap/client/cli/tx.go @@ -0,0 +1,278 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +// Liquidity flags +const ( + MinReward = "min-reward" + MinNative = "min-native" + Deadline = "deadline" + Recipient = "recipient" +) + +// GetTxCmd returns the transaction commands for this module +func GetTxCmd(storeKey string, cdc *codec.Codec) *cobra.Command { + coinswapTxCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "Coinswap transaction subcommands", + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + + coinswapTxCmd.AddCommand(client.PostCommands( + GetCmdAddLiquidity(cdc), + GetCmdRemoveLiquidity(cdc), + GetCmdBuyOrder(cdc), + GetCmdSellOrder(cdc))...) + + return coinswapTxCmd +} + +// GetCmdAddLiquidity implements the add liquidity command handler +func GetCmdAddLiquidity(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "add-liquidity [deposit-coin] [deposit]", + Args: cobra.ExactArgs(2), + Short: "Add liquidity to the reserve pool", + Long: strings.TrimSpace( + fmt.Sprintf(`Add liquidity to the reserve pool for a trading pair. + +Example: +$ %s tx coinswap add-liquidity dai 1000atom --min-reward 100 --deadline 2h --from mykey +`, + version.ClientName, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(auth.DefaultTxEncoder(cdc)) + cliCtx := context.NewCLIContext().WithCodec(cdc) + + depositCoin, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + deposit, err := sdk.ParseCoin(args[1]) + if err != nil { + return err + } + + minRewardArg := viper.GetString(MinReward) + minReward, err := sdk.ParseCoin(minRewardArg) + if err != nil { + return err + } + + durationArg := viper.GetString(Deadline) + duration, err := time.ParseDuration(durationArg) + if err != nil { + return fmt.Errorf("failed to parse the duration : %s", err) + } + deadline := time.Now().Add(duration).UTC() + + senderAddr := cliCtx.GetFromAddress() + + msg := types.NewMsgAddLiquidity(depositCoin, deposit.Amount, minReward.Amount, deadline, senderAddr) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + + cmd.Flags().String(MinReward, "", "minimum amount of vouchers the sender is willing to accept for deposited coins (required)") + cmd.Flags().String(Deadline, "1h", "duration for which the transaction is valid (required)") + + cmd.MarkFlagRequired(MinReward) + cmd.MarkFlagRequired(Deadline) + + return cmd +} + +// GetCmdRemoveLiquidity implements the remove liquidity command handler +func GetCmdRemoveLiquidity(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "remove-liquidity [withdraw-coin] [pool-tokens]", + Args: cobra.ExactArgs(2), + Short: "Remove liquidity from the reserve pool", + Long: strings.TrimSpace( + fmt.Sprintf(`Remove liquidity from the reserve pool for a trading pair. + +Example: +$ %s tx coinswap remove-liquidity dai 1000atom --min-native 100atom --deadline 2h --from mykey +`, + version.ClientName, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(auth.DefaultTxEncoder(cdc)) + cliCtx := context.NewCLIContext().WithCodec(cdc) + + withdrawCoin, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + poolTokens, ok := sdk.NewIntFromString(args[1]) + if !ok { + return fmt.Errorf("pool-tokens %s is not a valid int, please input valid pool-tokens", args[1]) + } + + minNativeArg := viper.GetString(MinNative) + minNative, err := sdk.ParseCoin(minNativeArg) + if err != nil { + return err + } + + durationArg := viper.GetString(Deadline) + duration, err := time.ParseDuration(durationArg) + if err != nil { + return fmt.Errorf("failed to parse the duration : %s", err) + } + deadline := time.Now().Add(duration).UTC() + + senderAddr := cliCtx.GetFromAddress() + + msg := types.NewMsgRemoveLiquidity(withdrawCoin, poolTokens, minNative.Amount, deadline, senderAddr) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + + cmd.Flags().String(MinNative, "", "minimum amount of the native asset the sender is willing to accept (required)") + cmd.Flags().String(Deadline, "1h", "duration for which the transaction is valid (required)") + + cmd.MarkFlagRequired(MinNative) + cmd.MarkFlagRequired(Deadline) + + return cmd +} + +// GetCmdBuyOrder implements the buy order command handler +func GetCmdBuyOrder(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "buy-order [input] [output]", + Args: cobra.ExactArgs(2), + Short: "Buy order", + Long: strings.TrimSpace( + fmt.Sprintf(`Buy order for a trading pair. + +Example: +$ %s tx coinswap buy-order 5atom 2eth --deadline 2h --recipient recipientAddr --from mykey +`, + version.ClientName, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(auth.DefaultTxEncoder(cdc)) + cliCtx := context.NewCLIContext().WithCodec(cdc) + + input, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + output, err := sdk.ParseCoin(args[1]) + if err != nil { + return err + } + + durationArg := viper.GetString(Deadline) + duration, err := time.ParseDuration(durationArg) + if err != nil { + return fmt.Errorf("failed to parse the duration : %s", err) + } + deadline := time.Now().Add(duration).UTC() + + senderAddr := cliCtx.GetFromAddress() + + recipientAddrArg := viper.GetString(Recipient) + recipientAddr, err := sdk.AccAddressFromBech32(recipientAddrArg) + if err != nil { + return err + } + + msg := types.NewMsgSwapOrder(input, output, deadline, senderAddr, recipientAddr, true) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + + cmd.Flags().String(Recipient, "", "recipient's address (required)") + cmd.Flags().String(Deadline, "1h", "duration for which the transaction is valid (required)") + + cmd.MarkFlagRequired(Recipient) + cmd.MarkFlagRequired(Deadline) + + return cmd +} + +// GetCmdSellOrder implements the sell order command handler +func GetCmdSellOrder(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "sell-order [input] [output]", + Args: cobra.ExactArgs(2), + Short: "Sell order", + Long: strings.TrimSpace( + fmt.Sprintf(`Sell order for a trading pair. + +Example: +$ %s tx coinswap sell-order 2eth 5atom --deadline 2h --recipient recipientAddr --from mykey +`, + version.ClientName, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(auth.DefaultTxEncoder(cdc)) + cliCtx := context.NewCLIContext().WithCodec(cdc) + + input, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + output, err := sdk.ParseCoin(args[1]) + if err != nil { + return err + } + + durationArg := viper.GetString(Deadline) + duration, err := time.ParseDuration(durationArg) + if err != nil { + return fmt.Errorf("failed to parse the duration : %s", err) + } + deadline := time.Now().Add(duration).UTC() + + senderAddr := cliCtx.GetFromAddress() + + recipientAddrArg := viper.GetString(Recipient) + recipientAddr, err := sdk.AccAddressFromBech32(recipientAddrArg) + if err != nil { + return err + } + + msg := types.NewMsgSwapOrder(input, output, deadline, senderAddr, recipientAddr, false) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + + cmd.Flags().String(Recipient, "", "recipient's address (required)") + cmd.Flags().String(Deadline, "1h", "duration for which the transaction is valid (required)") + + cmd.MarkFlagRequired(Recipient) + cmd.MarkFlagRequired(Deadline) + + return cmd +} diff --git a/x/coinswap/client/rest/query.go b/x/coinswap/client/rest/query.go new file mode 100644 index 000000000000..10a5d0033e71 --- /dev/null +++ b/x/coinswap/client/rest/query.go @@ -0,0 +1,72 @@ +package rest + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { + // Get liquidity values stored in the reserve pool for a trading pair + r.HandleFunc( + "/coinswap/liquidity/{non_native_denom}", + liquidityHandlerFn(cliCtx), + ).Methods("GET") + + // Get the current coinswap parameter values + r.HandleFunc( + "/coinswap/parameters", + paramsHandlerFn(cliCtx), + ).Methods("GET") +} + +func liquidityHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + nonNativeDenom := vars["non_native_denom"] + + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + params := types.NewQueryLiquidityParams(nonNativeDenom) + bz, err := cliCtx.Codec.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + route := fmt.Sprintf("custom/%s/%s", types.QuerierRoute, types.QueryLiquidity) + res, height, err := cliCtx.QueryWithData(route, bz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func paramsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + route := fmt.Sprintf("custom/%s/%s", types.QuerierRoute, types.QueryParameters) + res, height, err := cliCtx.Query(route) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/coinswap/client/rest/rest.go b/x/coinswap/client/rest/rest.go new file mode 100644 index 000000000000..b848f1f57588 --- /dev/null +++ b/x/coinswap/client/rest/rest.go @@ -0,0 +1,13 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" +) + +// RegisterRoutes registers coinswap-related REST handlers to a router +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { + registerQueryRoutes(cliCtx, r) + registerTxRoutes(cliCtx, r) +} diff --git a/x/coinswap/client/rest/tx.go b/x/coinswap/client/rest/tx.go new file mode 100644 index 000000000000..bdbe21580760 --- /dev/null +++ b/x/coinswap/client/rest/tx.go @@ -0,0 +1,131 @@ +package rest + +import ( + "net/http" + "time" + + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc( + "/coinswap/add_liquidity", + postAddLiquidityHandlerFn(cliCtx), + ).Methods("POST") + r.HandleFunc( + "/coinswap/remove_liquidity", + postRemoveLiquidityHandlerFn(cliCtx), + ).Methods("POST") + r.HandleFunc( + "/coinswap/swap_order", + postSwapOrderHandlerFn(cliCtx), + ).Methods("POST") +} + +type ( + // AddLiquidityRequest defines the properties of an add liquidity request's body. + AddLiquidityRequest struct { + BaseReq rest.BaseReq `json:"base_req"` + Deposit sdk.Coin `json:"deposit"` + DepositAmount sdk.Int `json:"deposit_amount"` + MinReward sdk.Int `json:"min_reward"` + Deadline time.Time `json:"deadline"` + } + + // RemoveLiquidityRequest defines the properties of a remove liquidity request's body. + RemoveLiquidityRequest struct { + BaseReq rest.BaseReq `json:"base_req"` + Withdraw sdk.Coin `json:"withdraw"` + WithdrawAmount sdk.Int `json:"withdraw_amount"` + MinNative sdk.Int `json:"min_native"` + Deadline time.Time `json:"deadline"` + } + + // SwapOrderRequest defines the properties of a swap order request's body. + SwapOrderRequest struct { + BaseReq rest.BaseReq `json:"base_req"` + Input sdk.Coin `json:"input"` + Output sdk.Coin `json:"output"` + Deadline time.Time `json:"deadline"` + Recipient sdk.AccAddress `json:"recipient"` // in bech32 + IsBuyOrder bool `json:"is_buy_order"` + } +) + +func postAddLiquidityHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req AddLiquidityRequest + + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + senderAddr, err := sdk.AccAddressFromBech32(req.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgAddLiquidity(req.Deposit, req.DepositAmount, req.MinReward, req.Deadline, senderAddr) + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} + +func postRemoveLiquidityHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req RemoveLiquidityRequest + + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + senderAddr, err := sdk.AccAddressFromBech32(req.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgRemoveLiquidity(req.Withdraw, req.WithdrawAmount, req.MinNative, req.Deadline, senderAddr) + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} + +func postSwapOrderHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req SwapOrderRequest + + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + senderAddr, err := sdk.AccAddressFromBech32(req.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgSwapOrder(req.Input, req.Output, req.Deadline, senderAddr, req.Recipient, req.IsBuyOrder) + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} diff --git a/x/coinswap/genesis.go b/x/coinswap/genesis.go new file mode 100644 index 000000000000..9755464205a4 --- /dev/null +++ b/x/coinswap/genesis.go @@ -0,0 +1,43 @@ +package coinswap + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +// TODO: ... + +// GenesisState - coinswap genesis state +type GenesisState struct { + Params types.Params `json:"params"` +} + +// NewGenesisState is the constructor function for GenesisState +func NewGenesisState(params types.Params) GenesisState { + return GenesisState{ + Params: params, + } +} + +// DefaultGenesisState creates a default GenesisState object +func DefaultGenesisState() GenesisState { + return NewGenesisState(types.DefaultParams()) +} + +// InitGenesis new coinswap genesis +func InitGenesis(ctx sdk.Context, keeper Keeper, data GenesisState) { + +} + +// ExportGenesis returns a GenesisState for a given context and keeper. +func ExportGenesis(ctx sdk.Context, keeper Keeper) GenesisState { + return NewGenesisState(types.DefaultParams()) +} + +// ValidateGenesis - placeholder function +func ValidateGenesis(data GenesisState) error { + if err := types.ValidateParams(data.Params); err != nil { + return err + } + return nil +} diff --git a/x/coinswap/handler.go b/x/coinswap/handler.go new file mode 100644 index 000000000000..a39d74a485e3 --- /dev/null +++ b/x/coinswap/handler.go @@ -0,0 +1,168 @@ +package coinswap + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// NewHandler returns a handler for "coinswap" type messages. +func NewHandler(k Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) sdk.Result { + ctx = ctx.WithEventManager(sdk.NewEventManager()) + + switch msg := msg.(type) { + case MsgSwapOrder: + return HandleMsgSwapOrder(ctx, msg, k) + + case MsgAddLiquidity: + return HandleMsgAddLiquidity(ctx, msg, k) + + case MsgRemoveLiquidity: + return HandleMsgRemoveLiquidity(ctx, msg, k) + + default: + errMsg := fmt.Sprintf("unrecognized coinswap message type: %T", msg) + return sdk.ErrUnknownRequest(errMsg).Result() + } + } +} + +// Handle MsgSwapOrder. +func HandleMsgSwapOrder(ctx sdk.Context, msg MsgSwapOrder, k Keeper) sdk.Result { + // check that deadline has not passed + if ctx.BlockHeader().Time.After(msg.Deadline) { + return ErrInvalidDeadline(DefaultCodespace, "deadline has passed for MsgSwapOrder").Result() + } + + var calculatedAmount sdk.Int + doubleSwap := isDoubleSwap(ctx, k, input.Denom, output.Denom) + nativeDenom := k.GetNativeDenom(ctx) + + if msg.IsBuyOrder { + if doubleSwap { + nativeAmount = k.GetInputAmount(ctx, msg.Output.Amount, nativeDenom, msg.Output.Denom) + calculatedAmount = k.GetInputAmount(ctx, k, nativeAmount, msg.Input.Denom, nativeDenom) + nativeCoin := sdk.NewCoin(nativeDenom, nativeAmount) + k.SwapCoins(ctx, sdk.NewCoin(msg.Input.Denom, calculatedAmount), nativeCoin) + k.SwapCoins(ctx, k, nativeCoin, msg.Output) + } else { + calculatedAmount = k.GetInputAmount(ctx, k, msg.Output.Amount, msg.Input.Denom, msg.Output.Denom) + k.SwapCoins(ctx, sdk.NewCoin(msg.Input.Denom, calculatedAmount), msg.Output) + } + + // assert that the calculated amount is less than or equal to the + // maximum amount the buyer is willing to pay. + if !calculatedAmount.LTE(msg.Input.Amount) { + return ErrConstraintNotMet(DefaultCodespace, fmt.Sprintf("maximum amount (%d) to be sold was exceeded (%d)", msg.Input.Amount, calculatedAmount)).Result() + } + } else { + if doubleSwap { + nativeAmount = k.GetOutputAmount(ctx, msg.Input.Amount, msg.Input.Denom, nativeDenom) + calculatedAmount = k.GetOutputAmount(ctx, nativeAmount, nativeDenom, msg.Output.Denom) + nativeCoin := sdk.NewCoin(nativeDenom, nativeAmount) + k.SwapCoins(ctx, msg.Input, nativeCoin) + k.SwapCoins(ctx, nativeCoin, sdk.NewCoin(msg.Output.Denom, calculatedAmount)) + } else { + calculatedAmount = k.GetOutputAmount(ctx, input.Amount, input.Denom, outputDenom) + k.SwapCoins(ctx, msg.Input, sdk.NewCoin(msg.Output.Denom, calculatedAmount)) + } + + // assert that the calculated amount is greater than or equal to the + // minimum amount the sender is willing to buy. + if !calculatedAmount.GTE(msg.Output.Amount) { + return ErrConstraintNotMet(DefaultCodespace, "minimum amount (%d) to be sold was not met (%d)", msg.Output.Amount, calculatedAmount).Result() + } + + } + + return sdk.Result{} +} + +// Handle MsgAddLiquidity. If the reserve pool does not exist, it will be +// created. The first liquidity provider sets the exchange rate. +func HandleMsgAddLiquidity(ctx sdk.Context, msg MsgAddLiquidity, k Keeper) sdk.Result { + // check that deadline has not passed + if ctx.BlockHeader().Time.After(msg.Deadline) { + return ErrInvalidDeadline(DefaultCodespace, "deadline has passed for MsgAddLiquidity").Result() + } + + nativeDenom := k.GetNativeDenom(ctx) + moduleName := k.GetModuleName(ctx, nativeDenom, msg.Deposit.Denom) + + // create reserve pool if it does not exist + reservePool, found := k.GetReservePool(ctx, msg.Deposit.Denom) + if !found { + k.CreateReservePool(ctx, msg.Deposit.Denom) + } + + nativeBalance := reservePool.AmountOf(nativeDenom) + coinBalance := reservePool.AmountOf(msg.Deposit.Denom) + liquidityCoinBalance := reservePool.AmountOf(moduleName) + + // calculate amount of UNI to be minted for sender + // and coin amount to be deposited + // TODO: verify + amtToMint := (liquidityCoinBalance.Mul(msg.DepositAmount)).Quo(nativeBalance) + coinAmountDeposited := (liquidityCoinsBalance.Mul(msg.DepositAmount)).Quo(nativeBalance) + nativeCoinDeposited := sdk.NewCoin(nativeDenom, msg.DepositAmount) + coinDeposited := sdk.NewCoin(msg.Deposit.Denom, coinAmountDeposited) + + if !k.HasCoins(ctx, msg.Sender, nativeCoinDeposited, coinDeposited) { + return sdk.ErrInsufficientCoins("sender does not have sufficient funds to add liquidity").Result() + } + + // transfer deposited liquidity into coinswaps ModuleAccount + err := k.SendCoins(ctx, msg.Sender, moduleName, nativeCoinDeposited, coinDeposited) + if err != nil { + return err.Result() + } + + // mint liquidity vouchers for sender + k.MintCoins(ctx, moduleName, amtToMint) + k.RecieveCoins(ctx, msg.Sender, moduleName, sdk.NewCoin(moduleName, amtToMint)) + + return sdk.Result{} +} + +// HandleMsgRemoveLiquidity handler for MsgRemoveLiquidity +func HandleMsgRemoveLiquidity(ctx sdk.Context, msg MsgRemoveLiquidity, k Keeper) sdk.Result { + // check that deadline has not passed + if ctx.BlockHeader().Time.After(msg.Deadline) { + return ErrInvalidDeadline(DefaultCodespace, "deadline has passed for MsgRemoveLiquidity") + } + + nativeDenom := k.GetNativeDenom(ctx) + moduleName := k.GetModuleName(ctx, nativeDenom, msg.Deposit.Denom) + + // check if reserve pool exists + reservePool, found := k.GetReservePool(ctx, msg.Withdraw.Denom) + if !found { + panic(fmt.Sprintf("error retrieving reserve pool for ModuleAccoint name: %s", moduleName)) + } + + nativeBalance := reservePool.AmountOf(nativeDenom) + coinBalance := reservePool.AmountOf(msg.Withdraw.Denom) + liquidityCoinBalance := reservePool.AmountOf(moduleName) + + // calculate amount of UNI to be burned for sender + // and coin amount to be returned + // TODO: verify, add amt burned + nativeWithdrawn := msg.WithdrawAmount.Mul(nativeBalance).Quo(liquidityCoinBalance) + coinWithdrawn := msg.WithdrawAmount.Mul(coinBalance).Quo(liquidityCoinBalance) + nativeCoin := sdk.NewCoin(nativeDenom, nativeWithdrawn) + exchangeCoin = sdk.NewCoin(msg.Withdraw.Denom, coinWithdrawn) + + if !k.HasCoins(ctx, msg.Sender, sdk.NewCoin(moduleName, amtBurned)) { + return sdk.ErrInsufficientCoins("sender does not have sufficient funds to remove liquidity").Result() + } + + // burn liquidity vouchers + k.SendCoins(ctx, msg.Sender, moduleName, sdk.NewCoin(moduleName, amtBurned)) + k.BurnCoins(ctx, moduleName, amtBurned) + + // transfer withdrawn liquidity from coinswaps ModuleAccount to sender's account + k.RecieveCoins(ctx, msg.Sender, moduleName, nativeCoin, coinDeposited) + + return sdk.Result{} +} diff --git a/x/coinswap/internal/keeper/keeper.go b/x/coinswap/internal/keeper/keeper.go new file mode 100644 index 000000000000..26a3ab17f727 --- /dev/null +++ b/x/coinswap/internal/keeper/keeper.go @@ -0,0 +1,121 @@ +package keeper + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" + "github.com/cosmos/cosmos-sdk/x/params" + supply "github.com/cosmos/cosmos-sdk/x/supply/types" + + "github.com/tendermint/tendermint/libs/log" +) + +// Keeper of the coinswap store +type Keeper struct { + cdc *codec.Codec + storeKey sdk.StoreKey + bk types.BankKeeper + sk types.SupplyKeeper + paramSpace params.Subspace +} + +// NewKeeper returns a coinswap keeper. It handles: +// - creating new ModuleAccounts for each trading pair +// - burning minting liquidity coins +// - sending to and from ModuleAccounts +func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, bk types.BankKeeper, sk types.SupplyKeeper, paramSpace params.Subspace) Keeper { + return Keeper{ + storeKey: key, + bk: bk, + sk: sk, + cdc: cdc, + paramSpace: paramSpace.WithKeyTable(types.ParamKeyTable()), + } +} + +// CreateReservePool initializes a new reserve pool by creating a +// ModuleAccount with minting and burning permissions. +func (keeper Keeper) CreateReservePool(ctx sdk.Context, moduleName string) { + moduleAcc := keeper.sk.GetModuleAccount(ctx, moduleName) + if moduleAcc != nil { + panic(fmt.Sprintf("reserve pool for %s already exists", moduleName)) + } + // TODO: add burning permissions + moduleAcc = supply.NewEmptyModuleAccount(moduleName, "minter") + keeper.sk.SetModuleAccount(ctx, moduleAcc) +} + +// HasCoins returns whether or not an account has at least coins. +func (keeper Keeper) HasCoins(ctx sdk.Context, addr sdk.AccAddress, coins ...sdk.Coin) bool { + return keeper.bk.HasCoins(ctx, addr, coins) +} + +// BurnCoins burns liquidity coins from the ModuleAccount at moduleName. The +// moduleName and denomination of the liquidity coins are the same. +func (keeper Keeper) BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Int) { + err := keeper.sk.BurnCoins(ctx, moduleName, sdk.NewCoins(sdk.NewCoin(moduleName, amt))) + if err != nil { + panic(err) + } +} + +// MintCoins mints liquidity coins to the ModuleAccount at moduleName. The +// moduleName and denomination of the liquidity coins are the same. +func (keeper Keeper) MintCoins(ctx sdk.Context, moduleName string, amt sdk.Int) { + err := keeper.sk.MintCoins(ctx, moduleName, sdk.NewCoins(sdk.NewCoin(moduleName, amt))) + if err != nil { + panic(err) + } +} + +// SendCoin sends coins from the address to the ModuleAccount at moduleName. +func (keeper Keeper) SendCoins(ctx sdk.Context, addr sdk.AccAddress, moduleName string, coins ...sdk.Coin) { + err := keeper.sk.SendCoinsFromAccountToModule(ctx, addr, moduleName, coins) + if err != nil { + panic(err) + } +} + +// RecieveCoin sends coins from the ModuleAccount at moduleName to the +// address provided. +func (keeper Keeper) RecieveCoins(ctx sdk.Context, addr sdk.AccAddress, moduleName string, coins ...sdk.Coin) { + err := keeper.sk.SendCoinsFromModuleToAccount(ctx, moduleName, addr, coins) + if err != nil { + panic(err) + } +} + +// GetReservePool returns the total balance of an reserve pool at the +// provided denomination. +func (keeper Keeper) GetReservePool(ctx sdk.Context, moduleName string) (coins sdk.Coins, found bool) { + acc := keeper.sk.GetModuleAccount(ctx, moduleName) + if acc != nil { + return acc.GetCoins(), true + } + return coins, false +} + +// GetNativeDenom returns the native denomination for this module from the +// global param store. +func (keeper Keeper) GetNativeDenom(ctx sdk.Context) (nativeDenom string) { + keeper.paramSpace.Get(ctx, types.KeyNativeDenom, &nativeDenom) + return +} + +// GetFeeParam returns the current FeeParam from the global param store +func (keeper Keeper) GetFeeParam(ctx sdk.Context) (feeParam types.FeeParam) { + keeper.paramSpace.Get(ctx, types.KeyFee, &feeParam) + return +} + +// SetParams sets the parameters for the coinswap module. +func (keeper Keeper) SetParams(ctx sdk.Context, params types.Params) { + keeper.paramSpace.SetParamSet(ctx, ¶ms) +} + +// Logger returns a module-specific logger. +func (keeper Keeper) Logger(ctx sdk.Context) log.Logger { + return ctx.Logger().With("module", fmt.Sprintf("x/%s", types.ModuleName)) +} diff --git a/x/coinswap/internal/keeper/keeper_test.go b/x/coinswap/internal/keeper/keeper_test.go new file mode 100644 index 000000000000..8985721de27f --- /dev/null +++ b/x/coinswap/internal/keeper/keeper_test.go @@ -0,0 +1,73 @@ +package keeper + +import ( + "testing" + + "github.com/stretchr/testify/require" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +const ( + moduleName = "swap:atom:btc" +) + +// test that the module account gets created with an initial +// balance of zero coins. +func TestCreateReservePool(t *testing.T) { + ctx, keeper, _ := createTestInput(t, sdk.NewInt(0), 0) + + moduleAcc := keeper.sk.GetModuleAccount(ctx, moduleName) + require.Nil(t, moduleAcc) + + keeper.CreateReservePool(ctx, moduleName) + moduleAcc = keeper.sk.GetModuleAccount(ctx, moduleName) + require.NotNil(t, moduleAcc) + require.Equal(t, sdk.Coins{}, moduleAcc.GetCoins(), "module account has non zero balance after creation") + + // attempt to recreate existing ModuleAccount + require.Panics(t, func() { keeper.CreateReservePool(ctx, moduleName) }) +} + +// test that the params can be properly set and retrieved +func TestParams(t *testing.T) { + ctx, keeper, _ := createTestInput(t, sdk.NewInt(0), 0) + + cases := []struct { + params types.Params + }{ + {types.DefaultParams()}, + {types.NewParams("pineapple", types.NewFeeParam(sdk.NewInt(5), sdk.NewInt(10)))}, + } + + for _, tc := range cases { + keeper.SetParams(ctx, tc.params) + + feeParam := keeper.GetFeeParam(ctx) + require.Equal(t, tc.params.Fee, feeParam) + + nativeDenom := keeper.GetNativeDenom(ctx) + require.Equal(t, tc.params.NativeDenom, nativeDenom) + } +} + +// test that non existent reserve pool returns false and +// that balance is updated. +func TestGetReservePool(t *testing.T) { + amt := sdk.NewInt(100) + ctx, keeper, accs := createTestInput(t, amt, 1) + + reservePool, found := keeper.GetReservePool(ctx, moduleName) + require.False(t, found) + + keeper.CreateReservePool(ctx, moduleName) + reservePool, found = keeper.GetReservePool(ctx, moduleName) + require.True(t, found) + + keeper.sk.SendCoinsFromAccountToModule(ctx, accs[0].GetAddress(), moduleName, sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, amt))) + reservePool, found = keeper.GetReservePool(ctx, moduleName) + reservePool, found = keeper.GetReservePool(ctx, moduleName) + require.True(t, found) + require.Equal(t, amt, reservePool.AmountOf(sdk.DefaultBondDenom)) +} diff --git a/x/coinswap/internal/keeper/querier.go b/x/coinswap/internal/keeper/querier.go new file mode 100644 index 000000000000..fdfc0e933158 --- /dev/null +++ b/x/coinswap/internal/keeper/querier.go @@ -0,0 +1,73 @@ +package keeper + +import ( + "fmt" + + abci "github.com/tendermint/tendermint/abci/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +// NewQuerier creates a querier for coinswap REST endpoints +func NewQuerier(k Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err sdk.Error) { + switch path[0] { + case types.QueryLiquidity: + return queryLiquidity(ctx, req, k) + + case types.QueryParameters: + return queryParameters(ctx, path[1:], req, k) + + default: + return nil, sdk.ErrUnknownRequest(fmt.Sprintf("%s is not a valid query request path", req.Path)) + } + } +} + +// queryLiquidity returns the total liquidity available for the provided denomination +// upon success or an error if the query fails. +func queryLiquidity(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + var denom string + err := k.cdc.UnmarshalJSON(req.Data, &denom) + if err != nil { + return nil, sdk.ErrUnknownRequest(sdk.AppendMsgToErr("incorrectly formatted request data", err.Error())) + } + + nativeDenom := k.GetNativeDenom(ctx) + moduleName, err := k.GetModuleName(nativeDenom, denom) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("could not retrieve module name", err.Error())) + } + reservePool, found := k.GetReservePool(ctx, moduleName) + if !found { + return nil, sdk.ErrInternal("reserve pool does not exist") + } + + bz, err := k.cdc.MarshalJSONIndent(reservePool.AmountOf(denom), "", " ") + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("could not marshal result to JSON", err.Error())) + } + return bz, nil +} + +// queryParameters returns coinswap module parameter queried for upon success +// or an error if the query fails +func queryParameters(ctx sdk.Context, path []string, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + switch path[0] { + case types.ParamFee: + bz, err := k.cdc.MarshalJSONIndent(k.GetFeeParam(ctx), "", " ") + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("could not marshal result to JSON", err.Error())) + } + return bz, nil + case types.ParamNativeDenom: + bz, err := k.cdc.MarshalJSONIndent(k.GetNativeDenom(ctx), "", " ") + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("could not marshal result to JSON", err.Error())) + } + return bz, nil + default: + return nil, sdk.ErrUnknownRequest(fmt.Sprintf("%s is not a valid query request path", req.Path)) + } +} diff --git a/x/coinswap/internal/keeper/querier_test.go b/x/coinswap/internal/keeper/querier_test.go new file mode 100644 index 000000000000..eee298f46ef6 --- /dev/null +++ b/x/coinswap/internal/keeper/querier_test.go @@ -0,0 +1,54 @@ +package keeper + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +func TestNewQuerier(t *testing.T) { + ctx, keeper, _ := createTestInput(t, sdk.NewInt(100), 2) + + req := abci.RequestQuery{ + Path: "", + Data: []byte{}, + } + + querier := NewQuerier(keeper) + + // query with incorrect path + res, err := querier(ctx, []string{"other"}, req) + require.Error(t, err) + require.Nil(t, res) + + // query for non existent reserve pool should return an error + req.Path = fmt.Sprintf("custom/%s/%s", types.QuerierRoute, types.QueryLiquidity) + req.Data = keeper.cdc.MustMarshalJSON("btc") + res, err = querier(ctx, []string{"liquidity"}, req) + require.Error(t, err) + require.Nil(t, res) + + // query for fee params + fee := types.DefaultParams().Fee + req.Path = fmt.Sprintf("custom/%s/%s/%s", types.QuerierRoute, types.QueryParameters, types.ParamFee) + req.Data = []byte{} + res, err = querier(ctx, []string{types.QueryParameters, types.ParamFee}, req) + keeper.cdc.UnmarshalJSON(res, &fee) + require.Nil(t, err) + require.Equal(t, fee, types.DefaultParams().Fee) + + // query for native denom param + var nativeDenom string + req.Path = fmt.Sprintf("custom/%s/%s/%s", types.QuerierRoute, types.QueryParameters, types.ParamNativeDenom) + res, err = querier(ctx, []string{types.QueryParameters, types.ParamNativeDenom}, req) + keeper.cdc.UnmarshalJSON(res, &nativeDenom) + require.Nil(t, err) + require.Equal(t, nativeDenom, types.DefaultParams().NativeDenom) +} + +// TODO: Add tests for valid liquidity queries diff --git a/x/coinswap/internal/keeper/swap.go b/x/coinswap/internal/keeper/swap.go new file mode 100644 index 000000000000..abc609c8c42d --- /dev/null +++ b/x/coinswap/internal/keeper/swap.go @@ -0,0 +1,89 @@ +package keeper + +import ( + "fmt" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" +) + +func (k Keeper) SwapCoins(ctx sdk.Context, sender sdk.AccAddress, coinSold, coinBought sdk.Coin) error { + if !k.HasCoins(ctx, sender, coinSold) { + return sdk.ErrInsufficientCoins(fmt.Sprintf("sender account does not have sufficient amount of %s to fulfill the swap order", coinSold.Denom)) + } + + moduleName, err := k.GetModuleName(coinSold.Denom, coinBought.Denom) + if err != nil { + return err + } + + k.SendCoins(ctx, sender, moduleName, coinSold) + k.RecieveCoins(ctx, sender, moduleName, coinBought) + return nil +} + +// GetInputAmount returns the amount of coins sold (calculated) given the output amount being bought (exact) +// The fee is included in the output coins being bought +// https://github.com/runtimeverification/verified-smart-contracts/blob/uniswap/uniswap/x-y-k.pdf +// TODO: continue using numerator/denominator -> open issue for eventually changing to sdk.Dec +func (k Keeper) GetInputAmount(ctx sdk.Context, outputAmt sdk.Int, inputDenom, outputDenom string) sdk.Int { + moduleName, err := k.GetModuleName(inputDenom, outputDenom) + if err != nil { + panic(err) + } + reservePool, found := k.GetReservePool(ctx, moduleName) + if !found { + panic(fmt.Sprintf("reserve pool for %s not found", moduleName)) + } + inputBalance := reservePool.AmountOf(inputDenom) + outputBalance := reservePool.AmountOf(outputDenom) + fee := k.GetFeeParam(ctx) + + numerator := inputBalance.Mul(outputAmt).Mul(fee.Denominator) + denominator := (outputBalance.Sub(outputAmt)).Mul(fee.Numerator) + return numerator.Quo(denominator).Add(sdk.OneInt()) +} + +// GetOutputAmount returns the amount of coins bought (calculated) given the input amount being sold (exact) +// The fee is included in the input coins being bought +// https://github.com/runtimeverification/verified-smart-contracts/blob/uniswap/uniswap/x-y-k.pdf +// TODO: continue using numerator/denominator -> open issue for eventually changing to sdk.Dec +func (k Keeper) GetOutputAmount(ctx sdk.Context, inputAmt sdk.Int, inputDenom, outputDenom string) sdk.Int { + moduleName, err := k.GetModuleName(inputDenom, outputDenom) + if err != nil { + panic(err) + } + reservePool, found := k.GetReservePool(ctx, moduleName) + if !found { + panic(fmt.Sprintf("reserve pool for %s not found", moduleName)) + } + inputBalance := reservePool.AmountOf(inputDenom) + outputBalance := reservePool.AmountOf(outputDenom) + fee := k.GetFeeParam(ctx) + + inputAmtWithFee := inputAmt.Mul(fee.Numerator) + numerator := inputAmtWithFee.Mul(outputBalance) + denominator := inputBalance.Mul(fee.Denominator).Add(inputAmtWithFee) + return numerator.Quo(denominator) +} + +// IsDoubleSwap returns true if the trade requires a double swap. +func (k Keeper) IsDoubleSwap(ctx sdk.Context, denom1, denom2 string) bool { + nativeDenom := k.GetNativeDenom(ctx) + return denom1 != nativeDenom && denom2 != nativeDenom +} + +// GetModuleName returns the ModuleAccount name for the provided denominations. +// The module name is in the format of 'swap:denom:denom' where the denominations +// are sorted alphabetically. +func (k Keeper) GetModuleName(denom1, denom2 string) (string, error) { + switch strings.Compare(denom1, denom2) { + case -1: + return "swap:" + denom1 + ":" + denom2, nil + case 1: + return "swap:" + denom2 + ":" + denom1, nil + default: + return "", types.ErrEqualDenom(types.DefaultCodespace, "denomnations for forming module name are equal") + } +} diff --git a/x/coinswap/internal/keeper/swap_test.go b/x/coinswap/internal/keeper/swap_test.go new file mode 100644 index 000000000000..404c09d8074d --- /dev/null +++ b/x/coinswap/internal/keeper/swap_test.go @@ -0,0 +1,35 @@ +package keeper + +import ( + "testing" + + "github.com/stretchr/testify/require" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +var ( + native = sdk.DefaultBondDenom +) + +func TestIsDoubleSwap(t *testing.T) { + ctx, keeper, _ := createTestInput(t, sdk.NewInt(0), 0) + + cases := []struct { + name string + denom1 string + denom2 string + isDoubleSwap bool + }{ + {"denom1 is native", native, "btc", false}, + {"denom2 is native", "btc", native, false}, + {"neither denom is native", "eth", "btc", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doubleSwap := keeper.IsDoubleSwap(ctx, tc.denom1, tc.denom2) + require.Equal(t, tc.isDoubleSwap, doubleSwap) + }) + } +} diff --git a/x/coinswap/internal/keeper/test_common.go b/x/coinswap/internal/keeper/test_common.go new file mode 100644 index 000000000000..94cbb6edf8ba --- /dev/null +++ b/x/coinswap/internal/keeper/test_common.go @@ -0,0 +1,84 @@ +package keeper + +import ( + "testing" + + abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/crypto/secp256k1" + dbm "github.com/tendermint/tendermint/libs/db" + "github.com/tendermint/tendermint/libs/log" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/store" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/cosmos/cosmos-sdk/x/coinswap/internal/types" + "github.com/cosmos/cosmos-sdk/x/params" + supplyKeeper "github.com/cosmos/cosmos-sdk/x/supply/keeper" + supply "github.com/cosmos/cosmos-sdk/x/supply/types" +) + +// create a codec used only for testing +func makeTestCodec() *codec.Codec { + var cdc = codec.New() + + bank.RegisterCodec(cdc) + auth.RegisterCodec(cdc) + supply.RegisterCodec(cdc) + types.RegisterCodec(cdc) + sdk.RegisterCodec(cdc) + codec.RegisterCrypto(cdc) + + return cdc +} + +func createTestInput(t *testing.T, amt sdk.Int, nAccs int64) (sdk.Context, Keeper, []auth.Account) { + keyAcc := sdk.NewKVStoreKey(auth.StoreKey) + keyParams := sdk.NewKVStoreKey(params.StoreKey) + tkeyParams := sdk.NewTransientStoreKey(params.TStoreKey) + keySupply := sdk.NewKVStoreKey(supply.StoreKey) + keyCoinswap := sdk.NewKVStoreKey(types.StoreKey) + + db := dbm.NewMemDB() + ms := store.NewCommitMultiStore(db) + ms.MountStoreWithDB(keyAcc, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(keyParams, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(tkeyParams, sdk.StoreTypeTransient, db) + ms.MountStoreWithDB(keySupply, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(keyCoinswap, sdk.StoreTypeIAVL, db) + err := ms.LoadLatestVersion() + require.Nil(t, err) + + cdc := makeTestCodec() + ctx := sdk.NewContext(ms, abci.Header{ChainID: "coinswap-chain"}, false, log.NewNopLogger()) + + pk := params.NewKeeper(cdc, keyParams, tkeyParams, params.DefaultCodespace) + ak := auth.NewAccountKeeper(cdc, keyAcc, pk.Subspace(auth.DefaultParamspace), auth.ProtoBaseAccount) + bk := bank.NewBaseKeeper(ak, pk.Subspace(bank.DefaultParamspace), bank.DefaultCodespace) + + initialCoins := sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, amt)) + accs := createTestAccs(ctx, int(nAccs), initialCoins, &ak) + + sk := supplyKeeper.NewKeeper(cdc, keySupply, ak, bk, supplyKeeper.DefaultCodespace, []string{supply.Basic}, []string{supply.Minter}, []string{supply.Burner}) + keeper := NewKeeper(cdc, keyCoinswap, bk, sk, pk.Subspace(types.DefaultParamspace)) + keeper.SetParams(ctx, types.DefaultParams()) + + return ctx, keeper, accs +} + +func createTestAccs(ctx sdk.Context, numAccs int, initialCoins sdk.Coins, ak *auth.AccountKeeper) (accs []auth.Account) { + for i := 0; i < numAccs; i++ { + privKey := secp256k1.GenPrivKey() + pubKey := privKey.PubKey() + addr := sdk.AccAddress(pubKey.Address()) + acc := auth.NewBaseAccountWithAddress(addr) + acc.Coins = initialCoins + acc.PubKey = pubKey + acc.AccountNumber = uint64(i) + ak.SetAccount(ctx, &acc) + } + return +} diff --git a/x/coinswap/internal/types/codec.go b/x/coinswap/internal/types/codec.go new file mode 100644 index 000000000000..dc7b464e7239 --- /dev/null +++ b/x/coinswap/internal/types/codec.go @@ -0,0 +1,22 @@ +package types + +import ( + "github.com/cosmos/cosmos-sdk/codec" +) + +// RegisterCodec registers concrete types on the codec. +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(MsgSwapOrder{}, "cosmos-sdk/MsgSwapOrder", nil) + cdc.RegisterConcrete(MsgAddLiquidity{}, "cosmos-sdk/MsgAddLiquidity", nil) + cdc.RegisterConcrete(MsgRemoveLiquidity{}, "cosmos-sdk/MsgRemoveLiquidity", nil) +} + +// ModuleCdc generic sealed codec to be used throughout module +var ModuleCdc *codec.Codec + +func init() { + ModuleCdc = codec.New() + RegisterCodec(ModuleCdc) + codec.RegisterCrypto(ModuleCdc) + ModuleCdc.Seal() +} diff --git a/x/coinswap/internal/types/errors.go b/x/coinswap/internal/types/errors.go new file mode 100644 index 000000000000..f2bb1ceb5b22 --- /dev/null +++ b/x/coinswap/internal/types/errors.go @@ -0,0 +1,51 @@ +// nolint +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +const ( + DefaultCodespace sdk.CodespaceType = ModuleName + + CodeReservePoolAlreadyExists sdk.CodeType = 101 + CodeEqualDenom sdk.CodeType = 102 + CodeInvalidDeadline sdk.CodeType = 103 + CodeNotPositive sdk.CodeType = 104 + CodeConstraintNotMet sdk.CodeType = 105 +) + +func ErrReservePoolAlreadyExists(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeReservePoolAlreadyExists, msg) + } + return sdk.NewError(codespace, CodeReservePoolAlreadyExists, "reserve pool already exists") +} + +func ErrEqualDenom(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeEqualDenom, msg) + } + return sdk.NewError(codespace, CodeEqualDenom, "input and output denomination are equal") +} + +func ErrInvalidDeadline(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeInvalidDeadline, msg) + } + return sdk.NewError(codespace, CodeInvalidDeadline, "invalid deadline") +} + +func ErrNotPositive(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeNotPositive, msg) + } + return sdk.NewError(codespace, CodeNotPositive, "amount is not positive") +} + +func ErrConstraintNotMet(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeConstraintNotMet, msg) + } + return sdk.NewError(codespace, CodeConstraintNotMet, "constraint not met") +} diff --git a/x/coinswap/internal/types/expected_keepers.go b/x/coinswap/internal/types/expected_keepers.go new file mode 100644 index 000000000000..d45600adafae --- /dev/null +++ b/x/coinswap/internal/types/expected_keepers.go @@ -0,0 +1,24 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + supply "github.com/cosmos/cosmos-sdk/x/supply/exported" +) + +// BankKeeper defines the expected bank keeper +type BankKeeper interface { + HasCoins(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins) bool + GetCoins(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins +} + +// SupplyKeeper defines the expected supply keeper +type SupplyKeeper interface { + GetModuleAccount(ctx sdk.Context, moduleName string) supply.ModuleAccountI + SetModuleAccount(ctx sdk.Context, macc supply.ModuleAccountI) + + SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) sdk.Error + SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) sdk.Error + + MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) sdk.Error + BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) sdk.Error +} diff --git a/x/coinswap/internal/types/keys.go b/x/coinswap/internal/types/keys.go new file mode 100644 index 000000000000..0e11e62b003e --- /dev/null +++ b/x/coinswap/internal/types/keys.go @@ -0,0 +1,15 @@ +package types + +const ( + // ModuleName is the name of the module. + ModuleName = "coinswap" + + // RouterKey is the message route for the coinswap module. + RouterKey = ModuleName + + // StoreKey is the default store key for the coinswap module. + StoreKey = ModuleName + + // QuerierRoute is the querier route for the coinswap module. + QuerierRoute = StoreKey +) diff --git a/x/coinswap/internal/types/msgs.go b/x/coinswap/internal/types/msgs.go new file mode 100644 index 000000000000..ec4998d1d6ac --- /dev/null +++ b/x/coinswap/internal/types/msgs.go @@ -0,0 +1,226 @@ +package types + +import ( + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +var ( + _ sdk.Msg = MsgSwapOrder{} + _ sdk.Msg = MsgAddLiquidity{} + _ sdk.Msg = MsgRemoveLiquidity{} +) + +/* --------------------------------------------------------------------------- */ +// MsgSwapOrder +/* --------------------------------------------------------------------------- */ + +// MsgSwap Order - struct for swapping a coin +// Input and Output can either be exact or calculated. +// An exact coin has the senders desired buy or sell amount. +// A calculated coin has the desired denomination and bounded amount +// the sender is willing to buy or sell in this order. +type MsgSwapOrder struct { + Input sdk.Coin `json:"input"` // the amount the sender is trading + Output sdk.Coin `json:"output"` // the amount the sender is recieivng + Deadline time.Time `json:"deadline"` // deadline for the transaction to still be considered valid + Sender sdk.AccAddress `json:"sender"` + Recipient sdk.AccAddress `json:"recipient"` + IsBuyOrder bool `json:"is_buy_order"` // boolean indicating whether the order should be treated as a buy or sell +} + +// NewMsgSwapOrder creates a new MsgSwapOrder object. +func NewMsgSwapOrder( + input, output sdk.Coin, deadline time.Time, + sender, recipient sdk.AccAddress, isBuyOrder bool, +) MsgSwapOrder { + + return MsgSwapOrder{ + Input: input, + Output: output, + Deadline: deadline, + Sender: sender, + Recipient: recipient, + IsBuyOrder: isBuyOrder, + } +} + +// Route Implements Msg. +func (msg MsgSwapOrder) Route() string { return RouterKey } + +// Type Implements Msg. +func (msg MsgSwapOrder) Type() string { return "swap_order" } + +// ValidateBasic Implements Msg. +func (msg MsgSwapOrder) ValidateBasic() sdk.Error { + if !msg.Input.IsValid() { + return sdk.ErrInvalidCoins("input coin is invalid: " + msg.Input.String()) + } + if msg.Input.IsZero() { + return sdk.ErrInvalidCoins("input coin is zero: " + msg.Input.String()) + } + if !msg.Output.IsValid() { + return sdk.ErrInvalidCoins("output coin is invalid: " + msg.Output.String()) + } + if msg.Output.IsZero() { + return sdk.ErrInvalidCoins("output coin is zero: " + msg.Output.String()) + } + if msg.Input.Denom == msg.Output.Denom { + return ErrEqualDenom(DefaultCodespace, "") + } + if msg.Deadline.IsZero() { + return ErrInvalidDeadline(DefaultCodespace, "deadline for MsgSwapOrder not initialized") + } + if msg.Sender.Empty() { + return sdk.ErrInvalidAddress("invalid sender address") + } + if msg.Recipient.Empty() { + return sdk.ErrInvalidAddress("invalid recipient address") + } + return nil +} + +// GetSignBytes Implements Msg. +func (msg MsgSwapOrder) GetSignBytes() []byte { + return sdk.MustSortJSON(ModuleCdc.MustMarshalJSON(msg)) +} + +// GetSigners Implements Msg. +func (msg MsgSwapOrder) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +/* --------------------------------------------------------------------------- */ +// MsgAddLiquidity +/* --------------------------------------------------------------------------- */ + +// MsgAddLiquidity - struct for adding liquidity to a reserve pool +type MsgAddLiquidity struct { + Deposit sdk.Coin `json:"deposit"` // coin to be deposited as liquidity with an upper bound for its amount + DepositAmount sdk.Int `json:"deposit_amount"` // exact amount of native asset being add to the liquidity pool + MinReward sdk.Int `json:"min_reward"` // lower bound UNI sender is willing to accept for deposited coins + Deadline time.Time `json:"deadline"` + Sender sdk.AccAddress `json:"sender"` +} + +// NewMsgAddLiquidity creates a new MsgAddLiquidity object. +func NewMsgAddLiquidity( + deposit sdk.Coin, depositAmount, minReward sdk.Int, + deadline time.Time, sender sdk.AccAddress, +) MsgAddLiquidity { + + return MsgAddLiquidity{ + Deposit: deposit, + DepositAmount: depositAmount, + MinReward: minReward, + Deadline: deadline, + Sender: sender, + } +} + +// Type Implements Msg. +func (msg MsgAddLiquidity) Route() string { return RouterKey } + +// Type Implements Msg. +func (msg MsgAddLiquidity) Type() string { return "add_liquidity" } + +// ValidateBasic Implements Msg. +func (msg MsgAddLiquidity) ValidateBasic() sdk.Error { + if !msg.Deposit.IsValid() { + return sdk.ErrInvalidCoins("coin is invalid: " + msg.Deposit.String()) + } + if msg.Deposit.IsZero() { + return sdk.ErrInvalidCoins("coin is zero: " + msg.Deposit.String()) + } + if !msg.DepositAmount.IsPositive() { + return ErrNotPositive(DefaultCodespace, "deposit amount provided is not positive") + } + if !msg.MinReward.IsPositive() { + return ErrNotPositive(DefaultCodespace, "minimum liquidity is not positive") + } + if msg.Deadline.IsZero() { + return ErrInvalidDeadline(DefaultCodespace, "deadline for MsgAddLiquidity not initialized") + } + if msg.Sender.Empty() { + return sdk.ErrInvalidAddress("invalid sender address") + } + return nil +} + +// GetSignBytes Implements Msg. +func (msg MsgAddLiquidity) GetSignBytes() []byte { + return sdk.MustSortJSON(ModuleCdc.MustMarshalJSON(msg)) +} + +// GetSigners Implements Msg. +func (msg MsgAddLiquidity) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +/* --------------------------------------------------------------------------- */ +// MsgRemoveLiquidity +/* --------------------------------------------------------------------------- */ + +// MsgRemoveLiquidity - struct for removing liquidity from a reserve pool +type MsgRemoveLiquidity struct { + Withdraw sdk.Coin `json:"withdraw"` // coin to be withdrawn with a lower bound for its amount + WithdrawAmount sdk.Int `json:"withdraw_amount"` // amount of UNI to be burned to withdraw liquidity from a reserve pool + MinNative sdk.Int `json:"min_native"` // minimum amount of the native asset the sender is willing to accept + Deadline time.Time `json:"deadline"` + Sender sdk.AccAddress `json:"sender"` +} + +// NewMsgRemoveLiquidity creates a new MsgRemoveLiquidity object +func NewMsgRemoveLiquidity( + withdraw sdk.Coin, withdrawAmount, minNative sdk.Int, + deadline time.Time, sender sdk.AccAddress, +) MsgRemoveLiquidity { + + return MsgRemoveLiquidity{ + Withdraw: withdraw, + WithdrawAmount: withdrawAmount, + MinNative: minNative, + Deadline: deadline, + Sender: sender, + } +} + +// Type Implements Msg. +func (msg MsgRemoveLiquidity) Route() string { return RouterKey } + +// Type Implements Msg. +func (msg MsgRemoveLiquidity) Type() string { return "remove_liquidity" } + +// ValidateBasic Implements Msg. +func (msg MsgRemoveLiquidity) ValidateBasic() sdk.Error { + if !msg.WithdrawAmount.IsPositive() { + return ErrNotPositive(DefaultCodespace, "withdraw amount is not positive") + } + if !msg.Withdraw.IsValid() { + return sdk.ErrInvalidCoins("coin is invalid: " + msg.Withdraw.String()) + } + if msg.Withdraw.IsZero() { + return sdk.ErrInvalidCoins("coin is zero: " + msg.Withdraw.String()) + } + if !msg.MinNative.IsPositive() { + return ErrNotPositive(DefaultCodespace, "minimum native amount is not positive") + } + if msg.Deadline.IsZero() { + return ErrInvalidDeadline(DefaultCodespace, "deadline for MsgRemoveLiquidity not initialized") + } + if msg.Sender.Empty() { + return sdk.ErrInvalidAddress("invalid sender address") + } + return nil +} + +// GetSignBytes Implements Msg. +func (msg MsgRemoveLiquidity) GetSignBytes() []byte { + return sdk.MustSortJSON(ModuleCdc.MustMarshalJSON(msg)) +} + +// GetSigners Implements Msg. +func (msg MsgRemoveLiquidity) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} diff --git a/x/coinswap/internal/types/msgs_test.go b/x/coinswap/internal/types/msgs_test.go new file mode 100644 index 000000000000..6851b3d2d3de --- /dev/null +++ b/x/coinswap/internal/types/msgs_test.go @@ -0,0 +1,97 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// test ValidateBasic for MsgSwapOrder +func TestMsgSwapOrder(t *testing.T) { + tests := []struct { + name string + msg MsgSwapOrder + expectPass bool + }{ + {"no input coin", NewMsgSwapOrder(sdk.Coin{}, output, deadline, sender, recipient, true), false}, + {"zero input coin", NewMsgSwapOrder(sdk.NewCoin(denom0, sdk.ZeroInt()), output, deadline, sender, recipient, true), false}, + {"no output coin", NewMsgSwapOrder(input, sdk.Coin{}, deadline, sender, recipient, false), false}, + {"zero output coin", NewMsgSwapOrder(input, sdk.NewCoin(denom1, sdk.ZeroInt()), deadline, sender, recipient, true), false}, + {"swap and coin denomination are equal", NewMsgSwapOrder(input, sdk.NewCoin(denom0, amt), deadline, sender, recipient, true), false}, + {"deadline not initialized", NewMsgSwapOrder(input, output, emptyTime, sender, recipient, true), false}, + {"no sender", NewMsgSwapOrder(input, output, deadline, emptyAddr, recipient, true), false}, + {"no recipient", NewMsgSwapOrder(input, output, deadline, sender, emptyAddr, true), false}, + {"valid MsgSwapOrder", NewMsgSwapOrder(input, output, deadline, sender, recipient, true), true}, + {"sender and recipient are same", NewMsgSwapOrder(input, output, deadline, sender, sender, true), true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.msg.ValidateBasic() + if tc.expectPass { + require.Nil(t, err) + } else { + require.NotNil(t, err) + } + }) + } +} + +// test ValidateBasic for MsgAddLiquidity +func TestMsgAddLiquidity(t *testing.T) { + tests := []struct { + name string + msg MsgAddLiquidity + expectPass bool + }{ + {"no deposit coin", NewMsgAddLiquidity(sdk.Coin{}, amt, sdk.OneInt(), deadline, sender), false}, + {"zero deposit coin", NewMsgAddLiquidity(sdk.NewCoin(denom1, sdk.ZeroInt()), amt, sdk.OneInt(), deadline, sender), false}, + {"invalid withdraw amount", NewMsgAddLiquidity(input, sdk.ZeroInt(), sdk.OneInt(), deadline, sender), false}, + {"invalid minumum reward bound", NewMsgAddLiquidity(input, amt, sdk.ZeroInt(), deadline, sender), false}, + {"deadline not initialized", NewMsgAddLiquidity(input, amt, sdk.OneInt(), emptyTime, sender), false}, + {"empty sender", NewMsgAddLiquidity(input, amt, sdk.OneInt(), deadline, emptyAddr), false}, + {"valid MsgAddLiquidity", NewMsgAddLiquidity(input, amt, sdk.OneInt(), deadline, sender), true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.msg.ValidateBasic() + if tc.expectPass { + require.Nil(t, err) + } else { + require.NotNil(t, err) + } + }) + } +} + +// test ValidateBasic for MsgRemoveLiquidity +func TestMsgRemoveLiquidity(t *testing.T) { + tests := []struct { + name string + msg MsgRemoveLiquidity + expectPass bool + }{ + {"no withdraw coin", NewMsgRemoveLiquidity(sdk.Coin{}, amt, sdk.OneInt(), deadline, sender), false}, + {"zero withdraw coin", NewMsgRemoveLiquidity(sdk.NewCoin(denom1, sdk.ZeroInt()), amt, sdk.OneInt(), deadline, sender), false}, + {"invalid deposit amount", NewMsgRemoveLiquidity(input, sdk.ZeroInt(), sdk.OneInt(), deadline, sender), false}, + {"invalid minimum native bound", NewMsgRemoveLiquidity(input, amt, sdk.ZeroInt(), deadline, sender), false}, + {"deadline not initialized", NewMsgRemoveLiquidity(input, amt, sdk.OneInt(), emptyTime, sender), false}, + {"empty sender", NewMsgRemoveLiquidity(input, amt, sdk.OneInt(), deadline, emptyAddr), false}, + {"valid MsgRemoveLiquidity", NewMsgRemoveLiquidity(input, amt, sdk.OneInt(), deadline, sender), true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.msg.ValidateBasic() + if tc.expectPass { + require.Nil(t, err) + } else { + require.NotNil(t, err) + } + }) + } + +} diff --git a/x/coinswap/internal/types/params.go b/x/coinswap/internal/types/params.go new file mode 100644 index 000000000000..018c5129ae51 --- /dev/null +++ b/x/coinswap/internal/types/params.go @@ -0,0 +1,105 @@ +package types + +import ( + "fmt" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params" +) + +const ( + DefaultParamspace = ModuleName +) + +// Parameter store keys +var ( + KeyNativeDenom = []byte("nativeDenom") + KeyFee = []byte("fee") +) + +// Params defines the fee and native denomination for coinswap +type Params struct { + NativeDenom string `json:"native_denom"` + Fee FeeParam `json:"fee"` +} + +func NewParams(nativeDenom string, fee FeeParam) Params { + return Params{ + NativeDenom: nativeDenom, + Fee: fee, + } +} + +// FeeParam defines the numerator and denominator used in calculating the +// amount to be reserved as a liquidity fee. +// TODO: come up with a more descriptive name than Numerator/Denominator +// Fee = 1 - (Numerator / Denominator) TODO: move this to spec +type FeeParam struct { + Numerator sdk.Int `json:"fee_numerator"` + Denominator sdk.Int `json:"fee_denominator"` +} + +func NewFeeParam(numerator, denominator sdk.Int) FeeParam { + return FeeParam{ + Numerator: numerator, + Denominator: denominator, + } +} + +// ParamKeyTable returns the KeyTable for coinswap module +func ParamKeyTable() params.KeyTable { + return params.NewKeyTable().RegisterParamSet(&Params{}) +} + +// Implements params.ParamSet. +func (p *Params) ParamSetPairs() params.ParamSetPairs { + return params.ParamSetPairs{ + {KeyNativeDenom, &p.NativeDenom}, + {KeyFee, &p.Fee}, + } +} + +// String returns a human readable string representation of the parameters. +func (p Params) String() string { + return fmt.Sprintf(`Params: +Native Denom: %s +Fee: %s`, p.NativeDenom, p.Fee, + ) +} + +// String returns a decimal representation of the parameters. +func (fp FeeParam) String() sdk.Dec { + feeN := sdk.NewDecFromInt(fp.Numerator) + feeD := sdk.NewDecFromInt(fp.Denominator) + fee := sdk.OneDec().Sub((feeN.Quo(feeD))) + return fee +} + +// DefaultParams returns the default coinswap module parameters +func DefaultParams() Params { + feeParam := NewFeeParam(sdk.NewInt(997), sdk.NewInt(1000)) + + return Params{ + NativeDenom: sdk.DefaultBondDenom, + Fee: feeParam, + } +} + +// ValidateParams validates a set of params +func ValidateParams(p Params) error { + // TODO: ensure equivalent sdk.validateDenom validation + if strings.TrimSpace(p.NativeDenom) != "" { + return fmt.Errorf("native denomination must not be empty") + } + if !p.Fee.Numerator.IsPositive() { + return fmt.Errorf("fee numerator is not positive: %v", p.Fee.Numerator) + } + if !p.Fee.Denominator.IsPositive() { + return fmt.Errorf("fee denominator is not positive: %v", p.Fee.Denominator) + } + if p.Fee.Numerator.GTE(p.Fee.Denominator) { + return fmt.Errorf("fee numerator is greater than or equal to fee numerator") + } + return nil +} diff --git a/x/coinswap/internal/types/params_test.go b/x/coinswap/internal/types/params_test.go new file mode 100644 index 000000000000..b6e9226c5a0f --- /dev/null +++ b/x/coinswap/internal/types/params_test.go @@ -0,0 +1,38 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func TestValidateParams(t *testing.T) { + // check that valid case work + defaultParams := DefaultParams() + err := ValidateParams(defaultParams) + require.Nil(t, err) + + // all cases should return an error + invalidTests := []struct { + name string + params Params + }{ + {"empty native denom", NewParams(" ", defaultParams.Fee)}, + {"native denom with caps", NewParams("aTom", defaultParams.Fee)}, + {"native denom too short", NewParams("a", defaultParams.Fee)}, + {"native denom too long", NewParams("a very long coin denomination", defaultParams.Fee)}, + {"fee numerator == denominator", NewParams(defaultParams.NativeDenom, NewFeeParam(sdk.NewInt(1000), sdk.NewInt(1000)))}, + {"fee numerator > denominator", NewParams(defaultParams.NativeDenom, NewFeeParam(sdk.NewInt(10000), sdk.NewInt(10)))}, + {"fee numerator negative", NewParams(defaultParams.NativeDenom, NewFeeParam(sdk.NewInt(-1), sdk.NewInt(10)))}, + {"fee denominator negative", NewParams(defaultParams.NativeDenom, NewFeeParam(sdk.NewInt(10), sdk.NewInt(-1)))}, + } + + for _, tc := range invalidTests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateParams(tc.params) + require.NotNil(t, err) + }) + } +} diff --git a/x/coinswap/internal/types/querier.go b/x/coinswap/internal/types/querier.go new file mode 100644 index 000000000000..4f0d7cc70a43 --- /dev/null +++ b/x/coinswap/internal/types/querier.go @@ -0,0 +1,27 @@ +package types + +import ( + "strings" +) + +const ( + // Query endpoints supported by the coinswap querier + QueryLiquidity = "liquidity" + QueryParameters = "parameters" + + ParamFee = "fee" + ParamNativeDenom = "nativeDenom" +) + +// defines the params for the following queries: +// - 'custom/coinswap/liquidity' +type QueryLiquidityParams struct { + NonNativeDenom string +} + +// Params used for querying liquidity +func NewQueryLiquidityParams(nonNativeDenom string) QueryLiquidityParams { + return QueryLiquidityParams{ + NonNativeDenom: strings.TrimSpace(nonNativeDenom), + } +} diff --git a/x/coinswap/internal/types/test_common.go b/x/coinswap/internal/types/test_common.go new file mode 100644 index 000000000000..ebe296b1b296 --- /dev/null +++ b/x/coinswap/internal/types/test_common.go @@ -0,0 +1,28 @@ +package types + +import ( + "github.com/tendermint/tendermint/crypto/ed25519" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// nolint: deadcode unused +var ( + amt = sdk.NewInt(100) + + sender_pk = ed25519.GenPrivKey().PubKey() + recipient_pk = ed25519.GenPrivKey().PubKey() + sender = sdk.AccAddress(sender_pk.Address()) + recipient = sdk.AccAddress(recipient_pk.Address()) + + denom0 = "atom" + denom1 = "btc" + + input = sdk.NewCoin(denom0, sdk.NewInt(1000)) + output = sdk.NewCoin(denom1, sdk.NewInt(500)) + deadline = time.Now() + + emptyAddr sdk.AccAddress + emptyTime time.Time +) diff --git a/x/coinswap/module.go b/x/coinswap/module.go new file mode 100644 index 000000000000..02aeee07df83 --- /dev/null +++ b/x/coinswap/module.go @@ -0,0 +1,119 @@ +package coinswap + +import ( + "encoding/json" + + "github.com/gorilla/mux" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + abci "github.com/tendermint/tendermint/abci/types" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} +) + +// AppModuleBasic app module basics object +type AppModuleBasic struct{} + +// Name defines module name +func (AppModuleBasic) Name() string { + return ModuleName +} + +// RegisterCodec registers module codec +func (AppModuleBasic) RegisterCodec(cdc *codec.Codec) { + RegisterCodec(cdc) +} + +// DefaultGenesis default genesis state +func (AppModuleBasic) DefaultGenesis() json.RawMessage { + return ModuleCdc.MustMarshalJSON(DefaultGenesisState()) +} + +// ValidateGenesis module validate genesis +func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { + var data GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &data) + if err != nil { + return err + } + return ValidateGenesis(data) +} + +// RegisterRESTRoutes registers rest routes +func (AppModuleBasic) RegisterRESTRoutes(_ context.CLIContext, _ *mux.Router) { + +} + +// GetTxCmd returns the root tx command of this module +func (AppModuleBasic) GetTxCmd(_ *codec.Codec) *cobra.Command { return nil } + +// GetQueryCmd returns the root query command of this module +func (AppModuleBasic) GetQueryCmd(_ *codec.Codec) *cobra.Command { return nil } + +// AppModule coinswap app module +type AppModule struct { + AppModuleBasic + keeper Keeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper) module.AppModule { + return sdk.NewGenesisOnlyAppModule(AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + }) +} + +// RegisterInvariants registers the coinswap module invariants +func (am AppModule) RegisterInvariants(ir sdk.InvariantRouter) { + RegisterInvariants(ir, am.keeper) +} + +// Route module message route name +func (AppModule) Route() string { + return RouterKey +} + +// NewHandler module handler +func (am AppModule) NewHandler() sdk.Handler { + return NewHandler(am.keeper) +} + +// QuerierRoute module querier route name +func (AppModule) QuerierRoute() string { + return QuerierRoute +} + +// NewQuerierHandler module querier +func (am AppModule) NewQuerierHandler() sdk.Querier { + return NewQuerier(am.keeper) +} + +// InitGenesis coinswap module init-genesis +func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { + var genesisState GenesisState + ModuleCdc.MustUnmarshalJSON(data, &genesisState) + InitGenesis(ctx, am.keeper, genesisState) + return []abci.ValidatorUpdate{} +} + +// ExportGenesis module export genesis +func (am AppModule) ExportGenesis(ctx sdk.Context) json.RawMessage { + gs := ExportGenesis(ctx, am.keeper) + return ModuleCdc.MustMarshalJSON(gs) +} + +// BeginBlock module begin-block +func (AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) {} + +// EndBlock module end-block +func (am AppModule) EndBlock(ctx sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + return []abci.ValidatorUpdate{} +}