From 9d9b169e6a25f2d2ab38ad0eaa9ac435f94a3d65 Mon Sep 17 00:00:00 2001 From: Denali Marsh Date: Tue, 8 Jun 2021 17:19:12 +0200 Subject: [PATCH] Swap module scaffolding and params (#922) * swap module scaffolding * global swap fee * can't think of a reason for begin blocker; removing for abci.go for now; * test pair types; refactor pair name logic; simplify pairs validation and fix stack overflow error * check comparison * use test package * init swap module genesis * add basic marshall tests * remove reward apy from pairs * fix integration helpers * use max swap fee constant; fix validation of swap fee; add tests to cover param validation and param set setup * use noerror over nil * start genesis tests * test param set validation mirrors param validation * add genesis tests * remove print statement * add subtests for genesis test cases; add extra querier test for unknown route; add keeper params testing * add spec * update swagger * find replace hard -> swap in comments * remove unused method * rename pairs to allowed pools; pool is more commonly used, and allowedPool makes it more clear what swap parameter is for. In addition, we won't conflict with Pool data structure for storing a created pool in the store. * remove generated link * missed spec rename * validate token order for allowed pools * fix swagger * json should be snakecase; change allowedPools to allowed_pools Co-authored-by: Nick DeLuca --- app/app.go | 14 +- app/test_common.go | 2 + swagger-ui/swagger.yaml | 43 +++++- x/swap/alias.go | 28 ++++ x/swap/client/cli/query.go | 58 ++++++++ x/swap/client/cli/tx.go | 28 ++++ x/swap/client/rest/query.go | 37 +++++ x/swap/client/rest/rest.go | 17 +++ x/swap/client/rest/tx.go | 9 ++ x/swap/genesis.go | 24 +++ x/swap/handler.go | 17 +++ x/swap/keeper/integration_test.go | 32 ++++ x/swap/keeper/keeper.go | 29 ++++ x/swap/keeper/params.go | 19 +++ x/swap/keeper/params_test.go | 39 +++++ x/swap/keeper/querier.go | 36 +++++ x/swap/keeper/querier_test.go | 65 +++++++++ x/swap/module.go | 166 +++++++++++++++++++++ x/swap/spec/01_concepts.md | 13 ++ x/swap/spec/02_state.md | 35 +++++ x/swap/spec/03_messages.md | 5 + x/swap/spec/04_events.md | 9 ++ x/swap/spec/05_params.md | 19 +++ x/swap/spec/README.md | 20 +++ x/swap/types/codec.go | 18 +++ x/swap/types/errors.go | 11 ++ x/swap/types/events.go | 6 + x/swap/types/genesis.go | 42 ++++++ x/swap/types/genesis_test.go | 168 +++++++++++++++++++++ x/swap/types/keys.go | 22 +++ x/swap/types/msg.go | 1 + x/swap/types/params.go | 91 ++++++++++++ x/swap/types/params_test.go | 233 ++++++++++++++++++++++++++++++ x/swap/types/pool.go | 90 ++++++++++++ x/swap/types/pool_test.go | 185 ++++++++++++++++++++++++ x/swap/types/querier.go | 6 + 36 files changed, 1634 insertions(+), 3 deletions(-) create mode 100644 x/swap/alias.go create mode 100644 x/swap/client/cli/query.go create mode 100644 x/swap/client/cli/tx.go create mode 100644 x/swap/client/rest/query.go create mode 100644 x/swap/client/rest/rest.go create mode 100644 x/swap/client/rest/tx.go create mode 100644 x/swap/genesis.go create mode 100644 x/swap/handler.go create mode 100644 x/swap/keeper/integration_test.go create mode 100644 x/swap/keeper/keeper.go create mode 100644 x/swap/keeper/params.go create mode 100644 x/swap/keeper/params_test.go create mode 100644 x/swap/keeper/querier.go create mode 100644 x/swap/keeper/querier_test.go create mode 100644 x/swap/module.go create mode 100644 x/swap/spec/01_concepts.md create mode 100644 x/swap/spec/02_state.md create mode 100644 x/swap/spec/03_messages.md create mode 100644 x/swap/spec/04_events.md create mode 100644 x/swap/spec/05_params.md create mode 100644 x/swap/spec/README.md create mode 100644 x/swap/types/codec.go create mode 100644 x/swap/types/errors.go create mode 100644 x/swap/types/events.go create mode 100644 x/swap/types/genesis.go create mode 100644 x/swap/types/genesis_test.go create mode 100644 x/swap/types/keys.go create mode 100644 x/swap/types/msg.go create mode 100644 x/swap/types/params.go create mode 100644 x/swap/types/params_test.go create mode 100644 x/swap/types/pool.go create mode 100644 x/swap/types/pool_test.go create mode 100644 x/swap/types/querier.go diff --git a/app/app.go b/app/app.go index aa87ec1d..a21ebd04 100644 --- a/app/app.go +++ b/app/app.go @@ -43,6 +43,7 @@ import ( "github.com/kava-labs/kava/x/issuance" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" + "github.com/kava-labs/kava/x/swap" validatorvesting "github.com/kava-labs/kava/x/validator-vesting" ) @@ -85,6 +86,7 @@ var ( incentive.AppModuleBasic{}, issuance.AppModuleBasic{}, hard.AppModuleBasic{}, + swap.AppModuleBasic{}, ) // module account permissions @@ -158,6 +160,7 @@ type App struct { incentiveKeeper incentive.Keeper issuanceKeeper issuance.Keeper hardKeeper hard.Keeper + swapKeeper swap.Keeper // the module manager mm *module.Manager @@ -181,7 +184,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio gov.StoreKey, params.StoreKey, upgrade.StoreKey, evidence.StoreKey, validatorvesting.StoreKey, auction.StoreKey, cdp.StoreKey, pricefeed.StoreKey, bep3.StoreKey, kavadist.StoreKey, incentive.StoreKey, issuance.StoreKey, committee.StoreKey, - hard.StoreKey, + hard.StoreKey, swap.StoreKey, ) tkeys := sdk.NewTransientStoreKeys(params.TStoreKey) @@ -212,6 +215,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio incentiveSubspace := app.paramsKeeper.Subspace(incentive.DefaultParamspace) issuanceSubspace := app.paramsKeeper.Subspace(issuance.DefaultParamspace) hardSubspace := app.paramsKeeper.Subspace(hard.DefaultParamspace) + swapSubspace := app.paramsKeeper.Subspace(swap.DefaultParamspace) // add keepers app.accountKeeper = auth.NewAccountKeeper( @@ -393,6 +397,11 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio app.accountKeeper, app.supplyKeeper, ) + app.swapKeeper = swap.NewKeeper( + app.cdc, + keys[swap.StoreKey], + swapSubspace, + ) // register the staking hooks // NOTE: stakingKeeper above is passed by reference, so that it will contain these hooks @@ -428,6 +437,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio committee.NewAppModule(app.committeeKeeper, app.accountKeeper), issuance.NewAppModule(app.issuanceKeeper, app.accountKeeper, app.supplyKeeper), hard.NewAppModule(app.hardKeeper, app.supplyKeeper, app.pricefeedKeeper), + swap.NewAppModule(app.swapKeeper), ) // During begin block slashing happens after distr.BeginBlocker so that @@ -448,7 +458,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio validatorvesting.ModuleName, distr.ModuleName, staking.ModuleName, bank.ModuleName, slashing.ModuleName, gov.ModuleName, mint.ModuleName, evidence.ModuleName, - pricefeed.ModuleName, cdp.ModuleName, hard.ModuleName, auction.ModuleName, + pricefeed.ModuleName, cdp.ModuleName, hard.ModuleName, auction.ModuleName, swap.ModuleName, bep3.ModuleName, kavadist.ModuleName, incentive.ModuleName, committee.ModuleName, issuance.ModuleName, supply.ModuleName, // calculates the total supply from account - should run after modules that modify accounts in genesis crisis.ModuleName, // runs the invariants at genesis - should run after other modules diff --git a/app/test_common.go b/app/test_common.go index e6932331..5433754d 100644 --- a/app/test_common.go +++ b/app/test_common.go @@ -38,6 +38,7 @@ import ( "github.com/kava-labs/kava/x/issuance" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" + "github.com/kava-labs/kava/x/swap" validatorvesting "github.com/kava-labs/kava/x/validator-vesting" ) @@ -87,6 +88,7 @@ func (tApp TestApp) GetIncentiveKeeper() incentive.Keeper { return tApp.incentiv func (tApp TestApp) GetHardKeeper() hard.Keeper { return tApp.hardKeeper } func (tApp TestApp) GetCommitteeKeeper() committee.Keeper { return tApp.committeeKeeper } func (tApp TestApp) GetIssuanceKeeper() issuance.Keeper { return tApp.issuanceKeeper } +func (tApp TestApp) GetSwapKeeper() swap.Keeper { return tApp.swapKeeper } // InitializeFromGenesisStates calls InitChain on the app using the default genesis state, overwitten with any passed in genesis states func (tApp TestApp) InitializeFromGenesisStates(genesisStates ...GenesisState) TestApp { diff --git a/swagger-ui/swagger.yaml b/swagger-ui/swagger.yaml index 317b4b26..49cd0c60 100644 --- a/swagger-ui/swagger.yaml +++ b/swagger-ui/swagger.yaml @@ -2409,7 +2409,29 @@ paths: $ref: "#/definitions/Coin" 500: description: Server internal error - + /swap/parameters: + get: + summary: Get the current parameters of the swap module + tags: + - Swap + produces: + - application/json + responses: + 200: + description: Swap module parameters + schema: + type: object + properties: + height: + type: string + example: "100" + result: + type: array + x-nullable: true + items: + $ref: "#/definitions/SwapParams" + 500: + description: Server internal error /bank/accounts/{address}/transfers: post: summary: Send coins from one account to another @@ -4837,6 +4859,25 @@ definitions: active: type: boolean example: true + SwapParams: + type: object + properties: + allowed_pools: + type: array + items: + $ref: "#/definitions/AllowedPool" + swap_fee: + type: string + example: "0.03" + AllowedPool: + type: object + properties: + token_a: + type: string + example: "ukava" + token_b: + type: string + example: "usdx" HardParams: type: object properties: diff --git a/x/swap/alias.go b/x/swap/alias.go new file mode 100644 index 00000000..e9f1d1e1 --- /dev/null +++ b/x/swap/alias.go @@ -0,0 +1,28 @@ +package swap + +import ( + "github.com/kava-labs/kava/x/swap/keeper" + "github.com/kava-labs/kava/x/swap/types" +) + +const ( + ModuleName = types.ModuleName + QuerierRoute = types.QuerierRoute + RouterKey = types.RouterKey + StoreKey = types.StoreKey + DefaultParamspace = types.DefaultParamspace +) + +type ( + GenesisState = types.GenesisState + Keeper = keeper.Keeper +) + +var ( + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + ModuleCdc = types.ModuleCdc + ParamKeyTable = types.ParamKeyTable + RegisterCodec = types.RegisterCodec + DefaultGenesisState = types.DefaultGenesisState +) diff --git a/x/swap/client/cli/query.go b/x/swap/client/cli/query.go new file mode 100644 index 00000000..b2c3f7ff --- /dev/null +++ b/x/swap/client/cli/query.go @@ -0,0 +1,58 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/codec" + + "github.com/kava-labs/kava/x/swap/types" +) + +// GetQueryCmd returns the cli query commands for the module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + swapQueryCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "Querying commands for the swap module", + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + + swapQueryCmd.AddCommand(flags.GetCommands( + queryParamsCmd(queryRoute, cdc), + )...) + + return swapQueryCmd +} + +func queryParamsCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "params", + Short: "get the swap module parameters", + Long: "Get the current global swap module parameters.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetParams) + res, height, err := cliCtx.QueryWithData(route, nil) + if err != nil { + return err + } + cliCtx = cliCtx.WithHeight(height) + + // Decode and print results + var params types.Params + if err := cdc.UnmarshalJSON(res, ¶ms); err != nil { + return fmt.Errorf("failed to unmarshal params: %w", err) + } + return cliCtx.PrintOutput(params) + }, + } +} diff --git a/x/swap/client/cli/tx.go b/x/swap/client/cli/tx.go new file mode 100644 index 00000000..cfa2b9fa --- /dev/null +++ b/x/swap/client/cli/tx.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/codec" + + "github.com/kava-labs/kava/x/swap/types" +) + +// GetTxCmd returns the transaction commands for this module +func GetTxCmd(cdc *codec.Codec) *cobra.Command { + swapTxCmd := &cobra.Command{ + Use: types.ModuleName, + Short: fmt.Sprintf("%s transactions subcommands", types.ModuleName), + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + + swapTxCmd.AddCommand(flags.PostCommands()...) + + return swapTxCmd +} diff --git a/x/swap/client/rest/query.go b/x/swap/client/rest/query.go new file mode 100644 index 00000000..8dfa6a99 --- /dev/null +++ b/x/swap/client/rest/query.go @@ -0,0 +1,37 @@ +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/kava-labs/kava/x/swap/types" +) + +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc(fmt.Sprintf("/%s/parameters", types.ModuleName), queryParamsHandlerFn(cliCtx)).Methods("GET") +} + +func queryParamsHandlerFn(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.QueryGetParams) + + res, height, err := cliCtx.QueryWithData(route, nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/swap/client/rest/rest.go b/x/swap/client/rest/rest.go new file mode 100644 index 00000000..c238f421 --- /dev/null +++ b/x/swap/client/rest/rest.go @@ -0,0 +1,17 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" +) + +// REST variable names +// nolint +const () + +// RegisterRoutes registers swap-related REST handlers to a router +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { + registerQueryRoutes(cliCtx, r) + registerTxRoutes(cliCtx, r) +} diff --git a/x/swap/client/rest/tx.go b/x/swap/client/rest/tx.go new file mode 100644 index 00000000..ae173af7 --- /dev/null +++ b/x/swap/client/rest/tx.go @@ -0,0 +1,9 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) {} diff --git a/x/swap/genesis.go b/x/swap/genesis.go new file mode 100644 index 00000000..8ee02f32 --- /dev/null +++ b/x/swap/genesis.go @@ -0,0 +1,24 @@ +package swap + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/swap/types" +) + +// InitGenesis initializes story state from genesis file +func InitGenesis(ctx sdk.Context, k Keeper, gs types.GenesisState) { + if err := gs.Validate(); err != nil { + panic(fmt.Sprintf("failed to validate %s genesis state: %s", ModuleName, err)) + } + + k.SetParams(ctx, gs.Params) +} + +// ExportGenesis exports the genesis state +func ExportGenesis(ctx sdk.Context, k Keeper) types.GenesisState { + params := k.GetParams(ctx) + return types.NewGenesisState(params) +} diff --git a/x/swap/handler.go b/x/swap/handler.go new file mode 100644 index 00000000..8b13f1a3 --- /dev/null +++ b/x/swap/handler.go @@ -0,0 +1,17 @@ +package swap + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// NewHandler creates an sdk.Handler for swap messages +func NewHandler(k Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) (*sdk.Result, error) { + ctx = ctx.WithEventManager(sdk.NewEventManager()) + switch msg := msg.(type) { + default: + return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unrecognized %s message type: %T", ModuleName, msg) + } + } +} diff --git a/x/swap/keeper/integration_test.go b/x/swap/keeper/integration_test.go new file mode 100644 index 00000000..4f551d3d --- /dev/null +++ b/x/swap/keeper/integration_test.go @@ -0,0 +1,32 @@ +package keeper_test + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/swap/types" +) + +func i(in int64) sdk.Int { return sdk.NewInt(in) } +func c(denom string, amount int64) sdk.Coin { return sdk.NewInt64Coin(denom, amount) } +func cs(coins ...sdk.Coin) sdk.Coins { return sdk.NewCoins(coins...) } + +func NewAuthGenStateFromAccs(accounts ...authexported.GenesisAccount) app.GenesisState { + authGenesis := auth.NewGenesisState(auth.DefaultParams(), accounts) + return app.GenesisState{auth.ModuleName: auth.ModuleCdc.MustMarshalJSON(authGenesis)} +} + +func NewSwapGenStateMulti() app.GenesisState { + swapGenesis := types.GenesisState{ + Params: types.Params{ + AllowedPools: types.AllowedPools{ + types.NewAllowedPool("ukava", "usdx"), + }, + SwapFee: sdk.MustNewDecFromStr("0.03"), + }, + } + + return app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(swapGenesis)} +} diff --git a/x/swap/keeper/keeper.go b/x/swap/keeper/keeper.go new file mode 100644 index 00000000..3175be5a --- /dev/null +++ b/x/swap/keeper/keeper.go @@ -0,0 +1,29 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params/subspace" + + "github.com/kava-labs/kava/x/swap/types" +) + +// Keeper keeper for the swap module +type Keeper struct { + key sdk.StoreKey + cdc *codec.Codec + paramSubspace subspace.Subspace +} + +// NewKeeper creates a new keeper +func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace) Keeper { + if !paramstore.HasKeyTable() { + paramstore = paramstore.WithKeyTable(types.ParamKeyTable()) + } + + return Keeper{ + key: key, + cdc: cdc, + paramSubspace: paramstore, + } +} diff --git a/x/swap/keeper/params.go b/x/swap/keeper/params.go new file mode 100644 index 00000000..7e08c876 --- /dev/null +++ b/x/swap/keeper/params.go @@ -0,0 +1,19 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/swap/types" +) + +// GetParams returns the params from the store +func (k Keeper) GetParams(ctx sdk.Context) types.Params { + var p types.Params + k.paramSubspace.GetParamSet(ctx, &p) + return p +} + +// SetParams sets params on the store +func (k Keeper) SetParams(ctx sdk.Context, params types.Params) { + k.paramSubspace.SetParamSet(ctx, ¶ms) +} diff --git a/x/swap/keeper/params_test.go b/x/swap/keeper/params_test.go new file mode 100644 index 00000000..a6f44212 --- /dev/null +++ b/x/swap/keeper/params_test.go @@ -0,0 +1,39 @@ +package keeper_test + +import ( + "testing" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" + abci "github.com/tendermint/tendermint/abci/types" + tmtime "github.com/tendermint/tendermint/types/time" +) + +func TestParams_SetterAndGetter(t *testing.T) { + tApp := app.NewTestApp() + keeper := tApp.GetSwapKeeper() + + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + params := types.Params{ + AllowedPools: types.AllowedPools{ + types.NewAllowedPool("ukava", "usdx"), + }, + SwapFee: sdk.MustNewDecFromStr("0.03"), + } + keeper.SetParams(ctx, params) + assert.Equal(t, keeper.GetParams(ctx), params) + + oldParams := params + params = types.Params{ + AllowedPools: types.AllowedPools{ + types.NewAllowedPool("hard", "ukava"), + }, + SwapFee: sdk.MustNewDecFromStr("0.01"), + } + keeper.SetParams(ctx, params) + assert.NotEqual(t, keeper.GetParams(ctx), oldParams) + assert.Equal(t, keeper.GetParams(ctx), params) +} diff --git a/x/swap/keeper/querier.go b/x/swap/keeper/querier.go new file mode 100644 index 00000000..2ab995f5 --- /dev/null +++ b/x/swap/keeper/querier.go @@ -0,0 +1,36 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/x/swap/types" +) + +// NewQuerier is the module level router for state queries +func NewQuerier(k Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err error) { + switch path[0] { + case types.QueryGetParams: + return queryGetParams(ctx, req, k) + default: + return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unknown %s query endpoint", types.ModuleName) + } + } +} + +// query params in the swap store +func queryGetParams(ctx sdk.Context, req abci.RequestQuery, keeper Keeper) ([]byte, error) { + // Get params + params := keeper.GetParams(ctx) + + // Encode results + bz, err := codec.MarshalJSONIndent(types.ModuleCdc, params) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + return bz, nil +} diff --git a/x/swap/keeper/querier_test.go b/x/swap/keeper/querier_test.go new file mode 100644 index 00000000..6d18be55 --- /dev/null +++ b/x/swap/keeper/querier_test.go @@ -0,0 +1,65 @@ +package keeper_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + abci "github.com/tendermint/tendermint/abci/types" + tmtime "github.com/tendermint/tendermint/types/time" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/swap/keeper" + "github.com/kava-labs/kava/x/swap/types" +) + +type QuerierTestSuite struct { + suite.Suite + keeper keeper.Keeper + app app.TestApp + ctx sdk.Context + querier sdk.Querier +} + +func (suite *QuerierTestSuite) SetupTest() { + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + + tApp.InitializeFromGenesisStates( + NewSwapGenStateMulti(), + ) + + suite.ctx = ctx + suite.app = tApp + suite.keeper = tApp.GetSwapKeeper() + suite.querier = keeper.NewQuerier(suite.keeper) +} + +func (suite *QuerierTestSuite) TestUnkownRequest() { + ctx := suite.ctx.WithIsCheckTx(false) + bz, err := suite.querier(ctx, []string{"invalid-path"}, abci.RequestQuery{}) + suite.Nil(bz) + suite.EqualError(err, "unknown request: unknown swap query endpoint") +} + +func (suite *QuerierTestSuite) TestQueryParams() { + ctx := suite.ctx.WithIsCheckTx(false) + bz, err := suite.querier(ctx, []string{types.QueryGetParams}, abci.RequestQuery{}) + suite.Nil(err) + suite.NotNil(bz) + + var p types.Params + suite.Nil(types.ModuleCdc.UnmarshalJSON(bz, &p)) + + swapGenesisState := NewSwapGenStateMulti() + gs := types.GenesisState{} + types.ModuleCdc.UnmarshalJSON(swapGenesisState["swap"], &gs) + + suite.Equal(gs.Params, p) +} + +func TestQuerierTestSuite(t *testing.T) { + suite.Run(t, new(QuerierTestSuite)) +} diff --git a/x/swap/module.go b/x/swap/module.go new file mode 100644 index 00000000..d82db67b --- /dev/null +++ b/x/swap/module.go @@ -0,0 +1,166 @@ +package swap + +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" + + // "github.com/kava-labs/kava/x/swap/simulation" + "github.com/kava-labs/kava/x/swap/client/cli" + "github.com/kava-labs/kava/x/swap/client/rest" + "github.com/kava-labs/kava/x/swap/keeper" + "github.com/kava-labs/kava/x/swap/types" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} + // _ module.AppModuleSimulation = AppModule{} +) + +// AppModuleBasic app module basics object +type AppModuleBasic struct{} + +// Name get module name +func (AppModuleBasic) Name() string { + return ModuleName +} + +// RegisterCodec register 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 gs GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &gs) + if err != nil { + return err + } + return gs.Validate() +} + +// RegisterRESTRoutes registers REST routes for the swap module. +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr) +} + +// GetTxCmd returns the root tx command for the swap module. +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(cdc) +} + +// GetQueryCmd returns no root query command for the swap module. +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(types.StoreKey, cdc) +} + +//____________________________________________________________________________ + +// AppModule app module type +type AppModule struct { + AppModuleBasic + + keeper Keeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper) AppModule { + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + } +} + +// Name module name +func (AppModule) Name() string { + return ModuleName +} + +// RegisterInvariants register module invariants +func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} + +// Route module message route name +func (AppModule) Route() string { + return ModuleName +} + +// 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 returns no sdk.Querier. +func (am AppModule) NewQuerierHandler() sdk.Querier { + return keeper.NewQuerier(am.keeper) +} + +// InitGenesis 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 (am AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) { +} + +// EndBlock module end-block +func (am AppModule) EndBlock(_ sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + return []abci.ValidatorUpdate{} +} + +//____________________________________________________________________________ + +// // GenerateGenesisState creates a randomized GenState of the swap module +// func (AppModuleBasic) GenerateGenesisState(simState *module.SimulationState) { +// simulation.RandomizedGenState(simState) +// } + +// // ProposalContents doesn't return any content functions for governance proposals. +// func (AppModuleBasic) ProposalContents(_ module.SimulationState) []sim.WeightedProposalContent { +// return nil +// } + +// // RandomizedParams returns nil because swap has no params. +// func (AppModuleBasic) RandomizedParams(r *rand.Rand) []sim.ParamChange { +// return simulation.ParamChanges(r) +// } + +// // RegisterStoreDecoder registers a decoder for swap module's types +// func (AppModuleBasic) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { +// sdr[StoreKey] = simulation.DecodeStore +// } + +// // WeightedOperations returns the all the swap module operations with their respective weights. +// func (am AppModule) WeightedOperations(simState module.SimulationState) []sim.WeightedOperation { +// return nil +// } diff --git a/x/swap/spec/01_concepts.md b/x/swap/spec/01_concepts.md new file mode 100644 index 00000000..b7a0a305 --- /dev/null +++ b/x/swap/spec/01_concepts.md @@ -0,0 +1,13 @@ + + +# Concepts + +## Automated Market Maker + +The swap module provides for functionality and governance of an Automated Market Maker protocol. The main state transitions in the swap module include deposits/withdrawals to liquidity pools by liquidity providers and token swaps executed against liquidity pools by users. Each liquidity pool consists of a unique pair of two tokens. A global swap fee set by governance is paid by users to execute trades, with the proceeds going to the relevant pool's liquidity providers. + +## SWP Token distribution + +[See Incentive Module](../../incentive/spec/01_concepts.md) diff --git a/x/swap/spec/02_state.md b/x/swap/spec/02_state.md new file mode 100644 index 00000000..41a0502d --- /dev/null +++ b/x/swap/spec/02_state.md @@ -0,0 +1,35 @@ + + +# State + +## Parameters and Genesis State + +`Parameters` define the governance parameters and default behavior of the swap module. + +```go +// Params are governance parameters for the swap module +type Params struct { + AllowedPools AllowedPools `json:"allowed_pools" yaml:"allowed_pools"` + SwapFee sdk.Dec `json:"swap_fee" yaml:"swap_fee"` +} + +// AllowedPool defines a tradable pool +type AllowedPool struct { + TokenA string `json:"token_a" yaml:"token_a"` + TokenB string `json:"token_b" yaml:"token_b"` +} + +// AllowedPools is a slice of AllowedPool +type AllowedPools []AllowedPool +``` + +`GenesisState` defines the state that must be persisted when the blockchain stops/restarts in order for the normal function of the swap module to resume. + +```go +// GenesisState is the state that must be provided at genesis. +type GenesisState struct { + Params Params `json:"params" yaml:"params"` +} +``` diff --git a/x/swap/spec/03_messages.md b/x/swap/spec/03_messages.md new file mode 100644 index 00000000..dbab134f --- /dev/null +++ b/x/swap/spec/03_messages.md @@ -0,0 +1,5 @@ + + +# Messages diff --git a/x/swap/spec/04_events.md b/x/swap/spec/04_events.md new file mode 100644 index 00000000..32c53e3e --- /dev/null +++ b/x/swap/spec/04_events.md @@ -0,0 +1,9 @@ + + +# Events + +The swap module emits the following events: + +## Handlers diff --git a/x/swap/spec/05_params.md b/x/swap/spec/05_params.md new file mode 100644 index 00000000..321187da --- /dev/null +++ b/x/swap/spec/05_params.md @@ -0,0 +1,19 @@ + + +# Parameters + +Example parameters for the swap module: + +| Key | Type | Example | Description | +| ------------ | ------------------- | ------------- | --------------------------------------- | +| AllowedPools | array (AllowedPool) | [{see below}] | Array of tradable pools supported | +| SwapFee | sdk.Dec | 0.03 | Global trading fee in percentage format | + +Example parameters for `AllowedPool`: + +| Key | Type | Example | Description | +| ------ | ------ | ------- | ------------------- | +| TokenA | string | "ukava" | First coin's denom | +| TokenB | string | "usdx" | Second coin's denom | diff --git a/x/swap/spec/README.md b/x/swap/spec/README.md new file mode 100644 index 00000000..3a8da861 --- /dev/null +++ b/x/swap/spec/README.md @@ -0,0 +1,20 @@ + + +# `swap` + + + +1. **[Concepts](01_concepts.md)** +2. **[State](02_state.md)** +3. **[Messages](03_messages.md)** +4. **[Events](04_events.md)** +5. **[Params](05_params.md)** + +## Abstract + +`x/swap` is a Cosmos SDK module that implements an Automated Market Maker (AMM) that enables users to swap coins by trading against liquidity pools. diff --git a/x/swap/types/codec.go b/x/swap/types/codec.go new file mode 100644 index 00000000..6548cfcb --- /dev/null +++ b/x/swap/types/codec.go @@ -0,0 +1,18 @@ +package types + +import "github.com/cosmos/cosmos-sdk/codec" + +// ModuleCdc generic sealed codec to be used throughout module +var ModuleCdc *codec.Codec + +func init() { + cdc := codec.New() + RegisterCodec(cdc) + codec.RegisterCrypto(cdc) + ModuleCdc = cdc.Seal() +} + +// RegisterCodec registers the necessary types for swap module +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(AllowedPool{}, "swap/AllowedPool", nil) +} diff --git a/x/swap/types/errors.go b/x/swap/types/errors.go new file mode 100644 index 00000000..2e5fcc32 --- /dev/null +++ b/x/swap/types/errors.go @@ -0,0 +1,11 @@ +package types + +import ( + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// DONTCOVER + +var ( + ErrCustom = sdkerrors.Register(ModuleName, 2, "") +) diff --git a/x/swap/types/events.go b/x/swap/types/events.go new file mode 100644 index 00000000..2c2124dc --- /dev/null +++ b/x/swap/types/events.go @@ -0,0 +1,6 @@ +package types + +// Event types for swap module +const ( +// EventTypeCustom = "" +) diff --git a/x/swap/types/genesis.go b/x/swap/types/genesis.go new file mode 100644 index 00000000..556b6406 --- /dev/null +++ b/x/swap/types/genesis.go @@ -0,0 +1,42 @@ +package types + +import "bytes" + +// GenesisState is the state that must be provided at genesis. +type GenesisState struct { + Params Params `json:"params" yaml:"params"` +} + +// NewGenesisState creates a new genesis state. +func NewGenesisState(params Params) GenesisState { + return GenesisState{ + Params: params, + } +} + +// Validate validates the module's genesis state +func (gs GenesisState) Validate() error { + if err := gs.Params.Validate(); err != nil { + return err + } + return nil +} + +// DefaultGenesisState returns a default genesis state +func DefaultGenesisState() GenesisState { + return NewGenesisState( + DefaultParams(), + ) +} + +// Equal checks whether two gov GenesisState structs are equivalent +func (gs GenesisState) Equal(gs2 GenesisState) bool { + b1 := ModuleCdc.MustMarshalBinaryBare(gs) + b2 := ModuleCdc.MustMarshalBinaryBare(gs2) + return bytes.Equal(b1, b2) +} + +// IsEmpty returns true if a GenesisState is empty +func (gs GenesisState) IsEmpty() bool { + return gs.Equal(GenesisState{}) +} diff --git a/x/swap/types/genesis_test.go b/x/swap/types/genesis_test.go new file mode 100644 index 00000000..b41fe05d --- /dev/null +++ b/x/swap/types/genesis_test.go @@ -0,0 +1,168 @@ +package types_test + +import ( + "testing" + + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenesis_Default(t *testing.T) { + defaultGenesis := types.DefaultGenesisState() + + require.NoError(t, defaultGenesis.Validate()) + + defaultParams := types.DefaultParams() + assert.Equal(t, defaultParams, defaultGenesis.Params) +} + +func TestGenesis_Empty(t *testing.T) { + emptyGenesis := types.GenesisState{} + assert.True(t, emptyGenesis.IsEmpty()) + + emptyGenesis = types.GenesisState{ + Params: types.Params{}, + } + assert.True(t, emptyGenesis.IsEmpty()) +} + +func TestGenesis_NotEmpty(t *testing.T) { + nonEmptyGenesis := types.GenesisState{ + Params: types.Params{ + AllowedPools: types.NewAllowedPools(types.NewAllowedPool("ukava", "hard")), + SwapFee: sdk.ZeroDec(), + }, + } + assert.False(t, nonEmptyGenesis.IsEmpty()) +} + +func TestGenesis_Validate_SwapFee(t *testing.T) { + type args struct { + name string + swapFee sdk.Dec + expectErr bool + } + // More comprehensive swap fee tests are in prams_test.go + testCases := []args{ + { + "normal", + sdk.MustNewDecFromStr("0.25"), + false, + }, + { + "negative", + sdk.MustNewDecFromStr("-0.5"), + true, + }, + { + "greater than 1.0", + sdk.MustNewDecFromStr("1.001"), + true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + genesisState := types.GenesisState{ + Params: types.Params{ + AllowedPools: types.DefaultAllowedPools, + SwapFee: tc.swapFee, + }, + } + + err := genesisState.Validate() + if tc.expectErr { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + }) + } +} + +func TestGenesis_Validate_AllowedPools(t *testing.T) { + type args struct { + name string + pairs types.AllowedPools + expectErr bool + } + // More comprehensive pair validation tests are in pair_test.go, params_test.go + testCases := []args{ + { + "normal", + types.DefaultAllowedPools, + false, + }, + { + "invalid", + types.AllowedPools{ + { + TokenA: "same", + TokenB: "same", + }, + }, + true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + genesisState := types.GenesisState{ + Params: types.Params{ + AllowedPools: tc.pairs, + SwapFee: types.DefaultSwapFee, + }, + } + + err := genesisState.Validate() + if tc.expectErr { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + }) + } +} + +func TestGenesis_Equal(t *testing.T) { + params := types.Params{ + types.NewAllowedPools(types.NewAllowedPool("ukava", "usdx")), + sdk.MustNewDecFromStr("0.85"), + } + + genesisA := types.GenesisState{params} + genesisB := types.GenesisState{params} + + assert.True(t, genesisA.Equal(genesisB)) +} + +func TestGenesis_NotEqual(t *testing.T) { + baseParams := types.Params{ + types.NewAllowedPools(types.NewAllowedPool("ukava", "usdx")), + sdk.MustNewDecFromStr("0.85"), + } + + // Base params + genesisAParams := baseParams + genesisA := types.GenesisState{genesisAParams} + + // Different swap fee + genesisBParams := baseParams + genesisBParams.SwapFee = sdk.MustNewDecFromStr("0.84") + genesisB := types.GenesisState{genesisBParams} + + // Different pairs + genesisCParams := baseParams + genesisCParams.AllowedPools = types.NewAllowedPools(types.NewAllowedPool("ukava", "hard")) + genesisC := types.GenesisState{genesisCParams} + + // A and B have different swap fees + assert.False(t, genesisA.Equal(genesisB)) + // A and C have different pair token B denoms + assert.False(t, genesisA.Equal(genesisC)) + // A and B and different swap fees and pair token B denoms + assert.False(t, genesisA.Equal(genesisB)) +} diff --git a/x/swap/types/keys.go b/x/swap/types/keys.go new file mode 100644 index 00000000..f3f0711b --- /dev/null +++ b/x/swap/types/keys.go @@ -0,0 +1,22 @@ +package types + +const ( + // ModuleName name that will be used throughout the module + ModuleName = "swap" + + // StoreKey Top level store key where all module items will be stored + StoreKey = ModuleName + + // RouterKey Top level router key + RouterKey = ModuleName + + // QuerierRoute Top level query string + QuerierRoute = ModuleName + + // DefaultParamspace default name for parameter store + DefaultParamspace = ModuleName +) + +var ( +// KeyPrefix = []byte{0x01} +) diff --git a/x/swap/types/msg.go b/x/swap/types/msg.go new file mode 100644 index 00000000..ab1254f4 --- /dev/null +++ b/x/swap/types/msg.go @@ -0,0 +1 @@ +package types diff --git a/x/swap/types/params.go b/x/swap/types/params.go new file mode 100644 index 00000000..a434f727 --- /dev/null +++ b/x/swap/types/params.go @@ -0,0 +1,91 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params" +) + +// Parameter keys and default values +var ( + KeyAllowedPools = []byte("AllowedPools") + KeySwapFee = []byte("SwapFee") + DefaultAllowedPools = AllowedPools{} + DefaultSwapFee = sdk.ZeroDec() + MaxSwapFee = sdk.OneDec() +) + +// Params are governance parameters for the swap module +type Params struct { + AllowedPools AllowedPools `json:"allowed_pools" yaml:"allowed_pools"` + SwapFee sdk.Dec `json:"swap_fee" yaml:"swap_fee"` +} + +// NewParams returns a new params object +func NewParams(pairs AllowedPools, swapFee sdk.Dec) Params { + return Params{ + AllowedPools: pairs, + SwapFee: swapFee, + } +} + +// DefaultParams returns default params for swap module +func DefaultParams() Params { + return NewParams( + DefaultAllowedPools, + DefaultSwapFee, + ) +} + +// String implements fmt.Stringer +func (p Params) String() string { + return fmt.Sprintf(`Params: + AllowedPools: %s + SwapFee: %s`, + p.AllowedPools, p.SwapFee) +} + +// ParamKeyTable Key declaration for parameters +func ParamKeyTable() params.KeyTable { + return params.NewKeyTable().RegisterParamSet(&Params{}) +} + +// ParamSetAllowedPools implements the ParamSet interface and returns all the key/value pairs +func (p *Params) ParamSetPairs() params.ParamSetPairs { + return params.ParamSetPairs{ + params.NewParamSetPair(KeyAllowedPools, &p.AllowedPools, validateAllowedPoolsParams), + params.NewParamSetPair(KeySwapFee, &p.SwapFee, validateSwapFee), + } +} + +// Validate checks that the parameters have valid values. +func (p Params) Validate() error { + if err := validateAllowedPoolsParams(p.AllowedPools); err != nil { + return err + } + + return validateSwapFee(p.SwapFee) +} + +func validateAllowedPoolsParams(i interface{}) error { + p, ok := i.(AllowedPools) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + + return p.Validate() +} + +func validateSwapFee(i interface{}) error { + swapFee, ok := i.(sdk.Dec) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + + if swapFee.IsNil() || swapFee.IsNegative() || swapFee.GT(MaxSwapFee) { + return fmt.Errorf(fmt.Sprintf("invalid swap fee: %s", swapFee)) + } + + return nil +} diff --git a/x/swap/types/params_test.go b/x/swap/types/params_test.go new file mode 100644 index 00000000..d62b9f0a --- /dev/null +++ b/x/swap/types/params_test.go @@ -0,0 +1,233 @@ +package types_test + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "testing" + + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + paramstypes "github.com/cosmos/cosmos-sdk/x/params" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestParams_UnmarshalJSON(t *testing.T) { + pools := types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("hard", "usdx"), + ) + poolData, err := json.Marshal(pools) + require.NoError(t, err) + + fee, err := sdk.NewDecFromStr("0.5") + require.NoError(t, err) + feeData, err := json.Marshal(fee) + require.NoError(t, err) + + data := fmt.Sprintf(`{ + "allowed_pools": %s, + "swap_fee": %s +}`, string(poolData), string(feeData)) + + var params types.Params + err = json.Unmarshal([]byte(data), ¶ms) + require.NoError(t, err) + + assert.Equal(t, pools, params.AllowedPools) + assert.Equal(t, fee, params.SwapFee) +} + +func TestParams_MarshalYAML(t *testing.T) { + pools := types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("hard", "usdx"), + ) + fee, err := sdk.NewDecFromStr("0.5") + require.NoError(t, err) + + p := types.Params{ + AllowedPools: pools, + SwapFee: fee, + } + + data, err := yaml.Marshal(p) + require.NoError(t, err) + + var params map[string]interface{} + err = yaml.Unmarshal(data, ¶ms) + require.NoError(t, err) + + _, ok := params["allowed_pools"] + require.True(t, ok) + _, ok = params["swap_fee"] + require.True(t, ok) +} + +func TestParams_Default(t *testing.T) { + defaultParams := types.DefaultParams() + + require.NoError(t, defaultParams.Validate()) + + assert.Equal(t, types.DefaultAllowedPools, defaultParams.AllowedPools) + assert.Equal(t, types.DefaultSwapFee, defaultParams.SwapFee) + + assert.Equal(t, 0, len(defaultParams.AllowedPools)) + assert.Equal(t, sdk.ZeroDec(), defaultParams.SwapFee) +} + +func TestParams_ParamSetPairs_AllowedPools(t *testing.T) { + assert.Equal(t, []byte("AllowedPools"), types.KeyAllowedPools) + defaultParams := types.DefaultParams() + + var paramSetPair *paramstypes.ParamSetPair + for _, pair := range defaultParams.ParamSetPairs() { + if bytes.Compare(pair.Key, types.KeyAllowedPools) == 0 { + paramSetPair = &pair + break + } + } + require.NotNil(t, paramSetPair) + + pairs, ok := paramSetPair.Value.(*types.AllowedPools) + require.True(t, ok) + assert.Equal(t, pairs, &defaultParams.AllowedPools) + + assert.Nil(t, paramSetPair.ValidatorFn(*pairs)) + assert.EqualError(t, paramSetPair.ValidatorFn(struct{}{}), "invalid parameter type: struct {}") +} + +func TestParams_ParamSetPairs_SwapFee(t *testing.T) { + assert.Equal(t, []byte("SwapFee"), types.KeySwapFee) + defaultParams := types.DefaultParams() + + var paramSetPair *paramstypes.ParamSetPair + for _, pair := range defaultParams.ParamSetPairs() { + if bytes.Compare(pair.Key, types.KeySwapFee) == 0 { + paramSetPair = &pair + break + } + } + require.NotNil(t, paramSetPair) + + swapFee, ok := paramSetPair.Value.(*sdk.Dec) + require.True(t, ok) + assert.Equal(t, swapFee, &defaultParams.SwapFee) + + assert.Nil(t, paramSetPair.ValidatorFn(*swapFee)) + assert.EqualError(t, paramSetPair.ValidatorFn(struct{}{}), "invalid parameter type: struct {}") +} + +func TestParams_Validation(t *testing.T) { + testCases := []struct { + name string + key []byte + testFn func(params *types.Params) + expectedErr string + }{ + { + name: "invalid denom", + key: types.KeyAllowedPools, + testFn: func(params *types.Params) { + params.AllowedPools = types.NewAllowedPools(types.NewAllowedPool("UKAVA", "ukava")) + }, + expectedErr: "invalid denom: UKAVA", + }, + { + name: "duplicate pools", + key: types.KeyAllowedPools, + testFn: func(params *types.Params) { + params.AllowedPools = types.NewAllowedPools(types.NewAllowedPool("ukava", "ukava")) + }, + expectedErr: "pool cannot have two tokens of the same type, received 'ukava' and 'ukava'", + }, + { + name: "nil swap fee", + key: types.KeySwapFee, + testFn: func(params *types.Params) { + params.SwapFee = sdk.Dec{} + }, + expectedErr: "invalid swap fee: ", + }, + { + name: "negative swap fee", + key: types.KeySwapFee, + testFn: func(params *types.Params) { + params.SwapFee = sdk.NewDec(-1) + }, + expectedErr: "invalid swap fee: -1.000000000000000000", + }, + { + name: "swap fee greater than 1", + key: types.KeySwapFee, + testFn: func(params *types.Params) { + params.SwapFee = sdk.MustNewDecFromStr("1.000000000000000001") + }, + expectedErr: "invalid swap fee: 1.000000000000000001", + }, + { + name: "0 swap fee", + key: types.KeySwapFee, + testFn: func(params *types.Params) { + params.SwapFee = sdk.ZeroDec() + }, + expectedErr: "", + }, + { + name: "1 swap fee", + key: types.KeySwapFee, + testFn: func(params *types.Params) { + params.SwapFee = sdk.OneDec() + }, + expectedErr: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + params := types.DefaultParams() + tc.testFn(¶ms) + + err := params.Validate() + + if tc.expectedErr == "" { + assert.Nil(t, err) + } else { + assert.EqualError(t, err, tc.expectedErr) + } + + var paramSetPair *paramstypes.ParamSetPair + for _, pair := range params.ParamSetPairs() { + if bytes.Compare(pair.Key, tc.key) == 0 { + paramSetPair = &pair + break + } + } + require.NotNil(t, paramSetPair) + value := reflect.ValueOf(paramSetPair.Value).Elem().Interface() + + // assert validation error is same as param set validation + assert.Equal(t, err, paramSetPair.ValidatorFn(value)) + }) + } +} + +func TestParams_String(t *testing.T) { + params := types.NewParams( + types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("ukava", "usdx"), + ), + sdk.MustNewDecFromStr("0.5"), + ) + require.NoError(t, params.Validate()) + + output := params.String() + assert.Contains(t, output, "hard/ukava") + assert.Contains(t, output, "ukava/usdx") + assert.Contains(t, output, "0.5") +} diff --git a/x/swap/types/pool.go b/x/swap/types/pool.go new file mode 100644 index 00000000..b3d2ab52 --- /dev/null +++ b/x/swap/types/pool.go @@ -0,0 +1,90 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// AllowedPool defines a tradable pool +type AllowedPool struct { + TokenA string `json:"token_a" yaml:"token_a"` + TokenB string `json:"token_b" yaml:"token_b"` +} + +// NewAllowedPool returns a new AllowedPool object +func NewAllowedPool(tokenA, tokenB string) AllowedPool { + return AllowedPool{ + TokenA: tokenA, + TokenB: tokenB, + } +} + +// Validate validates allowedPool attributes and returns an error if invalid +func (p AllowedPool) Validate() error { + err := sdk.ValidateDenom(p.TokenA) + if err != nil { + return err + } + + err = sdk.ValidateDenom(p.TokenB) + if err != nil { + return err + } + + if p.TokenA == p.TokenB { + return fmt.Errorf( + "pool cannot have two tokens of the same type, received '%s' and '%s'", + p.TokenA, p.TokenB, + ) + } + + if p.TokenA > p.TokenB { + return fmt.Errorf( + "invalid token order: '%s' must come before '%s'", + p.TokenB, p.TokenA, + ) + } + + return nil +} + +// Name returns a unique name for a allowedPool in alphabetical order +func (p AllowedPool) Name() string { + return fmt.Sprintf("%s/%s", p.TokenA, p.TokenB) +} + +// String pretty prints the allowedPool +func (p AllowedPool) String() string { + return fmt.Sprintf(`AllowedPool: + Name: %s + Token A: %s + Token B: %s +`, p.Name(), p.TokenA, p.TokenB) +} + +// AllowedPools is a slice of AllowedPool +type AllowedPools []AllowedPool + +// NewAllowedPools returns AllowedPools from the provided values +func NewAllowedPools(allowedPools ...AllowedPool) AllowedPools { + return AllowedPools(allowedPools) +} + +// Validate validates each allowedPool and returns an error if there are any duplicates +func (p AllowedPools) Validate() error { + seenAllowedPools := make(map[string]bool) + for _, allowedPool := range p { + err := allowedPool.Validate() + if err != nil { + return err + } + + if seen := seenAllowedPools[allowedPool.Name()]; seen { + return fmt.Errorf("duplicate pool: %s", allowedPool.Name()) + } + seenAllowedPools[allowedPool.Name()] = true + } + + return nil +} diff --git a/x/swap/types/pool_test.go b/x/swap/types/pool_test.go new file mode 100644 index 00000000..fd1941b7 --- /dev/null +++ b/x/swap/types/pool_test.go @@ -0,0 +1,185 @@ +package types_test + +import ( + "strings" + "testing" + + types "github.com/kava-labs/kava/x/swap/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAllowedPool_Validation(t *testing.T) { + testCases := []struct { + name string + allowedPool types.AllowedPool + expectedErr string + }{ + { + name: "blank token a", + allowedPool: types.NewAllowedPool("", "ukava"), + expectedErr: "invalid denom: ", + }, + { + name: "blank token b", + allowedPool: types.NewAllowedPool("ukava", ""), + expectedErr: "invalid denom: ", + }, + { + name: "invalid token a", + allowedPool: types.NewAllowedPool("1ukava", "ukava"), + expectedErr: "invalid denom: 1ukava", + }, + { + name: "invalid token b", + allowedPool: types.NewAllowedPool("ukava", "1ukava"), + expectedErr: "invalid denom: 1ukava", + }, + { + name: "no uppercase letters token a", + allowedPool: types.NewAllowedPool("uKava", "ukava"), + expectedErr: "invalid denom: uKava", + }, + { + name: "no uppercase letters token b", + allowedPool: types.NewAllowedPool("ukava", "UKAVA"), + expectedErr: "invalid denom: UKAVA", + }, + { + name: "matching tokens", + allowedPool: types.NewAllowedPool("ukava", "ukava"), + expectedErr: "pool cannot have two tokens of the same type, received 'ukava' and 'ukava'", + }, + { + name: "invalid token order", + allowedPool: types.NewAllowedPool("usdx", "ukava"), + expectedErr: "invalid token order: 'ukava' must come before 'usdx'", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.allowedPool.Validate() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +// ensure no regression in case insentive token matching if +// sdk.ValidateDenom ever allows upper case letters +func TestAllowedPool_TokenMatch(t *testing.T) { + allowedPool := types.NewAllowedPool("UKAVA", "ukava") + err := allowedPool.Validate() + assert.Error(t, err) + + allowedPool = types.NewAllowedPool("hard", "haRd") + err = allowedPool.Validate() + assert.Error(t, err) + + allowedPool = types.NewAllowedPool("Usdx", "uSdX") + err = allowedPool.Validate() + assert.Error(t, err) +} + +func TestAllowedPool_String(t *testing.T) { + allowedPool := types.NewAllowedPool("hard", "ukava") + require.NoError(t, allowedPool.Validate()) + + output := `AllowedPool: + Name: hard/ukava + Token A: hard + Token B: ukava +` + assert.Equal(t, output, allowedPool.String()) +} + +func TestAllowedPool_Name(t *testing.T) { + testCases := []struct { + tokens string + name string + }{ + { + tokens: "atoken btoken", + name: "atoken/btoken", + }, + { + tokens: "aaa aaaa", + name: "aaa/aaaa", + }, + { + tokens: "aaaa aaab", + name: "aaaa/aaab", + }, + { + tokens: "a001 a002", + name: "a001/a002", + }, + { + tokens: "hard ukava", + name: "hard/ukava", + }, + { + tokens: "bnb hard", + name: "bnb/hard", + }, + { + tokens: "bnb xrpb", + name: "bnb/xrpb", + }, + } + + for _, tc := range testCases { + t.Run(tc.tokens, func(t *testing.T) { + tokens := strings.Split(tc.tokens, " ") + require.Equal(t, 2, len(tokens)) + + allowedPool := types.NewAllowedPool(tokens[0], tokens[1]) + require.NoError(t, allowedPool.Validate()) + + assert.Equal(t, tc.name, allowedPool.Name()) + }) + } +} + +func TestAllowedPools_Validate(t *testing.T) { + testCases := []struct { + name string + allowedPools types.AllowedPools + expectedErr string + }{ + { + name: "invalid pool", + allowedPools: types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("HARD", "UKAVA"), + ), + expectedErr: "invalid denom: HARD", + }, + { + name: "duplicate pool", + allowedPools: types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("hard", "ukava"), + ), + expectedErr: "duplicate pool: hard/ukava", + }, + { + name: "duplicate pools", + allowedPools: types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("bnb", "usdx"), + types.NewAllowedPool("btcb", "xrpb"), + types.NewAllowedPool("bnb", "usdx"), + ), + expectedErr: "duplicate pool: bnb/usdx", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.allowedPools.Validate() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} diff --git a/x/swap/types/querier.go b/x/swap/types/querier.go new file mode 100644 index 00000000..211fa8d0 --- /dev/null +++ b/x/swap/types/querier.go @@ -0,0 +1,6 @@ +package types + +// Querier routes for the swap module +const ( + QueryGetParams = "params" +)