diff --git a/x/committee/simulation/operations.go b/x/committee/simulation/operations.go index f825429f..466dfc82 100644 --- a/x/committee/simulation/operations.go +++ b/x/committee/simulation/operations.go @@ -74,6 +74,9 @@ func SimulateMsgSubmitProposal(cdc *codec.Codec, ak AccountKeeper, k keeper.Keep } // pick a committee that has permissions for proposal pp := types.PubProposal(contentSim(r, ctx, accs)) + if pp == nil { + return simulation.NewOperationMsgBasic(types.ModuleName, "no-operation (conent generation function returned nil)", "", false, nil), nil, nil + } var selectedCommittee types.Committee var found bool for _, c := range committees { diff --git a/x/issuance/abci.go b/x/issuance/abci.go index 88b956d7..126c02c0 100644 --- a/x/issuance/abci.go +++ b/x/issuance/abci.go @@ -7,11 +7,10 @@ import ( // 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) - } + err := k.SeizeCoinsForBlockableAssets(ctx) + if err != nil { + panic(err) } + k.SynchronizeBlockList(ctx) + k.UpdateTimeBasedSupplyLimits(ctx) } diff --git a/x/issuance/abci_test.go b/x/issuance/abci_test.go new file mode 100644 index 00000000..98b4c0a2 --- /dev/null +++ b/x/issuance/abci_test.go @@ -0,0 +1,111 @@ +package issuance_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + sdk "github.com/cosmos/cosmos-sdk/types" + + 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" +) + +// Test suite used for all keeper tests +type ABCITestSuite struct { + suite.Suite + + keeper issuance.Keeper + app app.TestApp + ctx sdk.Context + addrs []sdk.AccAddress + modAccount sdk.AccAddress + blockTime time.Time +} + +// The default state used by each test +func (suite *ABCITestSuite) SetupTest() { + tApp := app.NewTestApp() + blockTime := tmtime.Now() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: blockTime}) + 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 + suite.blockTime = blockTime +} + +func (suite *ABCITestSuite) TestRateLimitingTimePassage() { + type args struct { + assets issuance.Assets + supplies issuance.AssetSupplies + blockTimes []time.Duration + expectedSupply issuance.AssetSupply + } + testCases := []struct { + name string + args args + }{ + { + "time passage same period", + args{ + assets: issuance.Assets{ + issuance.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, issuance.NewRateLimit(true, sdk.NewInt(10000000000), time.Hour*24)), + }, + supplies: issuance.AssetSupplies{ + issuance.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour), + }, + blockTimes: []time.Duration{time.Hour}, + expectedSupply: issuance.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour*2), + }, + }, + { + "time passage new period", + args{ + assets: issuance.Assets{ + issuance.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, issuance.NewRateLimit(true, sdk.NewInt(10000000000), time.Hour*24)), + }, + supplies: issuance.AssetSupplies{ + issuance.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour), + }, + blockTimes: []time.Duration{time.Hour * 12, time.Hour * 12}, + expectedSupply: issuance.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Duration(0)), + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := issuance.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + for _, supply := range tc.args.supplies { + suite.keeper.SetAssetSupply(suite.ctx, supply, supply.GetDenom()) + } + suite.keeper.SetPreviousBlockTime(suite.ctx, suite.blockTime) + for _, bt := range tc.args.blockTimes { + nextBlockTime := suite.ctx.BlockTime().Add(bt) + suite.ctx = suite.ctx.WithBlockTime(nextBlockTime) + suite.Require().NotPanics(func() { + issuance.BeginBlocker(suite.ctx, suite.keeper) + }) + } + actualSupply, found := suite.keeper.GetAssetSupply(suite.ctx, tc.args.expectedSupply.GetDenom()) + suite.Require().True(found) + suite.Require().Equal(tc.args.expectedSupply, actualSupply) + }) + } +} + +func TestABCITestSuite(t *testing.T) { + suite.Run(t, new(ABCITestSuite)) +} diff --git a/x/issuance/alias.go b/x/issuance/alias.go index 84d6f9e6..039d3c21 100644 --- a/x/issuance/alias.go +++ b/x/issuance/alias.go @@ -51,6 +51,8 @@ var ( DefaultParams = types.DefaultParams ParamKeyTable = types.ParamKeyTable NewAsset = types.NewAsset + NewRateLimit = types.NewRateLimit + NewAssetSupply = types.NewAssetSupply // variable aliases ModuleCdc = types.ModuleCdc @@ -61,6 +63,10 @@ var ( ErrAccountAlreadyBlocked = types.ErrAccountAlreadyBlocked ErrAccountAlreadyUnblocked = types.ErrAccountAlreadyUnblocked ErrIssueToModuleAccount = types.ErrIssueToModuleAccount + ErrExceedsSupplyLimit = types.ErrExceedsSupplyLimit + ErrAssetUnblockable = types.ErrAssetUnblockable + AssetSupplyPrefix = types.AssetSupplyPrefix + PreviousBlockTimeKey = types.PreviousBlockTimeKey KeyAssets = types.KeyAssets DefaultAssets = types.DefaultAssets ModuleAccountName = types.ModuleAccountName @@ -77,5 +83,8 @@ type ( Params = types.Params Asset = types.Asset Assets = types.Assets + RateLimit = types.RateLimit QueryAssetParams = types.QueryAssetParams + AssetSupply = types.AssetSupply + AssetSupplies = types.AssetSupplies ) diff --git a/x/issuance/client/cli/tx.go b/x/issuance/client/cli/tx.go index 477df636..007be41e 100644 --- a/x/issuance/client/cli/tx.go +++ b/x/issuance/client/cli/tx.go @@ -160,7 +160,7 @@ 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", + Long: "The asset owner pauses or un-pauses 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), diff --git a/x/issuance/genesis.go b/x/issuance/genesis.go index 6ad9f029..2969c116 100644 --- a/x/issuance/genesis.go +++ b/x/issuance/genesis.go @@ -12,17 +12,27 @@ import ( // 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) + if err := gs.Validate(); err != nil { + panic(fmt.Sprintf("failed to validate %s genesis state: %s", ModuleName, err)) + } // 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)) } + + k.SetParams(ctx, gs.Params) + + for _, supply := range gs.Supplies { + k.SetAssetSupply(ctx, supply, supply.GetDenom()) + } + } // 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) + supplies := k.GetAllAssetSupplies(ctx) + return types.NewGenesisState(params, supplies) } diff --git a/x/issuance/keeper/issuance.go b/x/issuance/keeper/issuance.go index 2406422d..3eed68d7 100644 --- a/x/issuance/keeper/issuance.go +++ b/x/issuance/keeper/issuance.go @@ -21,17 +21,26 @@ func (k Keeper) IssueTokens(ctx sdk.Context, tokens sdk.Coin, owner, receiver sd 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) + if asset.Blockable { + blocked, _ := k.checkBlockedAddress(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) } + // for rate-limited assets, check that the issuance isn't over the limit + if asset.RateLimit.Active { + err := k.IncrementCurrentAssetSupply(ctx, tokens) + if err != nil { + return err + } + } + // mint new tokens err := k.supplyKeeper.MintCoins(ctx, types.ModuleAccountName, sdk.NewCoins(tokens)) if err != nil { @@ -87,13 +96,20 @@ func (k Keeper) BlockAddress(ctx sdk.Context, denom string, owner, blockedAddres if !found { return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", denom) } + if !asset.Blockable { + return sdkerrors.Wrap(types.ErrAssetUnblockable, denom) + } if !owner.Equals(asset.Owner) { return sdkerrors.Wrapf(types.ErrNotAuthorized, "owner: %s, address: %s", asset.Owner, owner) } - blocked, _ := k.checkBlockedAddress(ctx, asset, blockedAddress) + blocked, _ := k.checkBlockedAddress(asset, blockedAddress) if blocked { return sdkerrors.Wrapf(types.ErrAccountAlreadyBlocked, "address: %s", blockedAddress) } + account := k.accountKeeper.GetAccount(ctx, blockedAddress) + if account == nil { + return sdkerrors.Wrapf(types.ErrAccountNotFound, "address: %s", blockedAddress) + } asset.BlockedAddresses = append(asset.BlockedAddresses, blockedAddress) k.SetAsset(ctx, asset) ctx.EventManager().EmitEvent( @@ -112,17 +128,20 @@ func (k Keeper) UnblockAddress(ctx sdk.Context, denom string, owner, addr sdk.Ac if !found { return sdkerrors.Wrapf(types.ErrAssetNotFound, "denom: %s", denom) } + if !asset.Blockable { + return sdkerrors.Wrap(types.ErrAssetUnblockable, 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) + blocked, i := k.checkBlockedAddress(asset, addr) if !blocked { if blocked { return sdkerrors.Wrapf(types.ErrAccountAlreadyUnblocked, "address: %s", addr) } } - blockedAddrs := k.removeBlockedAddress(ctx, asset.BlockedAddresses, i) + blockedAddrs := k.removeBlockedAddress(asset.BlockedAddresses, i) asset.BlockedAddresses = blockedAddrs k.SetAsset(ctx, asset) ctx.EventManager().EmitEvent( @@ -159,6 +178,20 @@ func (k Keeper) SetPauseStatus(ctx sdk.Context, owner sdk.AccAddress, denom stri return nil } +// SeizeCoinsForBlockableAssets seizes coins from blocked addresses for assets that have blocking enabled +func (k Keeper) SeizeCoinsForBlockableAssets(ctx sdk.Context) error { + params := k.GetParams(ctx) + for _, asset := range params.Assets { + if asset.Blockable { + err := k.SeizeCoinsFromBlockedAddresses(ctx, asset.Denom) + if err != nil { + return err + } + } + } + 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) @@ -167,6 +200,11 @@ func (k Keeper) SeizeCoinsFromBlockedAddresses(ctx sdk.Context, denom string) er } for _, address := range asset.BlockedAddresses { account := k.accountKeeper.GetAccount(ctx, address) + if account == nil { + // avoids a potential panic + // this could happen if, for example, an account was pruned from state but remained in the block list, + continue + } coinsAmount := account.GetCoins().AmountOf(denom) if !coinsAmount.IsPositive() { continue @@ -191,7 +229,7 @@ func (k Keeper) SeizeCoinsFromBlockedAddresses(ctx sdk.Context, denom string) er return nil } -func (k Keeper) checkBlockedAddress(ctx sdk.Context, asset types.Asset, checkAddress sdk.AccAddress) (bool, int) { +func (k Keeper) checkBlockedAddress(asset types.Asset, checkAddress sdk.AccAddress) (bool, int) { for i, address := range asset.BlockedAddresses { if address.Equals(checkAddress) { return true, i @@ -200,7 +238,7 @@ func (k Keeper) checkBlockedAddress(ctx sdk.Context, asset types.Asset, checkAdd return false, 0 } -func (k Keeper) removeBlockedAddress(ctx sdk.Context, blockedAddrs []sdk.AccAddress, i int) []sdk.AccAddress { +func (k Keeper) removeBlockedAddress(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 index a3de2abd..4fc18464 100644 --- a/x/issuance/keeper/issuance_test.go +++ b/x/issuance/keeper/issuance_test.go @@ -3,6 +3,7 @@ package keeper_test import ( "strings" "testing" + "time" "github.com/stretchr/testify/suite" @@ -11,6 +12,7 @@ import ( supplyexported "github.com/cosmos/cosmos-sdk/x/supply/exported" abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/crypto" tmtime "github.com/tendermint/tendermint/types/time" "github.com/kava-labs/kava/app" @@ -35,6 +37,10 @@ func (suite *KeeperTestSuite) SetupTest() { ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) tApp.InitializeFromGenesisStates() _, addrs := app.GeneratePrivKeyAddressPairs(5) + for _, addr := range addrs { + acc := tApp.GetAccountKeeper().NewAccountWithAddress(ctx, addr) + tApp.GetAccountKeeper().SetAccount(ctx, acc) + } keeper := tApp.GetIssuanceKeeper() modAccount, err := sdk.AccAddressFromBech32("kava1cj7njkw2g9fqx4e768zc75dp9sks8u9znxrf0w") suite.Require().NoError(err) @@ -58,7 +64,7 @@ func (suite *KeeperTestSuite) getModuleAccount(name string) supplyexported.Modul 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) + asset := types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))) params = types.NewParams(types.Assets{asset}) suite.keeper.SetParams(suite.ctx, params) newParams := suite.keeper.GetParams(suite.ctx) @@ -85,7 +91,7 @@ func (suite *KeeperTestSuite) TestIssueTokens() { "valid issuance", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -100,7 +106,7 @@ func (suite *KeeperTestSuite) TestIssueTokens() { "non-owner issuance", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[2], tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -115,7 +121,7 @@ func (suite *KeeperTestSuite) TestIssueTokens() { "invalid denom", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], tokens: sdk.NewCoin("othertoken", sdk.NewInt(100000)), @@ -130,7 +136,7 @@ func (suite *KeeperTestSuite) TestIssueTokens() { "issue to blocked address", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -145,7 +151,7 @@ func (suite *KeeperTestSuite) TestIssueTokens() { "issue to module account", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -160,7 +166,7 @@ func (suite *KeeperTestSuite) TestIssueTokens() { "paused issuance", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, true), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, true, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -190,6 +196,85 @@ func (suite *KeeperTestSuite) TestIssueTokens() { } } +func (suite *KeeperTestSuite) TestIssueTokensRateLimited() { + type args struct { + assets types.Assets + supplies types.AssetSupplies + sender sdk.AccAddress + tokens sdk.Coin + receiver sdk.AccAddress + blockTime time.Time + } + 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, true, types.NewRateLimit(true, sdk.NewInt(10000000000), time.Hour*24)), + }, + supplies: types.AssetSupplies{ + types.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour), + }, + sender: suite.addrs[0], + tokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + receiver: suite.addrs[2], + blockTime: suite.ctx.BlockTime().Add(time.Hour), + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "over-limit issuance", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(true, sdk.NewInt(10000000000), time.Hour*24)), + }, + supplies: types.AssetSupplies{ + types.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour), + }, + sender: suite.addrs[0], + tokens: sdk.NewCoin("usdtoken", sdk.NewInt(10000000001)), + receiver: suite.addrs[2], + blockTime: suite.ctx.BlockTime().Add(time.Hour), + }, + errArgs{ + expectPass: false, + contains: "asset supply over limit", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := types.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + for _, supply := range tc.args.supplies { + suite.keeper.SetAssetSupply(suite.ctx, supply, supply.GetDenom()) + } + suite.ctx = suite.ctx.WithBlockTime(tc.args.blockTime) + 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 @@ -210,7 +295,7 @@ func (suite *KeeperTestSuite) TestRedeemTokens() { "valid redemption", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -225,7 +310,7 @@ func (suite *KeeperTestSuite) TestRedeemTokens() { "invalid denom redemption", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -240,7 +325,7 @@ func (suite *KeeperTestSuite) TestRedeemTokens() { "non-owner redemption", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[2], initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -255,7 +340,7 @@ func (suite *KeeperTestSuite) TestRedeemTokens() { "paused redemption", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, true), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, true, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -270,7 +355,7 @@ func (suite *KeeperTestSuite) TestRedeemTokens() { "redeem amount greater than balance", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], initialTokens: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), @@ -328,7 +413,7 @@ func (suite *KeeperTestSuite) TestBlockAddress() { "valid block", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], blockedAddr: suite.addrs[1], @@ -339,11 +424,26 @@ func (suite *KeeperTestSuite) TestBlockAddress() { contains: "", }, }, + { + "unblockable token", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, false, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), + }, + sender: suite.addrs[0], + blockedAddr: suite.addrs[1], + denom: "usdtoken", + }, + errArgs{ + expectPass: false, + contains: "asset does not support block/unblock functionality", + }, + }, { "non-owner block", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[2], blockedAddr: suite.addrs[1], @@ -358,7 +458,7 @@ func (suite *KeeperTestSuite) TestBlockAddress() { "invalid denom block", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], blockedAddr: suite.addrs[1], @@ -369,6 +469,21 @@ func (suite *KeeperTestSuite) TestBlockAddress() { contains: "no asset with input denom found", }, }, + { + "block non-existing account", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), + }, + sender: suite.addrs[0], + blockedAddr: sdk.AccAddress(crypto.AddressHash([]byte("RandomAddr"))), + denom: "usdtoken", + }, + errArgs{ + expectPass: false, + contains: "cannot block account that does not exist in state", + }, + }, } for _, tc := range testCases { suite.Run(tc.name, func() { @@ -416,7 +531,7 @@ func (suite *KeeperTestSuite) TestUnblockAddress() { "valid unblock", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], blockedAddr: suite.addrs[1], @@ -431,7 +546,7 @@ func (suite *KeeperTestSuite) TestUnblockAddress() { "non-owner unblock", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[2], blockedAddr: suite.addrs[1], @@ -446,7 +561,7 @@ func (suite *KeeperTestSuite) TestUnblockAddress() { "invalid denom block", 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], blockedAddr: suite.addrs[1], @@ -505,7 +620,7 @@ func (suite *KeeperTestSuite) TestChangePauseStatus() { "valid pause", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], startStatus: false, @@ -521,7 +636,7 @@ func (suite *KeeperTestSuite) TestChangePauseStatus() { "valid unpause", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, true), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, true, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], startStatus: true, @@ -537,7 +652,7 @@ func (suite *KeeperTestSuite) TestChangePauseStatus() { "non-owner pause", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[2], startStatus: false, @@ -553,7 +668,7 @@ func (suite *KeeperTestSuite) TestChangePauseStatus() { "invalid denom pause", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, sender: suite.addrs[0], startStatus: true, @@ -606,7 +721,7 @@ func (suite *KeeperTestSuite) TestSeizeCoinsFromBlockedAddress() { "valid seize", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, initialCoins: sdk.NewCoin("usdtoken", sdk.NewInt(100000000)), denom: "usdtoken", @@ -621,7 +736,7 @@ func (suite *KeeperTestSuite) TestSeizeCoinsFromBlockedAddress() { "invalid denom seize", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, initialCoins: sdk.NewCoin("usdtoken", sdk.NewInt(100000000)), denom: "othertoken", diff --git a/x/issuance/keeper/keeper.go b/x/issuance/keeper/keeper.go index ce40fd38..03fc85aa 100644 --- a/x/issuance/keeper/keeper.go +++ b/x/issuance/keeper/keeper.go @@ -1,7 +1,10 @@ package keeper import ( + "time" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/x/params/subspace" @@ -31,3 +34,62 @@ func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace, supplyKeeper: sk, } } + +// GetAssetSupply gets an asset's current supply from the store. +func (k Keeper) GetAssetSupply(ctx sdk.Context, denom string) (types.AssetSupply, bool) { + var assetSupply types.AssetSupply + store := prefix.NewStore(ctx.KVStore(k.key), types.AssetSupplyPrefix) + bz := store.Get([]byte(denom)) + if bz == nil { + return types.AssetSupply{}, false + } + k.cdc.MustUnmarshalBinaryBare(bz, &assetSupply) + return assetSupply, true +} + +// SetAssetSupply updates an asset's supply +func (k Keeper) SetAssetSupply(ctx sdk.Context, supply types.AssetSupply, denom string) { + store := prefix.NewStore(ctx.KVStore(k.key), types.AssetSupplyPrefix) + store.Set([]byte(denom), k.cdc.MustMarshalBinaryBare(supply)) +} + +// IterateAssetSupplies provides an iterator over all stored AssetSupplies. +func (k Keeper) IterateAssetSupplies(ctx sdk.Context, cb func(supply types.AssetSupply) (stop bool)) { + iterator := sdk.KVStorePrefixIterator(ctx.KVStore(k.key), types.AssetSupplyPrefix) + + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var supply types.AssetSupply + k.cdc.MustUnmarshalBinaryBare(iterator.Value(), &supply) + + if cb(supply) { + break + } + } +} + +// GetAllAssetSupplies returns all asset supplies from the store +func (k Keeper) GetAllAssetSupplies(ctx sdk.Context) (supplies types.AssetSupplies) { + k.IterateAssetSupplies(ctx, func(supply types.AssetSupply) bool { + supplies = append(supplies, supply) + return false + }) + return +} + +// GetPreviousBlockTime get the blocktime for the previous block +func (k Keeper) GetPreviousBlockTime(ctx sdk.Context) (blockTime time.Time, found bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PreviousBlockTimeKey) + b := store.Get([]byte{}) + if b == nil { + return time.Time{}, false + } + k.cdc.MustUnmarshalBinaryLengthPrefixed(b, &blockTime) + return blockTime, true +} + +// SetPreviousBlockTime set the time of the previous block +func (k Keeper) SetPreviousBlockTime(ctx sdk.Context, blockTime time.Time) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PreviousBlockTimeKey) + store.Set([]byte{}, k.cdc.MustMarshalBinaryLengthPrefixed(blockTime)) +} diff --git a/x/issuance/keeper/params.go b/x/issuance/keeper/params.go index a6b13c42..92d5da30 100644 --- a/x/issuance/keeper/params.go +++ b/x/issuance/keeper/params.go @@ -2,6 +2,7 @@ package keeper import ( sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/kava-labs/kava/x/issuance/types" ) @@ -39,3 +40,23 @@ func (k Keeper) SetAsset(ctx sdk.Context, asset types.Asset) { } k.SetParams(ctx, params) } + +// GetRateLimit returns the rete-limit parameters for the input denom +func (k Keeper) GetRateLimit(ctx sdk.Context, denom string) (types.RateLimit, error) { + asset, found := k.GetAsset(ctx, denom) + if !found { + sdkerrors.Wrap(types.ErrAssetNotFound, denom) + } + return asset.RateLimit, nil +} + +// SynchronizeBlockList resets the block list to empty for any asset that is not blockable - could happen if this value is changed via governance +func (k Keeper) SynchronizeBlockList(ctx sdk.Context) { + params := k.GetParams(ctx) + for _, asset := range params.Assets { + if !asset.Blockable && len(asset.BlockedAddresses) > 0 { + asset.BlockedAddresses = []sdk.AccAddress{} + k.SetAsset(ctx, asset) + } + } +} diff --git a/x/issuance/keeper/supply.go b/x/issuance/keeper/supply.go new file mode 100644 index 00000000..89e6bbc3 --- /dev/null +++ b/x/issuance/keeper/supply.go @@ -0,0 +1,73 @@ +package keeper + +import ( + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + + "github.com/kava-labs/kava/x/issuance/types" +) + +// CreateNewAssetSupply creates a new AssetSupply in the store for the input denom +func (k Keeper) CreateNewAssetSupply(ctx sdk.Context, denom string) types.AssetSupply { + supply := types.NewAssetSupply( + sdk.NewCoin(denom, sdk.ZeroInt()), time.Duration(0)) + k.SetAssetSupply(ctx, supply, denom) + return supply +} + +// IncrementCurrentAssetSupply increments an asset's supply by the coin +func (k Keeper) IncrementCurrentAssetSupply(ctx sdk.Context, coin sdk.Coin) error { + supply, found := k.GetAssetSupply(ctx, coin.Denom) + if !found { + return sdkerrors.Wrap(types.ErrAssetNotFound, coin.Denom) + } + + limit, err := k.GetRateLimit(ctx, coin.Denom) + if err != nil { + return err + } + + if limit.Active { + supplyLimit := sdk.NewCoin(coin.Denom, limit.Limit) + // Resulting current supply must be under asset's limit + if supplyLimit.IsLT(supply.CurrentSupply.Add(coin)) { + return sdkerrors.Wrapf(types.ErrExceedsSupplyLimit, "increase %s, asset supply %s, limit %s", coin, supply.CurrentSupply, supplyLimit) + } + supply.CurrentSupply = supply.CurrentSupply.Add(coin) + k.SetAssetSupply(ctx, supply, coin.Denom) + } + return nil +} + +// UpdateTimeBasedSupplyLimits updates the time based supply for each asset, resetting it if the current time window has elapsed. +func (k Keeper) UpdateTimeBasedSupplyLimits(ctx sdk.Context) { + params := k.GetParams(ctx) + previousBlockTime, found := k.GetPreviousBlockTime(ctx) + if !found { + previousBlockTime = ctx.BlockTime() + k.SetPreviousBlockTime(ctx, previousBlockTime) + } + timeElapsed := ctx.BlockTime().Sub(previousBlockTime) + for _, asset := range params.Assets { + supply, found := k.GetAssetSupply(ctx, asset.Denom) + // if a new asset has been added by governance, create a new asset supply for it in the store + if !found { + supply = k.CreateNewAssetSupply(ctx, asset.Denom) + } + if asset.RateLimit.Active { + if asset.RateLimit.TimePeriod <= supply.TimeElapsed+timeElapsed { + supply.TimeElapsed = time.Duration(0) + supply.CurrentSupply = sdk.NewCoin(asset.Denom, sdk.ZeroInt()) + } else { + supply.TimeElapsed = supply.TimeElapsed + timeElapsed + } + } else { + supply.CurrentSupply = sdk.NewCoin(asset.Denom, sdk.ZeroInt()) + supply.TimeElapsed = time.Duration(0) + } + k.SetAssetSupply(ctx, supply, asset.Denom) + } + k.SetPreviousBlockTime(ctx, ctx.BlockTime()) +} diff --git a/x/issuance/keeper/supply_test.go b/x/issuance/keeper/supply_test.go new file mode 100644 index 00000000..5d27f64d --- /dev/null +++ b/x/issuance/keeper/supply_test.go @@ -0,0 +1,83 @@ +package keeper_test + +import ( + "strings" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/issuance/types" +) + +func (suite *KeeperTestSuite) TestIncrementCurrentAssetSupply() { + type args struct { + assets types.Assets + supplies types.AssetSupplies + coin sdk.Coin + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "valid supply increase", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(true, sdk.NewInt(10000000000), time.Hour*24)), + }, + supplies: types.AssetSupplies{ + types.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour), + }, + coin: sdk.NewCoin("usdtoken", sdk.NewInt(100000)), + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "over limit increase", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(true, sdk.NewInt(10000000000), time.Hour*24)), + }, + supplies: types.AssetSupplies{ + types.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour), + }, + coin: sdk.NewCoin("usdtoken", sdk.NewInt(10000000001)), + }, + errArgs{ + expectPass: false, + contains: "asset supply over limit", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + params := types.NewParams(tc.args.assets) + suite.keeper.SetParams(suite.ctx, params) + for _, supply := range tc.args.supplies { + suite.keeper.SetAssetSupply(suite.ctx, supply, supply.GetDenom()) + } + err := suite.keeper.IncrementCurrentAssetSupply(suite.ctx, tc.args.coin) + if tc.errArgs.expectPass { + suite.Require().NoError(err, tc.name) + for _, expectedSupply := range tc.args.supplies { + expectedSupply.CurrentSupply = expectedSupply.CurrentSupply.Add(tc.args.coin) + actualSupply, found := suite.keeper.GetAssetSupply(suite.ctx, expectedSupply.GetDenom()) + suite.Require().True(found) + suite.Require().Equal(expectedSupply, actualSupply, tc.name) + } + } else { + suite.Require().Error(err, tc.name) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} diff --git a/x/issuance/simulation/decoder.go b/x/issuance/simulation/decoder.go index baa943de..b2ef591c 100644 --- a/x/issuance/simulation/decoder.go +++ b/x/issuance/simulation/decoder.go @@ -1,7 +1,9 @@ package simulation import ( + "bytes" "fmt" + "time" "github.com/cosmos/cosmos-sdk/codec" @@ -12,5 +14,20 @@ import ( // 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])) + switch { + case bytes.Equal(kvA.Key[:1], types.AssetSupplyPrefix): + var supplyA, supplyB types.AssetSupply + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &supplyA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &supplyB) + return fmt.Sprintf("%s\n%s", supplyA, supplyB) + case bytes.Equal(kvA.Key[:1], types.PreviousBlockTimeKey): + var timeA, timeB time.Time + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &timeA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &timeB) + return fmt.Sprintf("%s\n%s", timeA, timeB) + default: + panic(fmt.Sprintf("invalid %s key prefix %X", types.ModuleName, kvA.Key[:1])) + + } + } diff --git a/x/issuance/simulation/decoder_test.go b/x/issuance/simulation/decoder_test.go new file mode 100644 index 00000000..e99a3560 --- /dev/null +++ b/x/issuance/simulation/decoder_test.go @@ -0,0 +1,54 @@ +package simulation + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/tendermint/tendermint/libs/kv" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/issuance/types" +) + +func makeTestCodec() (cdc *codec.Codec) { + cdc = codec.New() + sdk.RegisterCodec(cdc) + types.RegisterCodec(cdc) + return +} + +func TestDecodeIssuanceStore(t *testing.T) { + cdc := makeTestCodec() + supply := types.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour) + prevBlockTime := time.Now().UTC() + + kvPairs := kv.Pairs{ + kv.Pair{Key: types.AssetSupplyPrefix, Value: cdc.MustMarshalBinaryLengthPrefixed(supply)}, + kv.Pair{Key: []byte(types.PreviousBlockTimeKey), Value: cdc.MustMarshalBinaryLengthPrefixed(prevBlockTime)}, + } + + tests := []struct { + name string + expectedLog string + }{ + {"AssetSupply", fmt.Sprintf("%v\n%v", supply, supply)}, + {"PreviousBlockTime", fmt.Sprintf("%s\n%s", prevBlockTime, prevBlockTime)}, + {"other", ""}, + } + for i, tt := range tests { + i, tt := i, tt + t.Run(tt.name, func(t *testing.T) { + switch i { + case len(tests) - 1: + require.Panics(t, func() { DecodeStore(cdc, kvPairs[i], kvPairs[i]) }, tt.name) + default: + require.Equal(t, tt.expectedLog, DecodeStore(cdc, kvPairs[i], kvPairs[i]), tt.name) + } + }) + } +} diff --git a/x/issuance/simulation/genesis.go b/x/issuance/simulation/genesis.go index a0e746cb..9f638be0 100644 --- a/x/issuance/simulation/genesis.go +++ b/x/issuance/simulation/genesis.go @@ -4,6 +4,7 @@ import ( "fmt" "math/rand" "strings" + "time" "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" @@ -21,7 +22,7 @@ var ( func RandomizedGenState(simState *module.SimulationState) { accs = simState.Accounts params := randomizedParams(simState.Rand) - gs := types.NewGenesisState(params) + gs := types.NewGenesisState(params, types.AssetSupplies{}) 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) } @@ -38,7 +39,14 @@ func randomizedAssets(r *rand.Rand) types.Assets { 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) + rateLimited := r.Intn(2) == 0 + rateLimit := types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0)) + if rateLimited { + timeLimit := time.Duration(3600000000000 + (r.Intn(24) + 1)) + assetLimit := simulation.RandIntBetween(r, 100000000000, 1000000000000) + rateLimit = types.NewRateLimit(true, sdk.NewInt(int64(assetLimit)), timeLimit) + } + randomAsset := types.NewAsset(owner.Address, denom, []sdk.AccAddress{}, paused, true, rateLimit) randomAssets = append(randomAssets, randomAsset) } return randomAssets diff --git a/x/issuance/simulation/operations.go b/x/issuance/simulation/operations.go index 532ea29e..9cf6bb89 100644 --- a/x/issuance/simulation/operations.go +++ b/x/issuance/simulation/operations.go @@ -116,6 +116,21 @@ func SimulateMsgIssueTokens(ak types.AccountKeeper, k keeper.Keeper) simulation. } } randomAmount := simulation.RandIntBetween(r, 10000000, 1000000000000) + if asset.RateLimit.Active { + supply, found := k.GetAssetSupply(ctx, asset.Denom) + if !found { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("issuance - no asset supply for %s", asset.Denom) + } + if asset.RateLimit.Limit.LT(supply.CurrentSupply.Amount.Add(sdk.NewInt(int64(randomAmount)))) { + maxAmount := asset.RateLimit.Limit.Sub(supply.CurrentSupply.Amount) + if maxAmount.IsPositive() && maxAmount.GT(sdk.OneInt()) { + randomAmount = simulation.RandIntBetween(r, 1, int(maxAmount.Int64())) + } else { + randomAmount = 1 + } + } + + } 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) diff --git a/x/issuance/types/errors.go b/x/issuance/types/errors.go index 8b0a01b9..28407ba3 100644 --- a/x/issuance/types/errors.go +++ b/x/issuance/types/errors.go @@ -15,4 +15,7 @@ var ( 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") + ErrExceedsSupplyLimit = sdkerrors.Register(ModuleName, 9, "asset supply over limit") + ErrAssetUnblockable = sdkerrors.Register(ModuleName, 10, "asset does not support block/unblock functionality") + ErrAccountNotFound = sdkerrors.Register(ModuleName, 11, "cannot block account that does not exist in state") ) diff --git a/x/issuance/types/genesis.go b/x/issuance/types/genesis.go index 8f651326..7e69a6a6 100644 --- a/x/issuance/types/genesis.go +++ b/x/issuance/types/genesis.go @@ -4,26 +4,35 @@ 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"` + Params Params `json:"params" yaml:"params"` + Supplies AssetSupplies `json:"supplies" yaml:"supplies"` } // NewGenesisState returns a new GenesisState -func NewGenesisState(params Params) GenesisState { +func NewGenesisState(params Params, supplies AssetSupplies) GenesisState { return GenesisState{ - Params: params, + Params: params, + Supplies: supplies, } } // DefaultGenesisState returns the default GenesisState for the issuance module func DefaultGenesisState() GenesisState { return GenesisState{ - Params: DefaultParams(), + Params: DefaultParams(), + Supplies: AssetSupplies{}, } } // Validate performs basic validation of genesis data returning an // error for any failed validation criteria. func (gs GenesisState) Validate() error { + for _, supply := range gs.Supplies { + err := supply.Validate() + if err != nil { + return err + } + } return gs.Params.Validate() } diff --git a/x/issuance/types/genesis_test.go b/x/issuance/types/genesis_test.go index 7ebb46f5..cd61580f 100644 --- a/x/issuance/types/genesis_test.go +++ b/x/issuance/types/genesis_test.go @@ -3,6 +3,7 @@ package types_test import ( "strings" "testing" + "time" "github.com/stretchr/testify/suite" @@ -28,7 +29,8 @@ func (suite *GenesisTestSuite) SetupTest() { func (suite *GenesisTestSuite) TestValidate() { type args struct { - assets types.Assets + assets types.Assets + supplies types.AssetSupplies } type errArgs struct { expectPass bool @@ -42,7 +44,8 @@ func (suite *GenesisTestSuite) TestValidate() { { "default", args{ - assets: types.DefaultAssets, + assets: types.DefaultAssets, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: true, @@ -53,8 +56,36 @@ func (suite *GenesisTestSuite) TestValidate() { "with 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, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, + supplies: types.AssetSupplies{types.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.NewInt(1000000)), time.Hour)}, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "with asset rate limit", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(true, sdk.NewInt(1000000000), time.Hour*24)), + }, + supplies: types.AssetSupplies{}, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "with multiple assets", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), + types.NewAsset(suite.addrs[0], "pegtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), + }, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: true, @@ -65,8 +96,9 @@ func (suite *GenesisTestSuite) TestValidate() { "blocked owner", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[0]}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[0]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: false, @@ -77,8 +109,9 @@ func (suite *GenesisTestSuite) TestValidate() { "empty owner", args{ assets: types.Assets{ - types.NewAsset(sdk.AccAddress{}, "usdtoken", []sdk.AccAddress{suite.addrs[0]}, false), + types.NewAsset(sdk.AccAddress{}, "usdtoken", []sdk.AccAddress{suite.addrs[0]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: false, @@ -89,8 +122,9 @@ func (suite *GenesisTestSuite) TestValidate() { "empty blocked address", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{sdk.AccAddress{}}, false), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{nil}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: false, @@ -101,8 +135,9 @@ func (suite *GenesisTestSuite) TestValidate() { "invalid denom", args{ assets: types.Assets{ - types.NewAsset(suite.addrs[0], "USD2T ", []sdk.AccAddress{}, false), + types.NewAsset(suite.addrs[0], "USD2T ", []sdk.AccAddress{}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: false, @@ -113,9 +148,10 @@ func (suite *GenesisTestSuite) TestValidate() { "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), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), + types.NewAsset(suite.addrs[1], "usdtoken", []sdk.AccAddress{}, true, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: false, @@ -126,19 +162,33 @@ func (suite *GenesisTestSuite) TestValidate() { "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), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, true, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), }, + supplies: types.AssetSupplies{}, }, errArgs{ expectPass: false, contains: "duplicate asset denoms", }, }, + { + "invalid block list", + args{ + assets: types.Assets{ + types.NewAsset(suite.addrs[0], "usdtoken", []sdk.AccAddress{suite.addrs[1]}, false, false, types.NewRateLimit(false, sdk.ZeroInt(), time.Duration(0))), + }, + supplies: types.AssetSupplies{types.NewAssetSupply(sdk.NewCoin("usdtoken", sdk.ZeroInt()), time.Hour)}, + }, + errArgs{ + expectPass: false, + contains: "blocked-list should be empty", + }, + }, } for _, tc := range testCases { suite.Run(tc.name, func() { - gs := types.NewGenesisState(types.NewParams(tc.args.assets)) + gs := types.NewGenesisState(types.NewParams(tc.args.assets), tc.args.supplies) err := gs.Validate() if tc.errArgs.expectPass { suite.Require().NoError(err, tc.name) diff --git a/x/issuance/types/keys.go b/x/issuance/types/keys.go index c58ca5d7..0da2b917 100644 --- a/x/issuance/types/keys.go +++ b/x/issuance/types/keys.go @@ -16,3 +16,9 @@ const ( // QuerierRoute route used for abci queries QuerierRoute = ModuleName ) + +// KVStore key prefixes +var ( + AssetSupplyPrefix = []byte{0x01} + PreviousBlockTimeKey = []byte{0x02} +) diff --git a/x/issuance/types/msg_test.go b/x/issuance/types/msg_test.go new file mode 100644 index 00000000..a6010964 --- /dev/null +++ b/x/issuance/types/msg_test.go @@ -0,0 +1,393 @@ +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 MsgTestSuite struct { + suite.Suite + + addrs []sdk.AccAddress +} + +func (suite *MsgTestSuite) SetupTest() { + config := sdk.GetConfig() + app.SetBech32AddressPrefixes(config) + + _, addrs := app.GeneratePrivKeyAddressPairs(2) + suite.addrs = addrs +} + +func (suite *MsgTestSuite) TestMsgIssueTokens() { + type args struct { + sender sdk.AccAddress + tokens sdk.Coin + receiver sdk.AccAddress + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "default", + args{ + sender: suite.addrs[0], + tokens: sdk.NewCoin("valid", sdk.NewInt(100)), + receiver: suite.addrs[1], + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid sender", + args{ + sender: sdk.AccAddress{}, + tokens: sdk.NewCoin("valid", sdk.NewInt(100)), + receiver: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "sender address cannot be empty", + }, + }, + { + "invalid receiver", + args{ + sender: suite.addrs[0], + tokens: sdk.NewCoin("valid", sdk.NewInt(100)), + receiver: sdk.AccAddress{}, + }, + errArgs{ + expectPass: false, + contains: "receiver address cannot be empty", + }, + }, + { + "invalid tokens", + args{ + sender: suite.addrs[0], + tokens: sdk.Coin{Denom: "Invalid", Amount: sdk.NewInt(100)}, + receiver: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "invalid tokens", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + testMsg := types.NewMsgIssueTokens(tc.args.sender, tc.args.tokens, tc.args.receiver) + err := testMsg.ValidateBasic() + if tc.errArgs.expectPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *MsgTestSuite) TestMsgRedeemTokens() { + type args struct { + sender sdk.AccAddress + tokens sdk.Coin + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "default", + args{ + sender: suite.addrs[0], + tokens: sdk.NewCoin("valid", sdk.NewInt(100)), + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid sender", + args{ + sender: sdk.AccAddress{}, + tokens: sdk.NewCoin("valid", sdk.NewInt(100)), + }, + errArgs{ + expectPass: false, + contains: "sender address cannot be empty", + }, + }, + { + "invalid tokens", + args{ + sender: suite.addrs[0], + tokens: sdk.Coin{Denom: "Invalid", Amount: sdk.NewInt(100)}, + }, + errArgs{ + expectPass: false, + contains: "invalid tokens", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + testMsg := types.NewMsgRedeemTokens(tc.args.sender, tc.args.tokens) + err := testMsg.ValidateBasic() + if tc.errArgs.expectPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *MsgTestSuite) TestMsgBlockAddress() { + type args struct { + sender sdk.AccAddress + denom string + address sdk.AccAddress + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "default", + args{ + sender: suite.addrs[0], + denom: "valid", + address: suite.addrs[1], + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid sender", + args{ + sender: sdk.AccAddress{}, + denom: "valid", + address: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "sender address cannot be empty", + }, + }, + { + "invalid blocked", + args{ + sender: suite.addrs[0], + denom: "valid", + address: sdk.AccAddress{}, + }, + errArgs{ + expectPass: false, + contains: "blocked address cannot be empty", + }, + }, + { + "invalid denom", + args{ + sender: suite.addrs[0], + denom: "Invalid", + address: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "invalid denom", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + testMsg := types.NewMsgBlockAddress(tc.args.sender, tc.args.denom, tc.args.address) + err := testMsg.ValidateBasic() + if tc.errArgs.expectPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *MsgTestSuite) TestMsgUnblockAddress() { + type args struct { + sender sdk.AccAddress + denom string + address sdk.AccAddress + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "default", + args{ + sender: suite.addrs[0], + denom: "valid", + address: suite.addrs[1], + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid sender", + args{ + sender: sdk.AccAddress{}, + denom: "valid", + address: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "sender address cannot be empty", + }, + }, + { + "invalid blocked", + args{ + sender: suite.addrs[0], + denom: "valid", + address: sdk.AccAddress{}, + }, + errArgs{ + expectPass: false, + contains: "blocked address cannot be empty", + }, + }, + { + "invalid denom", + args{ + sender: suite.addrs[0], + denom: "Invalid", + address: suite.addrs[1], + }, + errArgs{ + expectPass: false, + contains: "invalid denom", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + testMsg := types.NewMsgUnblockAddress(tc.args.sender, tc.args.denom, tc.args.address) + err := testMsg.ValidateBasic() + if tc.errArgs.expectPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func (suite *MsgTestSuite) TestMsgSetPauseStatus() { + type args struct { + sender sdk.AccAddress + denom string + status bool + } + type errArgs struct { + expectPass bool + contains string + } + testCases := []struct { + name string + args args + errArgs errArgs + }{ + { + "default", + args{ + sender: suite.addrs[0], + denom: "valid", + status: true, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid sender", + args{ + sender: sdk.AccAddress{}, + denom: "valid", + status: true, + }, + errArgs{ + expectPass: false, + contains: "sender address cannot be empty", + }, + }, + { + "invalid denom", + args{ + sender: suite.addrs[0], + denom: "Invalid", + status: true, + }, + errArgs{ + expectPass: false, + contains: "invalid denom", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + testMsg := types.NewMsgSetPauseStatus(tc.args.sender, tc.args.denom, tc.args.status) + err := testMsg.ValidateBasic() + if tc.errArgs.expectPass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + suite.Require().True(strings.Contains(err.Error(), tc.errArgs.contains)) + } + }) + } +} + +func TestMsgTestSuite(t *testing.T) { + suite.Run(t, new(MsgTestSuite)) +} diff --git a/x/issuance/types/params.go b/x/issuance/types/params.go index 3d1a0c72..e14e7bef 100644 --- a/x/issuance/types/params.go +++ b/x/issuance/types/params.go @@ -2,6 +2,7 @@ package types import ( "fmt" + "time" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/x/params" @@ -67,15 +68,19 @@ type Asset struct { Denom string `json:"denom" yaml:"denom"` BlockedAddresses []sdk.AccAddress `json:"blocked_addresses" yaml:"blocked_addresses"` Paused bool `json:"paused" yaml:"paused"` + Blockable bool `json:"blockable" yaml:"blockable"` + RateLimit RateLimit `json:"rate_limit" yaml:"rate_limit"` } // NewAsset returns a new Asset -func NewAsset(owner sdk.AccAddress, denom string, blockedAddresses []sdk.AccAddress, paused bool) Asset { +func NewAsset(owner sdk.AccAddress, denom string, blockedAddresses []sdk.AccAddress, paused bool, blockable bool, limit RateLimit) Asset { return Asset{ Owner: owner, Denom: denom, BlockedAddresses: blockedAddresses, Paused: paused, + Blockable: blockable, + RateLimit: limit, } } @@ -84,6 +89,9 @@ func (a Asset) Validate() error { if a.Owner.Empty() { return fmt.Errorf("owner must not be empty") } + if !a.Blockable && len(a.BlockedAddresses) > 0 { + return fmt.Errorf("asset %s does not support blocking, blocked-list should be empty: %s", a.Denom, a.BlockedAddresses) + } for _, address := range a.BlockedAddresses { if address.Empty() { return fmt.Errorf("blocked address must not be empty") @@ -101,8 +109,9 @@ func (a Asset) String() string { Owner: %s Paused: %t Denom: %s - Blocked Addresses: %s`, - a.Owner, a.Paused, a.Denom, a.BlockedAddresses) + Blocked Addresses: %s + Rate limits: %s`, + a.Owner, a.Paused, a.Denom, a.BlockedAddresses, a.RateLimit) } // Assets array of Asset @@ -131,3 +140,28 @@ func (as Assets) String() string { } return out } + +// RateLimit parameters for rate-limiting the supply of an issued asset +type RateLimit struct { + Active bool `json:"active" yaml:"active"` + Limit sdk.Int `json:"limit" yaml:"limit"` + TimePeriod time.Duration `json:"time_period" yaml:"time_period"` +} + +// NewRateLimit initializes a new RateLimit +func NewRateLimit(active bool, limit sdk.Int, timePeriod time.Duration) RateLimit { + return RateLimit{ + Active: active, + Limit: limit, + TimePeriod: timePeriod, + } +} + +// String implements fmt.Stringer +func (r RateLimit) String() string { + return fmt.Sprintf(` + Active: %t + Limit: %s + Time Period: %s`, + r.Active, r.Limit, r.TimePeriod) +} diff --git a/x/issuance/types/supply.go b/x/issuance/types/supply.go new file mode 100644 index 00000000..9aa1ba39 --- /dev/null +++ b/x/issuance/types/supply.go @@ -0,0 +1,51 @@ +package types + +import ( + "fmt" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// AssetSupply contains information about an asset's rate-limited supply (the total supply of the asset is tracked in the top-level supply module) +type AssetSupply struct { + CurrentSupply sdk.Coin `json:"current_supply" yaml:"current_supply"` + TimeElapsed time.Duration `json:"time_elapsed" yaml:"time_elapsed"` +} + +// NewAssetSupply initializes a new AssetSupply +func NewAssetSupply(currentSupply sdk.Coin, timeElapsed time.Duration) AssetSupply { + return AssetSupply{ + CurrentSupply: currentSupply, + TimeElapsed: timeElapsed, + } +} + +// Validate performs a basic validation of an asset supply fields. +func (a AssetSupply) Validate() error { + if !a.CurrentSupply.IsValid() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "outgoing supply %s", a.CurrentSupply) + } + return nil +} + +// String implements stringer +func (a AssetSupply) String() string { + return fmt.Sprintf(` + asset supply: + Current supply: %s + Time elapsed: %s + `, + a.CurrentSupply, a.TimeElapsed) +} + +// GetDenom getter method for the denom of the asset supply +func (a AssetSupply) GetDenom() string { + return a.CurrentSupply.Denom +} + +// AssetSupplies is a slice of AssetSupply +type AssetSupplies []AssetSupply + +// TODO copy over supply tests from bep3