diff --git a/.circleci/config.yml b/.circleci/config.yml index c727d1a7..e1e6a0a0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,7 +71,7 @@ jobs: export VERSION="$(git describe --tags --long | sed 's/v\(.*\)/\1/')" export GO111MODULE=on mkdir -p /tmp/logs /tmp/workspace/profiles - for pkg in $(go list ./... | grep -v '/simulation' | circleci tests split); do + for pkg in $(go list ./... | grep -v 'simulation\|migrate\|contrib' | circleci tests split); do id=$(echo "$pkg" | sed 's|[/.]|_|g') go test -mod=readonly -timeout 8m -race -coverprofile=/tmp/workspace/profiles/$id.out -covermode=atomic -tags='ledger test_ledger_mock' "$pkg" | tee "/tmp/logs/$id-$RANDOM.log" done diff --git a/Makefile b/Makefile index 9e0b0249..ebe7480e 100644 --- a/Makefile +++ b/Makefile @@ -171,7 +171,7 @@ test-basic: test @go test ./app -run TestAppStateDeterminism -Enabled -Commit -NumBlocks=5 -BlockSize=200 -Seed 4 -v -timeout 2m test: - @go test ./... + @go test $$(go list ./... | grep -v 'migrate\|contrib') test-rest: rest_test/./run_all_tests_from_make.sh diff --git a/app/app.go b/app/app.go index 5a1f2832..5c062226 100644 --- a/app/app.go +++ b/app/app.go @@ -38,6 +38,7 @@ import ( "github.com/kava-labs/kava/x/cdp" "github.com/kava-labs/kava/x/committee" "github.com/kava-labs/kava/x/incentive" + "github.com/kava-labs/kava/x/issuance" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" validatorvesting "github.com/kava-labs/kava/x/validator-vesting" @@ -80,6 +81,7 @@ var ( bep3.AppModuleBasic{}, kavadist.AppModuleBasic{}, incentive.AppModuleBasic{}, + issuance.AppModuleBasic{}, ) // module account permissions @@ -97,6 +99,7 @@ var ( cdp.SavingsRateMacc: {supply.Minter}, bep3.ModuleName: nil, kavadist.ModuleName: {supply.Minter}, + issuance.ModuleAccountName: {supply.Minter, supply.Burner}, } // module accounts that are allowed to receive tokens @@ -140,6 +143,7 @@ type App struct { bep3Keeper bep3.Keeper kavadistKeeper kavadist.Keeper incentiveKeeper incentive.Keeper + issuanceKeeper issuance.Keeper // the module manager mm *module.Manager @@ -164,7 +168,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, supply.StoreKey, mint.StoreKey, distr.StoreKey, slashing.StoreKey, gov.StoreKey, params.StoreKey, upgrade.StoreKey, evidence.StoreKey, validatorvesting.StoreKey, auction.StoreKey, cdp.StoreKey, pricefeed.StoreKey, - bep3.StoreKey, kavadist.StoreKey, incentive.StoreKey, committee.StoreKey, + bep3.StoreKey, kavadist.StoreKey, incentive.StoreKey, issuance.StoreKey, committee.StoreKey, ) tkeys := sdk.NewTransientStoreKeys(params.TStoreKey) @@ -193,6 +197,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, bep3Subspace := app.paramsKeeper.Subspace(bep3.DefaultParamspace) kavadistSubspace := app.paramsKeeper.Subspace(kavadist.DefaultParamspace) incentiveSubspace := app.paramsKeeper.Subspace(incentive.DefaultParamspace) + issuanceSubspace := app.paramsKeeper.Subspace(issuance.DefaultParamspace) // add keepers app.accountKeeper = auth.NewAccountKeeper( @@ -350,6 +355,13 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, app.cdpKeeper, app.accountKeeper, ) + app.issuanceKeeper = issuance.NewKeeper( + app.cdc, + keys[issuance.StoreKey], + issuanceSubspace, + app.accountKeeper, + app.supplyKeeper, + ) // register the staking hooks // NOTE: stakingKeeper above is passed by reference, so that it will contain these hooks @@ -379,6 +391,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, kavadist.NewAppModule(app.kavadistKeeper, app.supplyKeeper), incentive.NewAppModule(app.incentiveKeeper, app.accountKeeper, app.supplyKeeper), committee.NewAppModule(app.committeeKeeper, app.accountKeeper), + issuance.NewAppModule(app.issuanceKeeper, app.accountKeeper, app.supplyKeeper), ) // During begin block slashing happens after distr.BeginBlocker so that @@ -389,7 +402,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, app.mm.SetOrderBeginBlockers( upgrade.ModuleName, mint.ModuleName, distr.ModuleName, slashing.ModuleName, validatorvesting.ModuleName, kavadist.ModuleName, auction.ModuleName, cdp.ModuleName, - bep3.ModuleName, incentive.ModuleName, committee.ModuleName, + bep3.ModuleName, incentive.ModuleName, committee.ModuleName, issuance.ModuleName, ) app.mm.SetOrderEndBlockers(crisis.ModuleName, gov.ModuleName, staking.ModuleName, pricefeed.ModuleName) @@ -400,7 +413,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, staking.ModuleName, bank.ModuleName, slashing.ModuleName, gov.ModuleName, mint.ModuleName, evidence.ModuleName, pricefeed.ModuleName, cdp.ModuleName, auction.ModuleName, - bep3.ModuleName, kavadist.ModuleName, incentive.ModuleName, committee.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 genutil.ModuleName, // genutils must occur after staking so that pools are properly initialized with tokens from genesis accounts. @@ -430,6 +443,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, kavadist.NewAppModule(app.kavadistKeeper, app.supplyKeeper), incentive.NewAppModule(app.incentiveKeeper, app.accountKeeper, app.supplyKeeper), committee.NewAppModule(app.committeeKeeper, app.accountKeeper), + issuance.NewAppModule(app.issuanceKeeper, app.accountKeeper, app.supplyKeeper), ) app.sm.RegisterStoreDecoders() diff --git a/app/params/params.go b/app/params/params.go index c3c16219..c7c5a59b 100644 --- a/app/params/params.go +++ b/app/params/params.go @@ -13,5 +13,9 @@ const ( DefaultWeightMsgUpdatePrices int = 20 DefaultWeightMsgCdp int = 20 DefaultWeightMsgClaimReward int = 20 + DefaultWeightMsgIssue int = 20 + DefaultWeightMsgRedeem int = 20 + DefaultWeightMsgBlock int = 20 + DefaultWeightMsgPause int = 20 OpWeightSubmitCommitteeChangeProposal int = 20 ) diff --git a/app/test_common.go b/app/test_common.go index b02fd822..bc66ee8f 100644 --- a/app/test_common.go +++ b/app/test_common.go @@ -35,6 +35,7 @@ import ( "github.com/kava-labs/kava/x/cdp" "github.com/kava-labs/kava/x/committee" "github.com/kava-labs/kava/x/incentive" + "github.com/kava-labs/kava/x/issuance" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" validatorvesting "github.com/kava-labs/kava/x/validator-vesting" @@ -84,6 +85,7 @@ func (tApp TestApp) GetBep3Keeper() bep3.Keeper { return tApp.bep3Keep func (tApp TestApp) GetKavadistKeeper() kavadist.Keeper { return tApp.kavadistKeeper } func (tApp TestApp) GetIncentiveKeeper() incentive.Keeper { return tApp.incentiveKeeper } func (tApp TestApp) GetCommitteeKeeper() committee.Keeper { return tApp.committeeKeeper } +func (tApp TestApp) GetIssuanceKeeper() issuance.Keeper { return tApp.issuanceKeeper } // This 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/x/issuance/abci.go b/x/issuance/abci.go new file mode 100644 index 00000000..88b956d7 --- /dev/null +++ b/x/issuance/abci.go @@ -0,0 +1,17 @@ +package issuance + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/issuance/keeper" +) + +// BeginBlocker iterates over each asset and seizes coins from blocked addresses by returning them to the asset owner +func BeginBlocker(ctx sdk.Context, k keeper.Keeper) { + params := k.GetParams(ctx) + for _, asset := range params.Assets { + err := k.SeizeCoinsFromBlockedAddresses(ctx, asset.Denom) + if err != nil { + panic(err) + } + } +} diff --git a/x/issuance/alias.go b/x/issuance/alias.go new file mode 100644 index 00000000..84d6f9e6 --- /dev/null +++ b/x/issuance/alias.go @@ -0,0 +1,81 @@ +package issuance + +import ( + "github.com/kava-labs/kava/x/issuance/keeper" + "github.com/kava-labs/kava/x/issuance/types" +) + +// nolint +// autogenerated code using github.com/rigelrozanski/multitool +// aliases generated for the following subdirectories: +// ALIASGEN: github.com/kava-labs/kava/x/issuance/keeper +// ALIASGEN: github.com/kava-labs/kava/x/issuance/types + +const ( + EventTypeIssue = types.EventTypeIssue + EventTypeRedeem = types.EventTypeRedeem + EventTypeBlock = types.EventTypeBlock + EventTypeUnblock = types.EventTypeUnblock + EventTypePause = types.EventTypePause + EventTypeSeize = types.EventTypeSeize + AttributeValueCategory = types.AttributeValueCategory + AttributeKeyDenom = types.AttributeKeyDenom + AttributeKeyIssueAmount = types.AttributeKeyIssueAmount + AttributeKeyRedeemAmount = types.AttributeKeyRedeemAmount + AttributeKeyBlock = types.AttributeKeyBlock + AttributeKeyUnblock = types.AttributeKeyUnblock + AttributeKeyAddress = types.AttributeKeyAddress + AttributeKeyPauseStatus = types.AttributeKeyPauseStatus + ModuleName = types.ModuleName + StoreKey = types.StoreKey + RouterKey = types.RouterKey + DefaultParamspace = types.DefaultParamspace + QuerierRoute = types.QuerierRoute + QueryGetParams = types.QueryGetParams + QueryGetAsset = types.QueryGetAsset +) + +var ( + // functions aliases + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + RegisterCodec = types.RegisterCodec + NewGenesisState = types.NewGenesisState + DefaultGenesisState = types.DefaultGenesisState + NewMsgIssueTokens = types.NewMsgIssueTokens + NewMsgRedeemTokens = types.NewMsgRedeemTokens + NewMsgBlockAddress = types.NewMsgBlockAddress + NewMsgUnblockAddress = types.NewMsgUnblockAddress + NewMsgSetPauseStatus = types.NewMsgSetPauseStatus + NewParams = types.NewParams + DefaultParams = types.DefaultParams + ParamKeyTable = types.ParamKeyTable + NewAsset = types.NewAsset + + // variable aliases + ModuleCdc = types.ModuleCdc + ErrAssetNotFound = types.ErrAssetNotFound + ErrNotAuthorized = types.ErrNotAuthorized + ErrAssetPaused = types.ErrAssetPaused + ErrAccountBlocked = types.ErrAccountBlocked + ErrAccountAlreadyBlocked = types.ErrAccountAlreadyBlocked + ErrAccountAlreadyUnblocked = types.ErrAccountAlreadyUnblocked + ErrIssueToModuleAccount = types.ErrIssueToModuleAccount + KeyAssets = types.KeyAssets + DefaultAssets = types.DefaultAssets + ModuleAccountName = types.ModuleAccountName +) + +type ( + Keeper = keeper.Keeper + GenesisState = types.GenesisState + MsgIssueTokens = types.MsgIssueTokens + MsgRedeemTokens = types.MsgRedeemTokens + MsgBlockAddress = types.MsgBlockAddress + MsgUnblockAddress = types.MsgUnblockAddress + MsgSetPauseStatus = types.MsgSetPauseStatus + Params = types.Params + Asset = types.Asset + Assets = types.Assets + QueryAssetParams = types.QueryAssetParams +) diff --git a/x/issuance/client/cli/query.go b/x/issuance/client/cli/query.go new file mode 100644 index 00000000..4b7cffb1 --- /dev/null +++ b/x/issuance/client/cli/query.go @@ -0,0 +1,55 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "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/issuance/types" +) + +// GetQueryCmd returns the cli query commands for the issuance module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + issuanceQueryCmd := &cobra.Command{ + Use: types.ModuleName, + Short: fmt.Sprintf("Querying commands for the %s module", types.ModuleName), + } + + issuanceQueryCmd.AddCommand(flags.GetCommands( + queryParamsCmd(queryRoute, cdc), + )...) + + return issuanceQueryCmd + +} + +func queryParamsCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "params", + Short: fmt.Sprintf("get the %s module parameters", types.ModuleName), + Long: "Get the current global issuance 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/issuance/client/cli/tx.go b/x/issuance/client/cli/tx.go new file mode 100644 index 00000000..477df636 --- /dev/null +++ b/x/issuance/client/cli/tx.go @@ -0,0 +1,192 @@ +package cli + +import ( + "bufio" + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/client/flags" + "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/kava-labs/kava/x/issuance/types" +) + +// GetTxCmd returns the transaction cli commands for the issuance module +func GetTxCmd(cdc *codec.Codec) *cobra.Command { + issuanceTxCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "transaction commands for the issuance module", + } + + issuanceTxCmd.AddCommand(flags.PostCommands( + getCmdIssueTokens(cdc), + getCmdRedeemTokens(cdc), + getCmdBlockAddress(cdc), + getCmdUnblockAddress(cdc), + getCmdPauseAsset(cdc), + )...) + + return issuanceTxCmd + +} + +func getCmdIssueTokens(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "issue [tokens] [receiver]", + Short: "issue new tokens to the receiver address", + Long: "The asset owner issues new tokens that will be credited to the receiver address", + Example: fmt.Sprintf(`$ %s tx %s issue 20000000usdtoken kava15qdefkmwswysgg4qxgqpqr35k3m49pkx2jdfnw + `, version.ClientName, types.ModuleName), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + tokens, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + receiver, err := sdk.AccAddressFromBech32(args[1]) + if err != nil { + return err + } + + msg := types.NewMsgIssueTokens(cliCtx.GetFromAddress(), tokens, receiver) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} + +func getCmdRedeemTokens(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "redeem [tokens]", + Short: "redeem tokens", + Long: "The asset owner redeems (burns) tokens, removing them from the circulating supply", + Example: fmt.Sprintf(`$ %s tx %s redeem 20000000usdtoken + `, version.ClientName, types.ModuleName), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + tokens, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + msg := types.NewMsgRedeemTokens(cliCtx.GetFromAddress(), tokens) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} + +func getCmdBlockAddress(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "block [address] [denom]", + Short: "block an address for the input denom", + Long: "The asset owner blocks an address from holding coins of that denomination. Any tokens of the input denomination held by the address will be sent to the owner address", + Example: fmt.Sprintf(`$ %s tx %s block kava15qdefkmwswysgg4qxgqpqr35k3m49pkx2jdfnw usdtoken + `, version.ClientName, types.ModuleName), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + address, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + + err = sdk.ValidateDenom(args[1]) + if err != nil { + return err + } + msg := types.NewMsgBlockAddress(cliCtx.GetFromAddress(), args[1], address) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} + +func getCmdUnblockAddress(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "unblock [address] [denom]", + Short: "unblock an address for the input denom", + Long: "The asset owner unblocks an address from holding coins of that denomination.", + Example: fmt.Sprintf(`$ %s tx %s unblock kava15qdefkmwswysgg4qxgqpqr35k3m49pkx2jdfnw usdtoken + `, version.ClientName, types.ModuleName), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + address, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + + err = sdk.ValidateDenom(args[1]) + if err != nil { + return err + } + msg := types.NewMsgUnblockAddress(cliCtx.GetFromAddress(), args[1], address) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} + +func getCmdPauseAsset(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "set-pause-status [denom] [status]", + Short: "pause or unpause an asset", + Long: "The asset owner pauses or unpauses the input asset, halting new issuance and redemption", + Example: fmt.Sprintf(`$ %s tx %s pause usdtoken true + `, version.ClientName, types.ModuleName), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + err := sdk.ValidateDenom(args[0]) + if err != nil { + return err + } + var status bool + if args[1] == "true" { + status = true + } else if args[1] == "false" { + status = false + } else { + return fmt.Errorf(fmt.Sprintf("status must be true or false, got %s", args[1])) + } + + msg := types.NewMsgSetPauseStatus(cliCtx.GetFromAddress(), args[0], status) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/issuance/client/rest/query.go b/x/issuance/client/rest/query.go new file mode 100644 index 00000000..7b60b774 --- /dev/null +++ b/x/issuance/client/rest/query.go @@ -0,0 +1,34 @@ +package rest + +import ( + "fmt" + "net/http" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/gorilla/mux" + + "github.com/kava-labs/kava/x/issuance/types" +) + +// define routes that get registered by the main application +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc(fmt.Sprintf("/%s/parameters", types.ModuleName), getParamsHandlerFn(cliCtx)).Methods("GET") +} + +func getParamsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + res, height, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/%s", types.ModuleName, types.QueryGetParams), nil) + cliCtx = cliCtx.WithHeight(height) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/issuance/client/rest/rest.go b/x/issuance/client/rest/rest.go new file mode 100644 index 00000000..06bf22be --- /dev/null +++ b/x/issuance/client/rest/rest.go @@ -0,0 +1,49 @@ +package rest + +import ( + "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" +) + +// RegisterRoutes - Central function to define routes that get registered by the main application +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { + registerQueryRoutes(cliCtx, r) + registerTxRoutes(cliCtx, r) +} + +// PostIssueReq defines the properties of an issue token request's body +type PostIssueReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Tokens sdk.Coin `json:"tokens" yaml:"tokens"` + Receiver sdk.AccAddress `json:"receiver" yaml:"receiver"` +} + +// PostRedeemReq defines the properties of a redeem token request's body +type PostRedeemReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Tokens sdk.Coin `json:"tokens" yaml:"tokens"` +} + +// PostBlockAddressReq defines the properties of a block address request's body +type PostBlockAddressReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Address sdk.AccAddress `json:"blocked_address" yaml:"blocked_address"` + Denom string `json:"denom" yaml:"denom"` +} + +// PostUnblockAddressReq defines the properties of a unblock address request's body +type PostUnblockAddressReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Address sdk.AccAddress `json:"blocked_address" yaml:"blocked_address"` + Denom string `json:"denom" yaml:"denom"` +} + +// PostPauseReq defines the properties of a pause request's body +type PostPauseReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + Denom string `json:"denom" yaml:"denom"` + Status bool `json:"status" yaml:"status"` +} diff --git a/x/issuance/client/rest/tx.go b/x/issuance/client/rest/tx.go new file mode 100644 index 00000000..449708fe --- /dev/null +++ b/x/issuance/client/rest/tx.go @@ -0,0 +1,181 @@ +package rest + +import ( + "fmt" + "net/http" + + "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/gorilla/mux" + + "github.com/kava-labs/kava/x/issuance/types" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc(fmt.Sprintf("/%s/issue", types.ModuleName), postIssueTokensHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/%s/redeem", types.ModuleName), postRedeemTokensHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/%s/block", types.ModuleName), postBlockAddressHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/%s/unblock", types.ModuleName), postUnblockAddressHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/%s/pause", types.ModuleName), postPauseHandlerFn(cliCtx)).Methods("POST") +} + +func postIssueTokensHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var requestBody PostIssueReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &requestBody) { + return + } + + requestBody.BaseReq = requestBody.BaseReq.Sanitize() + if !requestBody.BaseReq.ValidateBasic(w) { + return + } + + fromAddr, err := sdk.AccAddressFromBech32(requestBody.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgIssueTokens( + fromAddr, + requestBody.Tokens, + requestBody.Receiver, + ) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + utils.WriteGenerateStdTxResponse(w, cliCtx, requestBody.BaseReq, []sdk.Msg{msg}) + } +} + +func postRedeemTokensHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var requestBody PostRedeemReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &requestBody) { + return + } + + requestBody.BaseReq = requestBody.BaseReq.Sanitize() + if !requestBody.BaseReq.ValidateBasic(w) { + return + } + + fromAddr, err := sdk.AccAddressFromBech32(requestBody.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgRedeemTokens( + fromAddr, + requestBody.Tokens, + ) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + utils.WriteGenerateStdTxResponse(w, cliCtx, requestBody.BaseReq, []sdk.Msg{msg}) + } +} + +func postBlockAddressHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var requestBody PostBlockAddressReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &requestBody) { + return + } + + requestBody.BaseReq = requestBody.BaseReq.Sanitize() + if !requestBody.BaseReq.ValidateBasic(w) { + return + } + + fromAddr, err := sdk.AccAddressFromBech32(requestBody.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgBlockAddress( + fromAddr, + requestBody.Denom, + requestBody.Address, + ) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + utils.WriteGenerateStdTxResponse(w, cliCtx, requestBody.BaseReq, []sdk.Msg{msg}) + } +} + +func postUnblockAddressHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var requestBody PostUnblockAddressReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &requestBody) { + return + } + + requestBody.BaseReq = requestBody.BaseReq.Sanitize() + if !requestBody.BaseReq.ValidateBasic(w) { + return + } + + fromAddr, err := sdk.AccAddressFromBech32(requestBody.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgUnblockAddress( + fromAddr, + requestBody.Denom, + requestBody.Address, + ) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + utils.WriteGenerateStdTxResponse(w, cliCtx, requestBody.BaseReq, []sdk.Msg{msg}) + } +} + +func postPauseHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var requestBody PostPauseReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &requestBody) { + return + } + + requestBody.BaseReq = requestBody.BaseReq.Sanitize() + if !requestBody.BaseReq.ValidateBasic(w) { + return + } + + fromAddr, err := sdk.AccAddressFromBech32(requestBody.BaseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgSetPauseStatus( + fromAddr, + requestBody.Denom, + requestBody.Status, + ) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + utils.WriteGenerateStdTxResponse(w, cliCtx, requestBody.BaseReq, []sdk.Msg{msg}) + } +} diff --git a/x/issuance/genesis.go b/x/issuance/genesis.go new file mode 100644 index 00000000..6ad9f029 --- /dev/null +++ b/x/issuance/genesis.go @@ -0,0 +1,28 @@ +package issuance + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/issuance/keeper" + "github.com/kava-labs/kava/x/issuance/types" +) + +// InitGenesis initializes the store state from a genesis state. +func InitGenesis(ctx sdk.Context, k keeper.Keeper, supplyKeeper types.SupplyKeeper, gs types.GenesisState) { + + k.SetParams(ctx, gs.Params) + + // check if the module account exists + moduleAcc := supplyKeeper.GetModuleAccount(ctx, types.ModuleAccountName) + if moduleAcc == nil { + panic(fmt.Sprintf("%s module account has not been set", types.ModuleAccountName)) + } +} + +// ExportGenesis export genesis state for issuance module +func ExportGenesis(ctx sdk.Context, k keeper.Keeper) types.GenesisState { + params := k.GetParams(ctx) + return types.NewGenesisState(params) +} diff --git a/x/issuance/handler.go b/x/issuance/handler.go new file mode 100644 index 00000000..34b3cd7c --- /dev/null +++ b/x/issuance/handler.go @@ -0,0 +1,116 @@ +package issuance + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + + "github.com/kava-labs/kava/x/issuance/keeper" + "github.com/kava-labs/kava/x/issuance/types" +) + +// NewHandler creates an sdk.Handler for issuance messages +func NewHandler(k keeper.Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) (*sdk.Result, error) { + ctx = ctx.WithEventManager(sdk.NewEventManager()) + + switch msg := msg.(type) { + case types.MsgIssueTokens: + return handleMsgIssueTokens(ctx, k, msg) + case types.MsgRedeemTokens: + return handleMsgRedeemTokens(ctx, k, msg) + case types.MsgBlockAddress: + return handleMsgBlockAddress(ctx, k, msg) + case types.MsgUnblockAddress: + return handleMsgUnblockAddress(ctx, k, msg) + case types.MsgSetPauseStatus: + return handleMsgSetPauseStatus(ctx, k, msg) + default: + return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unrecognized %s message type: %T", types.ModuleName, msg) + } + } +} + +func handleMsgIssueTokens(ctx sdk.Context, k keeper.Keeper, msg types.MsgIssueTokens) (*sdk.Result, error) { + err := k.IssueTokens(ctx, msg.Tokens, msg.Sender, msg.Receiver) + if err != nil { + return nil, err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + ) + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} + +func handleMsgRedeemTokens(ctx sdk.Context, k keeper.Keeper, msg types.MsgRedeemTokens) (*sdk.Result, error) { + err := k.RedeemTokens(ctx, msg.Tokens, msg.Sender) + if err != nil { + return nil, err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + ) + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} + +func handleMsgBlockAddress(ctx sdk.Context, k keeper.Keeper, msg types.MsgBlockAddress) (*sdk.Result, error) { + err := k.BlockAddress(ctx, msg.Denom, msg.Sender, msg.Address) + if err != nil { + return nil, err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + ) + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} + +func handleMsgUnblockAddress(ctx sdk.Context, k keeper.Keeper, msg types.MsgUnblockAddress) (*sdk.Result, error) { + err := k.UnblockAddress(ctx, msg.Denom, msg.Sender, msg.Address) + if err != nil { + return nil, err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + ) + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} + +func handleMsgSetPauseStatus(ctx sdk.Context, k keeper.Keeper, msg types.MsgSetPauseStatus) (*sdk.Result, error) { + err := k.SetPauseStatus(ctx, msg.Sender, msg.Denom, msg.Status) + if err != nil { + return nil, err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + ) + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} diff --git a/x/issuance/keeper/issuance.go b/x/issuance/keeper/issuance.go new file mode 100644 index 00000000..2406422d --- /dev/null +++ b/x/issuance/keeper/issuance.go @@ -0,0 +1,206 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + supplyexported "github.com/cosmos/cosmos-sdk/x/supply/exported" + "github.com/kava-labs/kava/x/issuance/types" +) + +// IssueTokens mints new tokens and sends them to the receiver address +func (k Keeper) IssueTokens(ctx sdk.Context, tokens sdk.Coin, owner, receiver sdk.AccAddress) error { + asset, found := k.GetAsset(ctx, tokens.Denom) + if !found { + return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", tokens.Denom) + } + if !owner.Equals(asset.Owner) { + return sdkerrors.Wrapf(types.ErrNotAuthorized, "owner: %s, address: %s", asset.Owner, owner) + } + if asset.Paused { + return sdkerrors.Wrapf(types.ErrAssetPaused, "denom: %s", tokens.Denom) + } + blocked, _ := k.checkBlockedAddress(ctx, asset, receiver) + if blocked { + return sdkerrors.Wrapf(types.ErrAccountBlocked, "address: %s", receiver) + } + + acc := k.accountKeeper.GetAccount(ctx, receiver) + _, ok := acc.(supplyexported.ModuleAccountI) + if ok { + return sdkerrors.Wrapf(types.ErrIssueToModuleAccount, "address: %s", receiver) + } + + // mint new tokens + err := k.supplyKeeper.MintCoins(ctx, types.ModuleAccountName, sdk.NewCoins(tokens)) + if err != nil { + return err + } + // send to receiver + err = k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, receiver, sdk.NewCoins(tokens)) + if err != nil { + return err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeIssue, + sdk.NewAttribute(types.AttributeKeyIssueAmount, tokens.String()), + ), + ) + return nil +} + +// RedeemTokens sends tokens from the owner address to the module account and burns them +func (k Keeper) RedeemTokens(ctx sdk.Context, tokens sdk.Coin, owner sdk.AccAddress) error { + asset, found := k.GetAsset(ctx, tokens.Denom) + if !found { + return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", tokens.Denom) + } + if !owner.Equals(asset.Owner) { + return sdkerrors.Wrapf(types.ErrNotAuthorized, "owner: %s, address: %s", asset.Owner, owner) + } + if asset.Paused { + return sdkerrors.Wrapf(types.ErrAssetPaused, "denom: %s", tokens.Denom) + } + coins := sdk.NewCoins(tokens) + err := k.supplyKeeper.SendCoinsFromAccountToModule(ctx, owner, types.ModuleAccountName, coins) + if err != nil { + return err + } + err = k.supplyKeeper.BurnCoins(ctx, types.ModuleAccountName, coins) + if err != nil { + return err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeRedeem, + sdk.NewAttribute(types.AttributeKeyRedeemAmount, tokens.String()), + ), + ) + return nil +} + +// BlockAddress adds an address to the blocked list +func (k Keeper) BlockAddress(ctx sdk.Context, denom string, owner, blockedAddress sdk.AccAddress) error { + asset, found := k.GetAsset(ctx, denom) + if !found { + return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", denom) + } + if !owner.Equals(asset.Owner) { + return sdkerrors.Wrapf(types.ErrNotAuthorized, "owner: %s, address: %s", asset.Owner, owner) + } + blocked, _ := k.checkBlockedAddress(ctx, asset, blockedAddress) + if blocked { + return sdkerrors.Wrapf(types.ErrAccountAlreadyBlocked, "address: %s", blockedAddress) + } + asset.BlockedAddresses = append(asset.BlockedAddresses, blockedAddress) + k.SetAsset(ctx, asset) + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeBlock, + sdk.NewAttribute(types.AttributeKeyBlock, blockedAddress.String()), + sdk.NewAttribute(types.AttributeKeyDenom, asset.Denom), + ), + ) + return nil +} + +// UnblockAddress removes an address from the blocked list +func (k Keeper) UnblockAddress(ctx sdk.Context, denom string, owner, addr sdk.AccAddress) error { + asset, found := k.GetAsset(ctx, denom) + if !found { + return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", denom) + } + if !owner.Equals(asset.Owner) { + return sdkerrors.Wrapf(types.ErrNotAuthorized, "owner: %s, address: %s", asset.Owner, owner) + } + blocked, i := k.checkBlockedAddress(ctx, asset, addr) + if !blocked { + if blocked { + return sdkerrors.Wrapf(types.ErrAccountAlreadyUnblocked, "address: %s", addr) + } + } + + blockedAddrs := k.removeBlockedAddress(ctx, asset.BlockedAddresses, i) + asset.BlockedAddresses = blockedAddrs + k.SetAsset(ctx, asset) + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeUnblock, + sdk.NewAttribute(types.AttributeKeyUnblock, addr.String()), + sdk.NewAttribute(types.AttributeKeyDenom, asset.Denom), + ), + ) + return nil +} + +// SetPauseStatus pauses/un-pauses an asset +func (k Keeper) SetPauseStatus(ctx sdk.Context, owner sdk.AccAddress, denom string, status bool) error { + asset, found := k.GetAsset(ctx, denom) + if !found { + return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", denom) + } + if !owner.Equals(asset.Owner) { + return sdkerrors.Wrapf(types.ErrNotAuthorized, "owner: %s, address: %s", asset.Owner, owner) + } + if asset.Paused == status { + return nil + } + asset.Paused = !asset.Paused + k.SetAsset(ctx, asset) + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypePause, + sdk.NewAttribute(types.AttributeKeyPauseStatus, fmt.Sprintf("%t", status)), + sdk.NewAttribute(types.AttributeKeyDenom, asset.Denom), + ), + ) + return nil +} + +// SeizeCoinsFromBlockedAddresses checks blocked addresses for coins of the input denom and transfers them to the owner account +func (k Keeper) SeizeCoinsFromBlockedAddresses(ctx sdk.Context, denom string) error { + asset, found := k.GetAsset(ctx, denom) + if !found { + return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", denom) + } + for _, address := range asset.BlockedAddresses { + account := k.accountKeeper.GetAccount(ctx, address) + coinsAmount := account.GetCoins().AmountOf(denom) + if !coinsAmount.IsPositive() { + continue + } + coins := sdk.NewCoins(sdk.NewCoin(denom, coinsAmount)) + err := k.supplyKeeper.SendCoinsFromAccountToModule(ctx, address, types.ModuleAccountName, coins) + if err != nil { + return err + } + err = k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, asset.Owner, coins) + if err != nil { + return err + } + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeSeize, + sdk.NewAttribute(sdk.AttributeKeyAmount, coins.String()), + sdk.NewAttribute(types.AttributeKeyAddress, address.String()), + ), + ) + } + return nil +} + +func (k Keeper) checkBlockedAddress(ctx sdk.Context, asset types.Asset, checkAddress sdk.AccAddress) (bool, int) { + for i, address := range asset.BlockedAddresses { + if address.Equals(checkAddress) { + return true, i + } + } + return false, 0 +} + +func (k Keeper) removeBlockedAddress(ctx sdk.Context, blockedAddrs []sdk.AccAddress, i int) []sdk.AccAddress { + blockedAddrs[len(blockedAddrs)-1], blockedAddrs[i] = blockedAddrs[i], blockedAddrs[len(blockedAddrs)-1] + return blockedAddrs[:len(blockedAddrs)-1] +} diff --git a/x/issuance/keeper/issuance_test.go b/x/issuance/keeper/issuance_test.go new file mode 100644 index 00000000..a3de2abd --- /dev/null +++ b/x/issuance/keeper/issuance_test.go @@ -0,0 +1,671 @@ +package keeper_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/suite" + + sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + supplyexported "github.com/cosmos/cosmos-sdk/x/supply/exported" + + abci "github.com/tendermint/tendermint/abci/types" + tmtime "github.com/tendermint/tendermint/types/time" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/issuance/keeper" + "github.com/kava-labs/kava/x/issuance/types" +) + +// Test suite used for all keeper tests +type KeeperTestSuite struct { + suite.Suite + + keeper keeper.Keeper + app app.TestApp + ctx sdk.Context + addrs []sdk.AccAddress + modAccount sdk.AccAddress +} + +// The default state used by each test +func (suite *KeeperTestSuite) SetupTest() { + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + tApp.InitializeFromGenesisStates() + _, addrs := app.GeneratePrivKeyAddressPairs(5) + keeper := tApp.GetIssuanceKeeper() + modAccount, err := sdk.AccAddressFromBech32("kava1cj7njkw2g9fqx4e768zc75dp9sks8u9znxrf0w") + suite.Require().NoError(err) + suite.app = tApp + suite.ctx = ctx + suite.keeper = keeper + suite.addrs = addrs + suite.modAccount = modAccount +} + +func (suite *KeeperTestSuite) getAccount(addr sdk.AccAddress) authexported.Account { + ak := suite.app.GetAccountKeeper() + return ak.GetAccount(suite.ctx, addr) +} + +func (suite *KeeperTestSuite) getModuleAccount(name string) supplyexported.ModuleAccountI { + sk := suite.app.GetSupplyKeeper() + return sk.GetModuleAccount(suite.ctx, name) +} + +func (suite *KeeperTestSuite) TestGetSetParams() { + params := suite.keeper.GetParams(suite.ctx) + suite.Require().Equal(types.Params{Assets: types.Assets(nil)}, params) + asset := types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false) + params = types.NewParams(types.Assets{asset}) + suite.keeper.SetParams(suite.ctx, params) + newParams := suite.keeper.GetParams(suite.ctx) + suite.Require().Equal(params, newParams) +} + +func (suite *KeeperTestSuite) TestIssueTokens() { + type args struct { + assets types.Assets + sender sdk.AccAddress + tokens sdk.Coin + receiver sdk.AccAddress + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "valid issuance", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + receiver: suite.addrs[2], + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "non-owner issuance", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[2], + tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + receiver: suite.addrs[3], + }, + errArgs{ + expectPass: false, + contains: "account not authorized", + }, + }, + { + "invalid denom", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + tokens: sdk.NewCoin("othertoken", sdk.NewInt(100000)), + receiver: suite.addrs[2], + }, + errArgs{ + expectPass: false, + contains: "no asset with input denom found", + }, + }, + { + "issue to blocked address", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + receiver: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "account is blocked", + }, + }, + { + "issue to module account", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + receiver: suite.modAccount, + }, + errArgs{ + expectPass: false, + contains: "cannot issue tokens to module account", + }, + }, + { + "paused issuance", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, true), + }, + sender: suite.addrs[0], + tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + receiver: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "asset is paused", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := types.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + err := suite.keeper.IssueTokens(suite.ctx, tc.args.tokens, tc.args.sender, tc.args.receiver) + if tc.errArgs.expectPass { + suite.Require().NoError(err, tc.name) + receiverAccount := suite.getAccount(tc.args.receiver) + suite.Require().Equal(sdk.NewCoins(tc.args.tokens), receiverAccount.GetCoins()) + } else { + suite.Require().Error(err, tc.name) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *KeeperTestSuite) TestRedeemTokens() { + type args struct { + assets types.Assets + sender sdk.AccAddress + initialTokens sdk.Coin + redeemTokens sdk.Coin + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "valid redemption", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + redeemTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid denom redemption", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + redeemTokens: sdk.NewCoin("othertoken", sdk.NewInt(100000)), + }, + errArgs{ + expectPass: false, + contains: "", + }, + }, + { + "non-owner redemption", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[2], + initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + redeemTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + }, + errArgs{ + expectPass: false, + contains: "account not authorized", + }, + }, + { + "paused redemption", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, true), + }, + sender: suite.addrs[0], + initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + redeemTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + }, + errArgs{ + expectPass: false, + contains: "asset is paused", + }, + }, + { + "redeem amount greater than balance", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + redeemTokens: sdk.NewCoin("usdtoken", sdk.NewInt(200000)), + }, + errArgs{ + expectPass: false, + contains: "insufficient funds", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := types.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + sk := suite.app.GetSupplyKeeper() + err := sk.MintCoins(suite.ctx, types.ModuleAccountName, sdk.NewCoins(tc.args.initialTokens)) + suite.Require().NoError(err) + err = sk.SendCoinsFromModuleToAccount(suite.ctx, types.ModuleAccountName, tc.args.sender, sdk.NewCoins(tc.args.initialTokens)) + suite.Require().NoError(err) + + err = suite.keeper.RedeemTokens(suite.ctx, tc.args.redeemTokens, tc.args.sender) + + if tc.errArgs.expectPass { + suite.Require().NoError(err) + initialSupply := sdk.NewCoins(tc.args.redeemTokens) + moduleAccount := suite.getModuleAccount(types.ModuleAccountName) + suite.Require().Equal(initialSupply.Sub(sdk.NewCoins(tc.args.redeemTokens)), moduleAccount.GetCoins()) + } else { + suite.Require().Error(err) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *KeeperTestSuite) TestBlockAddress() { + type args struct { + assets types.Assets + sender sdk.AccAddress + blockedAddr sdk.AccAddress + denom string + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "valid block", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + sender: suite.addrs[0], + blockedAddr: suite.addrs[1], + denom: "usdtoken", + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "non-owner block", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + sender: suite.addrs[2], + blockedAddr: suite.addrs[1], + denom: "usdtoken", + }, + errArgs{ + expectPass: false, + contains: "account not authorized", + }, + }, + { + "invalid denom block", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + sender: suite.addrs[0], + blockedAddr: suite.addrs[1], + denom: "othertoken", + }, + errArgs{ + expectPass: false, + contains: "no asset with input denom found", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := types.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + + err := suite.keeper.BlockAddress(suite.ctx, tc.args.denom, tc.args.sender, tc.args.blockedAddr) + if tc.errArgs.expectPass { + suite.Require().NoError(err, tc.name) + asset, found := suite.keeper.GetAsset(suite.ctx, tc.args.denom) + blocked := false + suite.Require().True(found) + for _, blockedAddr := range asset.BlockedAddresses { + if blockedAddr.Equals(tc.args.blockedAddr) { + blocked = true + } + } + suite.Require().True(blocked) + } else { + suite.Require().Error(err, tc.name) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *KeeperTestSuite) TestUnblockAddress() { + type args struct { + assets types.Assets + sender sdk.AccAddress + blockedAddr sdk.AccAddress + denom string + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "valid unblock", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + blockedAddr: suite.addrs[1], + denom: "usdtoken", + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "non-owner unblock", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[2], + blockedAddr: suite.addrs[1], + denom: "usdtoken", + }, + errArgs{ + expectPass: false, + contains: "account not authorized", + }, + }, + { + "invalid denom block", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + sender: suite.addrs[0], + blockedAddr: suite.addrs[1], + denom: "othertoken", + }, + errArgs{ + expectPass: false, + contains: "no asset with input denom found", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := types.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + + err := suite.keeper.UnblockAddress(suite.ctx, tc.args.denom, tc.args.sender, tc.args.blockedAddr) + if tc.errArgs.expectPass { + suite.Require().NoError(err, tc.name) + asset, found := suite.keeper.GetAsset(suite.ctx, tc.args.denom) + blocked := false + suite.Require().True(found) + for _, blockedAddr := range asset.BlockedAddresses { + if blockedAddr.Equals(tc.args.blockedAddr) { + blocked = true + } + } + suite.Require().False(blocked) + } else { + suite.Require().Error(err, tc.name) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *KeeperTestSuite) TestChangePauseStatus() { + type args struct { + assets types.Assets + sender sdk.AccAddress + startStatus bool + endStatus bool + denom string + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "valid pause", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + sender: suite.addrs[0], + startStatus: false, + endStatus: true, + denom: "usdtoken", + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "valid unpause", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, true), + }, + sender: suite.addrs[0], + startStatus: true, + endStatus: false, + denom: "usdtoken", + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "non-owner pause", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + sender: suite.addrs[2], + startStatus: false, + endStatus: true, + denom: "usdtoken", + }, + errArgs{ + expectPass: false, + contains: "account not authorized", + }, + }, + { + "invalid denom pause", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + sender: suite.addrs[0], + startStatus: true, + endStatus: false, + denom: "othertoken", + }, + errArgs{ + expectPass: false, + contains: "no asset with input denom found", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := types.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + + err := suite.keeper.SetPauseStatus(suite.ctx, tc.args.sender, tc.args.denom, tc.args.endStatus) + if tc.errArgs.expectPass { + suite.Require().NoError(err, tc.name) + asset, found := suite.keeper.GetAsset(suite.ctx, tc.args.denom) + suite.Require().True(found) + suite.Require().Equal(tc.args.endStatus, asset.Paused) + } else { + suite.Require().Error(err, tc.name) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *KeeperTestSuite) TestSeizeCoinsFromBlockedAddress() { + type args struct { + assets types.Assets + initialCoins sdk.Coin + blockedAddrs []sdk.AccAddress + denom string + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "valid seize", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + initialCoins: sdk.NewCoin("usdtoken", sdk.NewInt(100000000)), + denom: "usdtoken", + blockedAddrs: []sdk.AccAddress{suite.addrs[1], suite.addrs[2]}, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid denom seize", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + }, + initialCoins: sdk.NewCoin("usdtoken", sdk.NewInt(100000000)), + denom: "othertoken", + blockedAddrs: []sdk.AccAddress{suite.addrs[1], suite.addrs[2]}, + }, + errArgs{ + expectPass: false, + contains: "no asset with input denom found", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + assetsWithBlockedAddrs := types.Assets{} + for _, asset := range tc.args.assets { + asset.BlockedAddresses = tc.args.blockedAddrs + assetsWithBlockedAddrs = append(assetsWithBlockedAddrs, asset) + } + params := types.NewParams(assetsWithBlockedAddrs) + suite.keeper.SetParams(suite.ctx, params) + sk := suite.app.GetSupplyKeeper() + for _, addr := range tc.args.blockedAddrs { + err := sk.MintCoins(suite.ctx, types.ModuleAccountName, sdk.NewCoins(tc.args.initialCoins)) + suite.Require().NoError(err) + err = sk.SendCoinsFromModuleToAccount(suite.ctx, types.ModuleAccountName, addr, sdk.NewCoins(tc.args.initialCoins)) + } + + err := suite.keeper.SeizeCoinsFromBlockedAddresses(suite.ctx, tc.args.denom) + if tc.errArgs.expectPass { + suite.Require().NoError(err, tc.name) + asset, found := suite.keeper.GetAsset(suite.ctx, tc.args.denom) + suite.Require().True(found) + ownerAccount := suite.getAccount(asset.Owner) + ownerCoinAmount := tc.args.initialCoins.Amount.Mul(sdk.NewInt(int64(len(tc.args.blockedAddrs)))) + suite.Require().Equal(sdk.NewCoins(sdk.NewCoin(tc.args.denom, ownerCoinAmount)), ownerAccount.GetCoins()) + } else { + suite.Require().Error(err, tc.name) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func TestKeeperTestSuite(t *testing.T) { + suite.Run(t, new(KeeperTestSuite)) +} diff --git a/x/issuance/keeper/keeper.go b/x/issuance/keeper/keeper.go new file mode 100644 index 00000000..ce40fd38 --- /dev/null +++ b/x/issuance/keeper/keeper.go @@ -0,0 +1,33 @@ +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/issuance/types" +) + +// Keeper keeper for the issuance module +type Keeper struct { + key sdk.StoreKey + cdc *codec.Codec + paramSubspace subspace.Subspace + accountKeeper types.AccountKeeper + supplyKeeper types.SupplyKeeper +} + +// NewKeeper returns a new keeper +func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace, ak types.AccountKeeper, sk types.SupplyKeeper) Keeper { + if !paramstore.HasKeyTable() { + paramstore = paramstore.WithKeyTable(types.ParamKeyTable()) + } + + return Keeper{ + key: key, + cdc: cdc, + paramSubspace: paramstore, + accountKeeper: ak, + supplyKeeper: sk, + } +} diff --git a/x/issuance/keeper/params.go b/x/issuance/keeper/params.go new file mode 100644 index 00000000..a6b13c42 --- /dev/null +++ b/x/issuance/keeper/params.go @@ -0,0 +1,41 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/issuance/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) +} + +// GetAsset returns an asset from the params and a boolean for if it was found +func (k Keeper) GetAsset(ctx sdk.Context, denom string) (types.Asset, bool) { + params := k.GetParams(ctx) + for _, asset := range params.Assets { + if asset.Denom == denom { + return asset, true + } + } + return types.Asset{}, false +} + +// SetAsset sets an asset in the params +func (k Keeper) SetAsset(ctx sdk.Context, asset types.Asset) { + params := k.GetParams(ctx) + for i := range params.Assets { + if params.Assets[i].Denom == asset.Denom { + params.Assets[i] = asset + } + } + k.SetParams(ctx, params) +} diff --git a/x/issuance/keeper/querier.go b/x/issuance/keeper/querier.go new file mode 100644 index 00000000..21c64e3e --- /dev/null +++ b/x/issuance/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/issuance/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 store +func queryGetParams(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, error) { + // Get params + params := k.GetParams(ctx) + + // Encode results + bz, err := codec.MarshalJSONIndent(k.cdc, params) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + return bz, nil +} diff --git a/x/issuance/keeper/querier_test.go b/x/issuance/keeper/querier_test.go new file mode 100644 index 00000000..ed668539 --- /dev/null +++ b/x/issuance/keeper/querier_test.go @@ -0,0 +1,19 @@ +package keeper_test + +import ( + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/x/issuance/keeper" + "github.com/kava-labs/kava/x/issuance/types" +) + +func (suite *KeeperTestSuite) TestQuerierGetParams() { + querier := keeper.NewQuerier(suite.keeper) + bz, err := querier(suite.ctx, []string{types.QueryGetParams}, abci.RequestQuery{}) + suite.Require().NoError(err) + suite.NotNil(bz) + + var p types.Params + suite.Nil(types.ModuleCdc.UnmarshalJSON(bz, &p)) + suite.Require().Equal(types.Params{Assets: types.Assets(nil)}, p) +} diff --git a/x/issuance/module.go b/x/issuance/module.go new file mode 100644 index 00000000..6cfd7a89 --- /dev/null +++ b/x/issuance/module.go @@ -0,0 +1,170 @@ +package issuance + +import ( + "encoding/json" + "math/rand" + + "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" + sim "github.com/cosmos/cosmos-sdk/x/simulation" + + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/x/issuance/client/cli" + "github.com/kava-labs/kava/x/issuance/client/rest" + "github.com/kava-labs/kava/x/issuance/simulation" + "github.com/kava-labs/kava/x/issuance/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 issuance module. +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr) +} + +// GetTxCmd returns the root tx command for the issuance module. +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(cdc) +} + +// GetQueryCmd returns no root query command for the issuance module. +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(StoreKey, cdc) +} + +//____________________________________________________________________________ + +// AppModule app module type +type AppModule struct { + AppModuleBasic + + keeper Keeper + accountKeeper types.AccountKeeper + supplyKeeper types.SupplyKeeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper, accountKeeper types.AccountKeeper, supplyKeeper types.SupplyKeeper) AppModule { + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + accountKeeper: accountKeeper, + supplyKeeper: supplyKeeper, + } +} + +// 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 ModuleName +} + +// NewQuerierHandler returns no sdk.Querier. +func (am AppModule) NewQuerierHandler() sdk.Querier { + return nil +} + +// 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, am.supplyKeeper, 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(ctx 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 issuance 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 issuance has no params. +func (AppModuleBasic) RandomizedParams(r *rand.Rand) []sim.ParamChange { + return simulation.ParamChanges(r) +} + +// RegisterStoreDecoder registers a decoder for issuance module's types +func (AppModuleBasic) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { + sdr[StoreKey] = simulation.DecodeStore +} + +// WeightedOperations returns the all the issuance module operations with their respective weights. +func (am AppModule) WeightedOperations(simState module.SimulationState) []sim.WeightedOperation { + return simulation.WeightedOperations(simState.AppParams, simState.Cdc, am.accountKeeper, am.keeper) +} diff --git a/x/issuance/simulation/decoder.go b/x/issuance/simulation/decoder.go new file mode 100644 index 00000000..baa943de --- /dev/null +++ b/x/issuance/simulation/decoder.go @@ -0,0 +1,16 @@ +package simulation + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/codec" + + "github.com/kava-labs/kava/x/issuance/types" + + "github.com/tendermint/tendermint/libs/kv" +) + +// DecodeStore the issuance module has no store keys -- all state is stored in params +func DecodeStore(cdc *codec.Codec, kvA, kvB kv.Pair) string { + panic(fmt.Sprintf("invalid %s key prefix %X", types.ModuleName, kvA.Key[:1])) +} diff --git a/x/issuance/simulation/genesis.go b/x/issuance/simulation/genesis.go new file mode 100644 index 00000000..a0e746cb --- /dev/null +++ b/x/issuance/simulation/genesis.go @@ -0,0 +1,58 @@ +package simulation + +import ( + "fmt" + "math/rand" + "strings" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/kava-labs/kava/x/issuance/types" +) + +var ( + accs []simulation.Account +) + +// RandomizedGenState generates a random GenesisState for the module +func RandomizedGenState(simState *module.SimulationState) { + accs = simState.Accounts + params := randomizedParams(simState.Rand) + gs := types.NewGenesisState(params) + fmt.Printf("Selected randomly generated %s parameters:\n%s\n", types.ModuleName, codec.MustMarshalJSONIndent(simState.Cdc, gs)) + simState.GenState[types.ModuleName] = simState.Cdc.MustMarshalJSON(gs) +} + +func randomizedParams(r *rand.Rand) types.Params { + assets := randomizedAssets(r) + return types.NewParams(assets) +} + +func randomizedAssets(r *rand.Rand) types.Assets { + randomAssets := types.Assets{} + numAssets := Max(1, r.Intn(5)) + for i := 0; i < numAssets; i++ { + denom := strings.ToLower(simulation.RandStringOfLength(r, (r.Intn(3) + 3))) + owner := randomOwner(r) + paused := r.Intn(2) == 0 + randomAsset := types.NewAsset(owner.Address, denom, []sdk.AccAddress{}, paused) + randomAssets = append(randomAssets, randomAsset) + } + return randomAssets +} + +func randomOwner(r *rand.Rand) simulation.Account { + acc, _ := simulation.RandomAcc(r, accs) + return acc +} + +// Max return max of two ints +func Max(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/x/issuance/simulation/operations.go b/x/issuance/simulation/operations.go new file mode 100644 index 00000000..532ea29e --- /dev/null +++ b/x/issuance/simulation/operations.go @@ -0,0 +1,305 @@ +package simulation + +import ( + "fmt" + "math/rand" + + "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/simapp/helpers" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/simulation" + simappparams "github.com/kava-labs/kava/app/params" + + "github.com/kava-labs/kava/x/issuance/keeper" + "github.com/kava-labs/kava/x/issuance/types" +) + +// Simulation operation weights constants +const ( + OpWeightMsgIssue = "op_weight_msg_issue" + OpWeightMsgRedeem = "op_weight_msg_redeem" + OpWeightMsgBlock = "op_weight_msg_block" + OpWeightMsgPause = "op_weight_msg_pause" +) + +// WeightedOperations returns all the operations from the module with their respective weights +func WeightedOperations( + appParams simulation.AppParams, cdc *codec.Codec, ak types.AccountKeeper, k keeper.Keeper, +) simulation.WeightedOperations { + var ( + weightMsgIssue int + weightMsgReedem int + weightMsgBlock int + weightMsgPause int + ) + + appParams.GetOrGenerate(cdc, OpWeightMsgIssue, &weightMsgIssue, nil, + func(_ *rand.Rand) { + weightMsgIssue = simappparams.DefaultWeightMsgIssue + }, + ) + + appParams.GetOrGenerate(cdc, OpWeightMsgRedeem, &weightMsgReedem, nil, + func(_ *rand.Rand) { + weightMsgReedem = simappparams.DefaultWeightMsgRedeem + }, + ) + + appParams.GetOrGenerate(cdc, OpWeightMsgBlock, &weightMsgBlock, nil, + func(_ *rand.Rand) { + weightMsgBlock = simappparams.DefaultWeightMsgBlock + }, + ) + + appParams.GetOrGenerate(cdc, OpWeightMsgPause, &weightMsgPause, nil, + func(_ *rand.Rand) { + weightMsgPause = simappparams.DefaultWeightMsgPause + }, + ) + + return simulation.WeightedOperations{ + simulation.NewWeightedOperation( + weightMsgIssue, + SimulateMsgIssueTokens(ak, k), + ), + simulation.NewWeightedOperation( + weightMsgReedem, + SimulateMsgRedeemTokens(ak, k), + ), + simulation.NewWeightedOperation( + weightMsgBlock, + SimulateMsgBlockAddress(ak, k), + ), + simulation.NewWeightedOperation( + weightMsgPause, + SimulateMsgPause(ak, k), + ), + } +} + +// SimulateMsgIssueTokens generates a MsgIssueTokens with random values +func SimulateMsgIssueTokens(ak types.AccountKeeper, k keeper.Keeper) simulation.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string, + ) (simulation.OperationMsg, []simulation.FutureOperation, error) { + // shuffle the assets and get a random one + assets := k.GetParams(ctx).Assets + r.Shuffle(len(assets), func(i, j int) { + assets[i], assets[j] = assets[j], assets[i] + }) + asset := assets[0] + + if asset.Paused { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + + // make sure owner account exists + ownerSimAcc, found := simulation.FindAccount(accs, asset.Owner) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("asset owner not found: %s", asset) + } + + // issue new tokens to the owner 50% of the time so we have funds to redeem + ownerAcc := ak.GetAccount(ctx, asset.Owner) + recipient := ownerAcc + if r.Intn(2) == 0 { + simAccount, _ := simulation.RandomAcc(r, accs) + recipient = ak.GetAccount(ctx, simAccount.Address) + } + if recipient == nil { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + for _, blockedAddr := range asset.BlockedAddresses { + if recipient.GetAddress().Equals(blockedAddr) { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + } + randomAmount := simulation.RandIntBetween(r, 10000000, 1000000000000) + msg := types.NewMsgIssueTokens(asset.Owner, sdk.NewCoin(asset.Denom, sdk.NewInt(int64(randomAmount))), recipient.GetAddress()) + spendableCoins := ownerAcc.SpendableCoins(ctx.BlockTime()) + fees, err := simulation.RandomFees(r, ctx, spendableCoins) + if err != nil { + return simulation.NoOpMsg(types.ModuleName), nil, err + } + tx := helpers.GenTx( + []sdk.Msg{msg}, + fees, + helpers.DefaultGenTxGas, + chainID, + []uint64{ownerAcc.GetAccountNumber()}, + []uint64{ownerAcc.GetSequence()}, + ownerSimAcc.PrivKey, + ) + + _, _, err = app.Deliver(tx) + if err != nil { + fmt.Printf("error on issue %s\n%s\n", msg, asset) + return simulation.NoOpMsg(types.ModuleName), nil, err + } + return simulation.NewOperationMsg(msg, true, ""), nil, nil + } +} + +// SimulateMsgRedeemTokens generates a MsgRedeemTokens with random values +func SimulateMsgRedeemTokens(ak types.AccountKeeper, k keeper.Keeper) simulation.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string, + ) (simulation.OperationMsg, []simulation.FutureOperation, error) { + // shuffle the assets and get a random one + assets := k.GetParams(ctx).Assets + r.Shuffle(len(assets), func(i, j int) { + assets[i], assets[j] = assets[j], assets[i] + }) + asset := assets[0] + if asset.Paused { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + + // make sure owner account exists + ownerSimAcc, found := simulation.FindAccount(accs, asset.Owner) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("asset owner not found: %s", asset) + } + + ownerAcc := ak.GetAccount(ctx, asset.Owner) + + spendableCoinAmount := ownerAcc.SpendableCoins(ctx.BlockTime()).AmountOf(asset.Denom) + if spendableCoinAmount.IsZero() { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + var redeemAmount sdk.Int + if spendableCoinAmount.Equal(sdk.OneInt()) { + redeemAmount = sdk.OneInt() + } else { + redeemAmount = sdk.NewInt(int64(simulation.RandIntBetween(r, 1, int(spendableCoinAmount.Int64())))) + } + + msg := types.NewMsgRedeemTokens(asset.Owner, sdk.NewCoin(asset.Denom, redeemAmount)) + spendableCoins := ownerAcc.SpendableCoins(ctx.BlockTime()).Sub(sdk.NewCoins(sdk.NewCoin(asset.Denom, redeemAmount))) + fees, err := simulation.RandomFees(r, ctx, spendableCoins) + if err != nil { + fmt.Printf("%s\n", msg) + return simulation.NoOpMsg(types.ModuleName), nil, err + } + tx := helpers.GenTx( + []sdk.Msg{msg}, + fees, + helpers.DefaultGenTxGas, + chainID, + []uint64{ownerAcc.GetAccountNumber()}, + []uint64{ownerAcc.GetSequence()}, + ownerSimAcc.PrivKey, + ) + + _, _, err = app.Deliver(tx) + if err != nil { + fmt.Printf("error on redeem %s\n%s\n", msg, asset) + return simulation.NoOpMsg(types.ModuleName), nil, err + } + return simulation.NewOperationMsg(msg, true, ""), nil, nil + } +} + +// SimulateMsgBlockAddress generates a MsgBlockAddress with random values +func SimulateMsgBlockAddress(ak types.AccountKeeper, k keeper.Keeper) simulation.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string, + ) (simulation.OperationMsg, []simulation.FutureOperation, error) { + // shuffle the assets and get a random one + assets := k.GetParams(ctx).Assets + r.Shuffle(len(assets), func(i, j int) { + assets[i], assets[j] = assets[j], assets[i] + }) + asset := assets[0] + + // make sure owner account exists + ownerSimAcc, found := simulation.FindAccount(accs, asset.Owner) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("asset owner not found: %s", asset) + } + ownerAcc := ak.GetAccount(ctx, asset.Owner) + + // find an account to block + simAccount, _ := simulation.RandomAcc(r, accs) + blockedAccount := ak.GetAccount(ctx, simAccount.Address) + if blockedAccount.GetAddress().Equals(asset.Owner) { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + for _, blockedAddr := range asset.BlockedAddresses { + if blockedAccount.GetAddress().Equals(blockedAddr) { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + } + + msg := types.NewMsgBlockAddress(asset.Owner, asset.Denom, blockedAccount.GetAddress()) + spendableCoins := ownerAcc.SpendableCoins(ctx.BlockTime()) + fees, err := simulation.RandomFees(r, ctx, spendableCoins) + if err != nil { + return simulation.NoOpMsg(types.ModuleName), nil, err + } + tx := helpers.GenTx( + []sdk.Msg{msg}, + fees, + helpers.DefaultGenTxGas*2, + chainID, + []uint64{ownerAcc.GetAccountNumber()}, + []uint64{ownerAcc.GetSequence()}, + ownerSimAcc.PrivKey, + ) + + _, _, err = app.Deliver(tx) + if err != nil { + fmt.Printf("error on block %s\n%s\n", msg, asset) + return simulation.NoOpMsg(types.ModuleName), nil, err + } + return simulation.NewOperationMsg(msg, true, ""), nil, nil + } +} + +// SimulateMsgPause generates a MsgChangePauseStatus with random values +func SimulateMsgPause(ak types.AccountKeeper, k keeper.Keeper) simulation.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string, + ) (simulation.OperationMsg, []simulation.FutureOperation, error) { + // shuffle the assets and get a random one + assets := k.GetParams(ctx).Assets + r.Shuffle(len(assets), func(i, j int) { + assets[i], assets[j] = assets[j], assets[i] + }) + asset := assets[0] + + // make sure owner account exists + ownerSimAcc, found := simulation.FindAccount(accs, asset.Owner) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("asset owner not found: %s", asset) + } + ownerAcc := ak.GetAccount(ctx, asset.Owner) + + // set status to paused/un-paused + status := r.Intn(2) == 0 + + msg := types.NewMsgSetPauseStatus(asset.Owner, asset.Denom, status) + spendableCoins := ownerAcc.SpendableCoins(ctx.BlockTime()) + fees, err := simulation.RandomFees(r, ctx, spendableCoins) + if err != nil { + return simulation.NoOpMsg(types.ModuleName), nil, err + } + tx := helpers.GenTx( + []sdk.Msg{msg}, + fees, + helpers.DefaultGenTxGas*2, + chainID, + []uint64{ownerAcc.GetAccountNumber()}, + []uint64{ownerAcc.GetSequence()}, + ownerSimAcc.PrivKey, + ) + + _, _, err = app.Deliver(tx) + if err != nil { + fmt.Printf("error on pause %s\n%s\n", msg, asset) + return simulation.NoOpMsg(types.ModuleName), nil, err + } + return simulation.NewOperationMsg(msg, true, ""), nil, nil + } +} diff --git a/x/issuance/simulation/params.go b/x/issuance/simulation/params.go new file mode 100644 index 00000000..984f30cf --- /dev/null +++ b/x/issuance/simulation/params.go @@ -0,0 +1,25 @@ +package simulation + +import ( + "fmt" + "math/rand" + + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/kava-labs/kava/x/issuance/types" +) + +const ( + keyAssets = "Assets" +) + +// ParamChanges defines the parameters that can be modified by param change proposals +func ParamChanges(r *rand.Rand) []simulation.ParamChange { + return []simulation.ParamChange{ + simulation.NewSimParamChange(types.ModuleName, keyAssets, + func(r *rand.Rand) string { + return fmt.Sprintf("\"%s\"", randomizedAssets(r)) + }, + ), + } +} diff --git a/x/issuance/spec/01_concepts.md b/x/issuance/spec/01_concepts.md new file mode 100644 index 00000000..a0f3b974 --- /dev/null +++ b/x/issuance/spec/01_concepts.md @@ -0,0 +1,7 @@ + + +# Concepts + +The issuance mechanism in this module is designed to allow a trusted party to issue an asset on to the Kava blockchain. The issuer has sole discretion over the minting and redemption (burning) of the asset, as well as restricting access to the asset via asset seizure. The functionality of this module is similar to that of ERC-20 contracts for stablecoins that have a single issuer. diff --git a/x/issuance/spec/02_state.md b/x/issuance/spec/02_state.md new file mode 100644 index 00000000..b815df25 --- /dev/null +++ b/x/issuance/spec/02_state.md @@ -0,0 +1,31 @@ + + +# State + +## Parameters and Genesis State + +```go + +// Asset type for assets in the issuance module +type Asset struct { + Owner sdk.AccAddress `json:"owner" yaml:"owner"` + Denom string `json:"denom" yaml:"denom"` + BlockedAddresses []sdk.AccAddress `json:"blocked_addresses" yaml:"blocked_addresses"` + Paused bool `json:"paused" yaml:"paused"` +} + +// Assets array of Asset +type Assets []Asset + +// Params governance parameters for the issuance module +type Params struct { + Assets Assets `json:"assets" yaml:"assets"` +} + +// GenesisState state that must be provided at genesis +type GenesisState struct { + Assets Assets `json:"assets" yaml:"assets"` +} +``` diff --git a/x/issuance/spec/03_messages.md b/x/issuance/spec/03_messages.md new file mode 100644 index 00000000..6de4869c --- /dev/null +++ b/x/issuance/spec/03_messages.md @@ -0,0 +1,68 @@ + + +# Messages + +The issuer can issue new tokens using a `MsgIssueTokens` + +```go +// MsgIssueTokens message type used to issue tokens +type MsgIssueTokens struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Tokens sdk.Coin `json:"tokens" yaml:"tokens"` + Receiver sdk.AccAddress `json:"receiver" yaml:"receiver"` +} +``` + +## State Modifications + +* New tokens are minted from the issuance module account +* New tokens are transferred from the module account to the receiver + +The issuer can redeem (burn) tokens using `MsgRedeemTokens`. + +```go +// MsgRedeemTokens message type used to redeem (burn) tokens +type MsgRedeemTokens struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Tokens sdk.Coin `json:"tokens" yaml:"tokens"` +} +``` + +## State Modifications + +* Tokens are transferred from the owner address to the issuer module account +* Tokens are burned + +Addresses can be added to the blocked list using `MsgBlockAddress` + +```go +// MsgBlockAddress message type used by the issuer to block an address from holding or transferring tokens +type MsgBlockAddress struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Denom string `json:"denom" yaml:"denom"` + BlockedAddress sdk.AccAddress `json:"blocked_address" yaml:"blocked_address"` +} +``` + +## State Modifications + +* The address is added to the block list, which prevents the account from holding coins of that denom +* Tokens are sent back to the issuer + +The issuer can pause or un-pause the contract using `MsgChangePauseStatus` + +```go +// MsgChangePauseStatus message type used by the issuer to issue new tokens +type MsgChangePauseStatus struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Denom string `json:"denom" yaml:"denom"` + Status bool `json:"status" yaml:"status"` +} +``` + +## State Modifications + +* The `Paused` value of the correspond asset is updated to `Status`. +* Issuance and redemption are paused if `Paused` is false diff --git a/x/issuance/spec/04_events.md b/x/issuance/spec/04_events.md new file mode 100644 index 00000000..2ede622c --- /dev/null +++ b/x/issuance/spec/04_events.md @@ -0,0 +1,18 @@ + + +# Events + +The `x/issuance` module emits the following events: + +## BeginBlock + +| Type | Attribute Key | Attribute Value | +|----------------------|---------------------|-----------------| +| issue_tokens | amount_issued | `{amount}` | +| redeem_tokens | amount_redeemed | `{amount}` | +| block_address | address_blocked | `{address}` | +| block_address | denom | `{denom}` | +| change_pause_status | pause_status | `{bool}` | +| change_pause_status | denom | `{denom}` | \ No newline at end of file diff --git a/x/issuance/spec/05_params.md b/x/issuance/spec/05_params.md new file mode 100644 index 00000000..f87d0a3a --- /dev/null +++ b/x/issuance/spec/05_params.md @@ -0,0 +1,21 @@ + + +# Parameters + +The issuance module has the following parameters: + +| Key | Type | Example | Description | +|------------|----------------|-----------------|---------------------------------------------| +| Assets | array (Asset) | `[{see below}]` | array of assets created via issuance module | + + +Each `Asset` has the following parameters + +| Key | Type | Example | Description | +|-------------------|------------------------|-------------------------------------------------|-------------------------------------------------------| +| Owner | sdk.AccAddress | "kava1cd8z53n7gh2hvz0lmmkzxkysfp5pghufat3h4a" | the address that controls the issuance of the asset | +| Denom | string | "usdtoken" | the denomination or exchange symbol of the asset | +| BlockedAccounts | array (sdk.AccAddress) | ["kava1tp9u8t8ang53a8tjh2mhqvvwdngqzjvmp3mamc"] | addresses which are blocked from holding the asset | +| Paused | boolean | false | boolean for if issuance and redemption are paused | diff --git a/x/issuance/spec/06_begin_block.md b/x/issuance/spec/06_begin_block.md new file mode 100644 index 00000000..9031f031 --- /dev/null +++ b/x/issuance/spec/06_begin_block.md @@ -0,0 +1,16 @@ + + +# Begin Block + +At the start of each block, coins held by blocked addresses are redeemed + +```go + func BeginBlocker(ctx sdk.Context, k Keeper) { + err := k.RedeemTokensFromBlockedAddresses(ctx, k) + if err != nil { + panic(err) + } + } +``` diff --git a/x/issuance/spec/README.md b/x/issuance/spec/README.md new file mode 100644 index 00000000..6d3f6298 --- /dev/null +++ b/x/issuance/spec/README.md @@ -0,0 +1,20 @@ + + +# `issuance` + + +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)** +6. **[BeginBlock](06_begin_block.md)** + +## Abstract + +`x/issuance` is an implementation of a Cosmos SDK Module that allows for an issuer to control the minting and burning of an asset. The issuer is a white-listed address, which may be a multisig address, that can mint and burn coins of a particular denomination. The issuer is the only entity that can mint or burn tokens of that asset type (i.e., there is no outside inflation or deflation). Additionally, the issuer can place transfer-restrictions on addresses, which prevents addresses from transferring and holding tokens with the issued denomination. Coins are minted and burned at the discretion of the issuer, using `MsgIssueTokens` and `MsgRedeemTokens` transaction types, respectively. diff --git a/x/issuance/types/codec.go b/x/issuance/types/codec.go new file mode 100644 index 00000000..0f38ae9c --- /dev/null +++ b/x/issuance/types/codec.go @@ -0,0 +1,23 @@ +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 issuance module +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(MsgIssueTokens{}, "issuance/MsgIssueTokens", nil) + cdc.RegisterConcrete(MsgRedeemTokens{}, "issuance/MsgRedeemTokens", nil) + cdc.RegisterConcrete(MsgBlockAddress{}, "issuance/MsgBlockAddress", nil) + cdc.RegisterConcrete(MsgUnblockAddress{}, "issuance/MsgUnblockAddress", nil) + cdc.RegisterConcrete(MsgSetPauseStatus{}, "issuance/MsgChangePauseStatus", nil) + cdc.RegisterConcrete(Asset{}, "issuance/Asset", nil) +} diff --git a/x/issuance/types/errors.go b/x/issuance/types/errors.go new file mode 100644 index 00000000..8b0a01b9 --- /dev/null +++ b/x/issuance/types/errors.go @@ -0,0 +1,18 @@ +package types + +import ( + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// DONTCOVER + +// Errors used by the issuance module +var ( + ErrAssetNotFound = sdkerrors.Register(ModuleName, 2, "no asset with input denom found") + ErrNotAuthorized = sdkerrors.Register(ModuleName, 3, "account not authorized") + ErrAssetPaused = sdkerrors.Register(ModuleName, 4, "asset is paused") + ErrAccountBlocked = sdkerrors.Register(ModuleName, 5, "account is blocked") + ErrAccountAlreadyBlocked = sdkerrors.Register(ModuleName, 6, "account is already blocked") + ErrAccountAlreadyUnblocked = sdkerrors.Register(ModuleName, 7, "account is already unblocked") + ErrIssueToModuleAccount = sdkerrors.Register(ModuleName, 8, "cannot issue tokens to module account") +) diff --git a/x/issuance/types/events.go b/x/issuance/types/events.go new file mode 100644 index 00000000..4b279fb2 --- /dev/null +++ b/x/issuance/types/events.go @@ -0,0 +1,19 @@ +package types + +// Events emitted by the issuance module +const ( + EventTypeIssue = "issue_tokens" + EventTypeRedeem = "redeem_tokens" + EventTypeBlock = "block_address" + EventTypeUnblock = "unblock_address" + EventTypePause = "change_pause_status" + EventTypeSeize = "seize_coins_from_blocked_address" + AttributeValueCategory = ModuleName + AttributeKeyDenom = "denom" + AttributeKeyIssueAmount = "amount_issued" + AttributeKeyRedeemAmount = "amount_redeemed" + AttributeKeyBlock = "address_blocked" + AttributeKeyUnblock = "address_unblocked" + AttributeKeyAddress = "address" + AttributeKeyPauseStatus = "pause_status" +) diff --git a/x/issuance/types/expected_keepers.go b/x/issuance/types/expected_keepers.go new file mode 100644 index 00000000..b8db8a0a --- /dev/null +++ b/x/issuance/types/expected_keepers.go @@ -0,0 +1,21 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + supplyexported "github.com/cosmos/cosmos-sdk/x/supply/exported" +) + +// SupplyKeeper defines the expected supply keeper for module accounts (noalias) +type SupplyKeeper interface { + GetModuleAccount(ctx sdk.Context, name string) supplyexported.ModuleAccountI + SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error + SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error + BurnCoins(ctx sdk.Context, name string, amt sdk.Coins) error + MintCoins(ctx sdk.Context, name string, amt sdk.Coins) error +} + +// AccountKeeper expected interface for the account keeper (noalias) +type AccountKeeper interface { + GetAccount(ctx sdk.Context, addr sdk.AccAddress) authexported.Account +} diff --git a/x/issuance/types/genesis.go b/x/issuance/types/genesis.go new file mode 100644 index 00000000..8f651326 --- /dev/null +++ b/x/issuance/types/genesis.go @@ -0,0 +1,40 @@ +package types + +import "bytes" + +// GenesisState is the state that must be provided at genesis for the issuance module +type GenesisState struct { + Params Params `json:"params" yaml:"params"` +} + +// NewGenesisState returns a new GenesisState +func NewGenesisState(params Params) GenesisState { + return GenesisState{ + Params: params, + } +} + +// DefaultGenesisState returns the default GenesisState for the issuance module +func DefaultGenesisState() GenesisState { + return GenesisState{ + Params: DefaultParams(), + } +} + +// Validate performs basic validation of genesis data returning an +// error for any failed validation criteria. +func (gs GenesisState) Validate() error { + return gs.Params.Validate() +} + +// Equal checks whether two 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/issuance/types/genesis_test.go b/x/issuance/types/genesis_test.go new file mode 100644 index 00000000..7ebb46f5 --- /dev/null +++ b/x/issuance/types/genesis_test.go @@ -0,0 +1,155 @@ +package types_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/suite" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/issuance/types" +) + +type GenesisTestSuite struct { + suite.Suite + + addrs []sdk.AccAddress +} + +func (suite *GenesisTestSuite) SetupTest() { + config := sdk.GetConfig() + app.SetBech32AddressPrefixes(config) + + _, addrs := app.GeneratePrivKeyAddressPairs(2) + suite.addrs = addrs +} + +func (suite *GenesisTestSuite) TestValidate() { + type args struct { + assets types.Assets + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "default", + args{ + assets: types.DefaultAssets, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "with asset", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "blocked owner", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[0]}, false), + }, + }, + errArgs{ + expectPass: false, + contains: "asset owner cannot be blocked", + }, + }, + { + "empty owner", + args{ + assets: types.Assets{ + types.NewAsset(sdk.AccAddress{}, "usdtoken", []sdk.AccAddress{suite.addrs[0]}, false), + }, + }, + errArgs{ + expectPass: false, + contains: "owner must not be empty", + }, + }, + { + "empty blocked address", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{sdk.AccAddress{}}, false), + }, + }, + errArgs{ + expectPass: false, + contains: "blocked address must not be empty", + }, + }, + { + "invalid denom", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "USD2T ", []sdk.AccAddress{}, false), + }, + }, + errArgs{ + expectPass: false, + contains: "invalid denom", + }, + }, + { + "duplicate denom", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + types.NewAsset(suite.addrs[1], "usdtoken", []sdk.AccAddress{}, true), + }, + }, + errArgs{ + expectPass: false, + contains: "duplicate asset denoms", + }, + }, + { + "duplicate asset", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false), + }, + }, + errArgs{ + expectPass: false, + contains: "duplicate asset denoms", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + gs := types.NewGenesisState(types.NewParams(tc.args.assets)) + err := gs.Validate() + if tc.errArgs.expectPass { + suite.Require().NoError(err, tc.name) + } else { + suite.Require().Error(err, tc.name) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func TestGenesisTestSuite(t *testing.T) { + suite.Run(t, new(GenesisTestSuite)) +} diff --git a/x/issuance/types/keys.go b/x/issuance/types/keys.go new file mode 100644 index 00000000..c58ca5d7 --- /dev/null +++ b/x/issuance/types/keys.go @@ -0,0 +1,18 @@ +package types + +const ( + // ModuleName The name that will be used throughout the module + ModuleName = "issuance" + + // StoreKey Top level store key where all module items will be stored + StoreKey = ModuleName + + // RouterKey Top level router key + RouterKey = ModuleName + + // DefaultParamspace default name for parameter store + DefaultParamspace = ModuleName + + // QuerierRoute route used for abci queries + QuerierRoute = ModuleName +) diff --git a/x/issuance/types/msg.go b/x/issuance/types/msg.go new file mode 100644 index 00000000..d6c52d55 --- /dev/null +++ b/x/issuance/types/msg.go @@ -0,0 +1,282 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// ensure Msg interface compliance at compile time +var _ sdk.Msg = &MsgIssueTokens{} +var _ sdk.Msg = &MsgRedeemTokens{} +var _ sdk.Msg = &MsgBlockAddress{} +var _ sdk.Msg = &MsgUnblockAddress{} +var _ sdk.Msg = &MsgSetPauseStatus{} + +// MsgIssueTokens message type used by the issuer to issue new tokens +type MsgIssueTokens struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Tokens sdk.Coin `json:"tokens" yaml:"tokens"` + Receiver sdk.AccAddress `json:"receiver" yaml:"receiver"` +} + +// NewMsgIssueTokens returns a new MsgIssueTokens +func NewMsgIssueTokens(sender sdk.AccAddress, tokens sdk.Coin, receiver sdk.AccAddress) MsgIssueTokens { + return MsgIssueTokens{ + Sender: sender, + Tokens: tokens, + Receiver: receiver, + } +} + +// Route return the message type used for routing the message. +func (msg MsgIssueTokens) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgIssueTokens) Type() string { return "issue_tokens" } + +// ValidateBasic does a simple validation check that doesn't require access to state. +func (msg MsgIssueTokens) ValidateBasic() error { + if msg.Sender.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "sender address cannot be empty") + } + if msg.Tokens.IsZero() || !msg.Tokens.IsValid() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "invalid tokens %s", msg.Tokens) + } + if msg.Receiver.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "receiver address cannot be empty") + } + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg +func (msg MsgIssueTokens) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign +func (msg MsgIssueTokens) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +// String implements fmt.Stringer +func (msg MsgIssueTokens) String() string { + return fmt.Sprintf(`Issue Tokens: + Sender %s + Tokens %s + Receiver %s + `, msg.Sender, msg.Tokens, msg.Receiver, + ) +} + +// MsgRedeemTokens message type used by the issuer to redeem (burn) tokens +type MsgRedeemTokens struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Tokens sdk.Coin `json:"tokens" yaml:"tokens"` +} + +// NewMsgRedeemTokens returns a new MsgRedeemTokens +func NewMsgRedeemTokens(sender sdk.AccAddress, tokens sdk.Coin) MsgRedeemTokens { + return MsgRedeemTokens{ + Sender: sender, + Tokens: tokens, + } +} + +// Route return the message type used for routing the message. +func (msg MsgRedeemTokens) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgRedeemTokens) Type() string { return "redeem_tokens" } + +// ValidateBasic does a simple validation check that doesn't require access to state. +func (msg MsgRedeemTokens) ValidateBasic() error { + if msg.Sender.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "sender address cannot be empty") + } + if msg.Tokens.IsZero() || !msg.Tokens.IsValid() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "invalid tokens %s", msg.Tokens) + } + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg +func (msg MsgRedeemTokens) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign +func (msg MsgRedeemTokens) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +// String implements fmt.Stringer +func (msg MsgRedeemTokens) String() string { + return fmt.Sprintf(`Redeem Tokens: + Sender %s + Tokens %s + `, msg.Sender, msg.Tokens, + ) +} + +// MsgBlockAddress message type used by the issuer to block an address from holding or transferring tokens +type MsgBlockAddress struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Denom string `json:"denom" yaml:"denom"` + Address sdk.AccAddress `json:"blocked_address" yaml:"blocked_address"` +} + +// NewMsgBlockAddress returns a new MsgBlockAddress +func NewMsgBlockAddress(sender sdk.AccAddress, denom string, addr sdk.AccAddress) MsgBlockAddress { + return MsgBlockAddress{ + Sender: sender, + Denom: denom, + Address: addr, + } +} + +// Route return the message type used for routing the message. +func (msg MsgBlockAddress) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgBlockAddress) Type() string { return "block_address" } + +// ValidateBasic does a simple validation check that doesn't require access to state. +func (msg MsgBlockAddress) ValidateBasic() error { + if msg.Sender.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "sender address cannot be empty") + } + if msg.Address.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "blocked address cannot be empty") + } + return sdk.ValidateDenom(msg.Denom) +} + +// GetSignBytes gets the canonical byte representation of the Msg +func (msg MsgBlockAddress) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign +func (msg MsgBlockAddress) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +// String implements fmt.Stringer +func (msg MsgBlockAddress) String() string { + return fmt.Sprintf(`Block Address: + Sender %s + Denom %s + Address %s + `, msg.Sender, msg.Denom, msg.Address, + ) +} + +// MsgUnblockAddress message type used by the issuer to unblock an address from holding or transferring tokens +type MsgUnblockAddress struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Denom string `json:"denom" yaml:"denom"` + Address sdk.AccAddress `json:"address" yaml:"address"` +} + +// NewMsgUnblockAddress returns a new MsgUnblockAddress +func NewMsgUnblockAddress(sender sdk.AccAddress, denom string, addr sdk.AccAddress) MsgUnblockAddress { + return MsgUnblockAddress{ + Sender: sender, + Denom: denom, + Address: addr, + } +} + +// Route return the message type used for routing the message. +func (msg MsgUnblockAddress) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgUnblockAddress) Type() string { return "unblock_address" } + +// ValidateBasic does a simple validation check that doesn't require access to state. +func (msg MsgUnblockAddress) ValidateBasic() error { + if msg.Sender.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "sender address cannot be empty") + } + if msg.Address.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "blocked address cannot be empty") + } + return sdk.ValidateDenom(msg.Denom) +} + +// GetSignBytes gets the canonical byte representation of the Msg +func (msg MsgUnblockAddress) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign +func (msg MsgUnblockAddress) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +// String implements fmt.Stringer +func (msg MsgUnblockAddress) String() string { + return fmt.Sprintf(`Unblock Address: + Sender %s + Denom %s + Address %s + `, msg.Sender, msg.Denom, msg.Address, + ) +} + +// MsgSetPauseStatus message type used by the issuer to issue new tokens +type MsgSetPauseStatus struct { + Sender sdk.AccAddress `json:"sender" yaml:"sender"` + Denom string `json:"denom" yaml:"denom"` + Status bool `json:"status" yaml:"status"` +} + +// NewMsgSetPauseStatus returns a new MsgSetPauseStatus +func NewMsgSetPauseStatus(sender sdk.AccAddress, denom string, status bool) MsgSetPauseStatus { + return MsgSetPauseStatus{ + Sender: sender, + Denom: denom, + Status: status, + } +} + +// Route return the message type used for routing the message. +func (msg MsgSetPauseStatus) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgSetPauseStatus) Type() string { return "change_pause_status" } + +// ValidateBasic does a simple validation check that doesn't require access to state. +func (msg MsgSetPauseStatus) ValidateBasic() error { + if msg.Sender.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "sender address cannot be empty") + } + return sdk.ValidateDenom(msg.Denom) +} + +// GetSignBytes gets the canonical byte representation of the Msg +func (msg MsgSetPauseStatus) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign +func (msg MsgSetPauseStatus) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +// String implements fmt.Stringer +func (msg MsgSetPauseStatus) String() string { + return fmt.Sprintf(`Set Pause Status: + Sender %s + Denom %s + Status %t + `, msg.Sender, msg.Denom, msg.Status, + ) +} diff --git a/x/issuance/types/params.go b/x/issuance/types/params.go new file mode 100644 index 00000000..3d1a0c72 --- /dev/null +++ b/x/issuance/types/params.go @@ -0,0 +1,133 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params" +) + +// Parameter keys and default values +var ( + KeyAssets = []byte("Assets") + DefaultAssets = Assets{} + ModuleAccountName = ModuleName +) + +// Params governance parameters for the issuance module +type Params struct { + Assets Assets `json:"assets" yaml:"assets"` +} + +// NewParams returns a new params object +func NewParams(assets Assets) Params { + return Params{Assets: assets} +} + +// DefaultParams returns default params for issuance module +func DefaultParams() Params { + return NewParams(DefaultAssets) +} + +// ParamKeyTable Key declaration for parameters +func ParamKeyTable() params.KeyTable { + return params.NewKeyTable().RegisterParamSet(&Params{}) +} + +// ParamSetPairs implements the ParamSet interface and returns all the key/value pairs +func (p *Params) ParamSetPairs() params.ParamSetPairs { + return params.ParamSetPairs{ + params.NewParamSetPair(KeyAssets, &p.Assets, validateAssetsParam), + } +} + +// Validate checks that the parameters have valid values. +func (p Params) Validate() error { + return validateAssetsParam(p.Assets) +} + +func validateAssetsParam(i interface{}) error { + assets, ok := i.(Assets) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + return assets.Validate() +} + +// String implements fmt.Stringer +func (p Params) String() string { + return fmt.Sprintf(`Params: + Assets: %s + `, p.Assets) +} + +// Asset type for assets in the issuance module +type Asset struct { + Owner sdk.AccAddress `json:"owner" yaml:"owner"` + Denom string `json:"denom" yaml:"denom"` + BlockedAddresses []sdk.AccAddress `json:"blocked_addresses" yaml:"blocked_addresses"` + Paused bool `json:"paused" yaml:"paused"` +} + +// NewAsset returns a new Asset +func NewAsset(owner sdk.AccAddress, denom string, blockedAddresses []sdk.AccAddress, paused bool) Asset { + return Asset{ + Owner: owner, + Denom: denom, + BlockedAddresses: blockedAddresses, + Paused: paused, + } +} + +// Validate performs a basic check of asset fields +func (a Asset) Validate() error { + if a.Owner.Empty() { + return fmt.Errorf("owner must not be empty") + } + for _, address := range a.BlockedAddresses { + if address.Empty() { + return fmt.Errorf("blocked address must not be empty") + } + if a.Owner.Equals(address) { + return fmt.Errorf("asset owner cannot be blocked") + } + } + return sdk.ValidateDenom(a.Denom) +} + +// String implements fmt.Stringer +func (a Asset) String() string { + return fmt.Sprintf(`Asset: + Owner: %s + Paused: %t + Denom: %s + Blocked Addresses: %s`, + a.Owner, a.Paused, a.Denom, a.BlockedAddresses) +} + +// Assets array of Asset +type Assets []Asset + +// Validate checks if all assets are valid and there are no duplicate entries +func (as Assets) Validate() error { + assetDenoms := make(map[string]bool) + for _, a := range as { + if assetDenoms[a.Denom] { + return fmt.Errorf("cannot have duplicate asset denoms: %s", a.Denom) + } + if err := a.Validate(); err != nil { + return err + } + assetDenoms[a.Denom] = true + } + return nil +} + +// String implements fmt.Stringer +func (as Assets) String() string { + out := "" + for _, a := range as { + out += a.String() + } + return out +} diff --git a/x/issuance/types/querier.go b/x/issuance/types/querier.go new file mode 100644 index 00000000..fb22ef75 --- /dev/null +++ b/x/issuance/types/querier.go @@ -0,0 +1,12 @@ +package types + +// Querier routes for the issuance module +const ( + QueryGetParams = "parameters" + QueryGetAsset = "asset" +) + +// QueryAssetParams params for querying an asset by denom +type QueryAssetParams struct { + Denom string `json:"denom" yaml:"denom"` +}