From 110adcab2c53d67f7dfa21bcfa9200f24be1fda7 Mon Sep 17 00:00:00 2001 From: drklee3 Date: Fri, 24 May 2024 12:03:09 -0700 Subject: [PATCH] feat(x/precisebank): Implement MintCoins (#1920) Implement MintCoins method that matches x/bank MintCoins validation behavior --- Makefile | 12 +- x/precisebank/keeper/fractional_balance.go | 6 +- .../keeper/fractional_balance_test.go | 35 +- x/precisebank/keeper/keeper.go | 5 +- x/precisebank/keeper/keeper_test.go | 5 + x/precisebank/keeper/mint.go | 138 +++++++ x/precisebank/keeper/mint_integration_test.go | 359 ++++++++++++++++++ x/precisebank/keeper/mint_test.go | 347 +++++++++++++++++ x/precisebank/keeper/view.go | 5 +- x/precisebank/testutil/fractional_balances.go | 2 +- 10 files changed, 885 insertions(+), 29 deletions(-) create mode 100644 x/precisebank/keeper/mint.go create mode 100644 x/precisebank/keeper/mint_integration_test.go create mode 100644 x/precisebank/keeper/mint_test.go diff --git a/Makefile b/Makefile index 9024a19a..88ae6da7 100644 --- a/Makefile +++ b/Makefile @@ -327,6 +327,16 @@ test-cli: build test-migrate: @$(GO_BIN) test -v -count=1 ./migrate/... +# Use the old Apple linker to workaround broken xcode - https://github.com/golang/go/issues/65169 +ifeq ($(OS_FAMILY),Darwin) + FUZZLDFLAGS := -ldflags=-extldflags=-Wl,-ld_classic +endif + +test-fuzz: + @$(GO_BIN) test $(FUZZLDFLAGS) -run NOTAREALTEST -v -fuzztime 10s -fuzz=FuzzMintCoins ./x/precisebank/keeper + @$(GO_BIN) test $(FUZZLDFLAGS) -run NOTAREALTEST -v -fuzztime 10s -fuzz=FuzzGenesisStateValidate_NonZeroRemainder ./x/precisebank/types + @$(GO_BIN) test $(FUZZLDFLAGS) -run NOTAREALTEST -v -fuzztime 10s -fuzz=FuzzGenesisStateValidate_ZeroRemainder ./x/precisebank/types + # Kick start lots of sims on an AWS cluster. # This submits an AWS Batch job to run a lot of sims, each within a docker image. Results are uploaded to S3 start-remote-sims: @@ -347,4 +357,4 @@ update-kvtool: git submodule update cd tests/e2e/kvtool && make install -.PHONY: all build-linux install clean build test test-cli test-all test-rest test-basic start-remote-sims +.PHONY: all build-linux install clean build test test-cli test-all test-rest test-basic test-fuzz start-remote-sims diff --git a/x/precisebank/keeper/fractional_balance.go b/x/precisebank/keeper/fractional_balance.go index a5eebf1a..636e383c 100644 --- a/x/precisebank/keeper/fractional_balance.go +++ b/x/precisebank/keeper/fractional_balance.go @@ -15,12 +15,12 @@ import ( func (k *Keeper) GetFractionalBalance( ctx sdk.Context, address sdk.AccAddress, -) (sdkmath.Int, bool) { +) sdkmath.Int { store := prefix.NewStore(ctx.KVStore(k.storeKey), types.FractionalBalancePrefix) bz := store.Get(types.FractionalBalanceKey(address)) if bz == nil { - return sdkmath.ZeroInt(), false + return sdkmath.ZeroInt() } var bal sdkmath.Int @@ -28,7 +28,7 @@ func (k *Keeper) GetFractionalBalance( panic(fmt.Errorf("failed to unmarshal fractional balance: %w", err)) } - return bal, true + return bal } // SetFractionalBalance sets the fractional balance for an address. diff --git a/x/precisebank/keeper/fractional_balance_test.go b/x/precisebank/keeper/fractional_balance_test.go index 0a604995..be733ea4 100644 --- a/x/precisebank/keeper/fractional_balance_test.go +++ b/x/precisebank/keeper/fractional_balance_test.go @@ -4,6 +4,7 @@ import ( "testing" sdkmath "cosmossdk.io/math" + "github.com/cosmos/cosmos-sdk/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/require" @@ -11,9 +12,6 @@ import ( ) func TestSetGetFractionalBalance(t *testing.T) { - tk := NewMockedTestData(t) - ctx, k := tk.ctx, tk.keeper - addr := sdk.AccAddress([]byte("test-address")) tests := []struct { @@ -63,6 +61,9 @@ func TestSetGetFractionalBalance(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { + td := NewMockedTestData(t) + ctx, k := td.ctx, td.keeper + if tt.setPanicMsg != "" { require.PanicsWithError(t, tt.setPanicMsg, func() { k.SetFractionalBalance(ctx, tt.address, tt.amount) @@ -75,23 +76,24 @@ func TestSetGetFractionalBalance(t *testing.T) { k.SetFractionalBalance(ctx, tt.address, tt.amount) }) - // If its zero balance, check it was deleted + // If its zero balance, check it was deleted in store if tt.amount.IsZero() { - _, exists := k.GetFractionalBalance(ctx, tt.address) - require.False(t, exists) + store := prefix.NewStore(ctx.KVStore(td.storeKey), types.FractionalBalancePrefix) + bz := store.Get(types.FractionalBalanceKey(tt.address)) + require.Nil(t, bz) return } - gotAmount, exists := k.GetFractionalBalance(ctx, tt.address) - require.True(t, exists) + gotAmount := k.GetFractionalBalance(ctx, tt.address) require.Equal(t, tt.amount, gotAmount) // Delete balance k.DeleteFractionalBalance(ctx, tt.address) - _, exists = k.GetFractionalBalance(ctx, tt.address) - require.False(t, exists) + store := prefix.NewStore(ctx.KVStore(td.storeKey), types.FractionalBalancePrefix) + bz := store.Get(types.FractionalBalanceKey(tt.address)) + require.Nil(t, bz) }) } } @@ -111,23 +113,24 @@ func TestSetFractionalBalance_InvalidAddr(t *testing.T) { } func TestSetFractionalBalance_ZeroDeletes(t *testing.T) { - tk := NewMockedTestData(t) - ctx, k := tk.ctx, tk.keeper + td := NewMockedTestData(t) + ctx, k := td.ctx, td.keeper addr := sdk.AccAddress([]byte("test-address")) // Set balance k.SetFractionalBalance(ctx, addr, sdkmath.NewInt(100)) - bal, exists := k.GetFractionalBalance(ctx, addr) - require.True(t, exists) + bal := k.GetFractionalBalance(ctx, addr) require.Equal(t, sdkmath.NewInt(100), bal) // Set zero balance k.SetFractionalBalance(ctx, addr, sdkmath.ZeroInt()) - _, exists = k.GetFractionalBalance(ctx, addr) - require.False(t, exists) + // Check balance was deleted + store := prefix.NewStore(ctx.KVStore(td.storeKey), types.FractionalBalancePrefix) + bz := store.Get(types.FractionalBalanceKey(addr)) + require.Nil(t, bz) // Set zero balance again on non-existent balance require.NotPanics( diff --git a/x/precisebank/keeper/keeper.go b/x/precisebank/keeper/keeper.go index 8077dfa3..bf35f94c 100644 --- a/x/precisebank/keeper/keeper.go +++ b/x/precisebank/keeper/keeper.go @@ -4,6 +4,7 @@ import ( "github.com/cosmos/cosmos-sdk/codec" storetypes "github.com/cosmos/cosmos-sdk/store/types" sdk "github.com/cosmos/cosmos-sdk/types" + evmtypes "github.com/evmos/ethermint/x/evm/types" "github.com/kava-labs/kava/x/precisebank/types" @@ -36,10 +37,6 @@ func NewKeeper( } } -func (k Keeper) MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error { - panic("unimplemented") -} - func (k Keeper) BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error { panic("unimplemented") } diff --git a/x/precisebank/keeper/keeper_test.go b/x/precisebank/keeper/keeper_test.go index dba66194..a39c5389 100644 --- a/x/precisebank/keeper/keeper_test.go +++ b/x/precisebank/keeper/keeper_test.go @@ -3,6 +3,7 @@ package keeper_test import ( "testing" + sdkmath "cosmossdk.io/math" storetypes "github.com/cosmos/cosmos-sdk/store/types" "github.com/cosmos/cosmos-sdk/testutil" sdk "github.com/cosmos/cosmos-sdk/types" @@ -46,3 +47,7 @@ func NewMockedTestData(t *testing.T) testData { ak: ak, } } + +func c(denom string, amount int64) sdk.Coin { return sdk.NewInt64Coin(denom, amount) } +func ci(denom string, amount sdkmath.Int) sdk.Coin { return sdk.NewCoin(denom, amount) } +func cs(coins ...sdk.Coin) sdk.Coins { return sdk.NewCoins(coins...) } diff --git a/x/precisebank/keeper/mint.go b/x/precisebank/keeper/mint.go new file mode 100644 index 00000000..1e0fabc3 --- /dev/null +++ b/x/precisebank/keeper/mint.go @@ -0,0 +1,138 @@ +package keeper + +import ( + "fmt" + + errorsmod "cosmossdk.io/errors" + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + + "github.com/kava-labs/kava/x/precisebank/types" +) + +// MintCoins creates new coins from thin air and adds it to the module account. +// If ExtendedCoinDenom is provided, the corresponding fractional amount is +// added to the module state. +// It will panic if the module account does not exist or is unauthorized. +func (k Keeper) MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error { + // Disallow minting to x/precisebank module + if moduleName == types.ModuleName { + panic(errorsmod.Wrapf(sdkerrors.ErrUnauthorized, "module account %s cannot be minted to", moduleName)) + } + + // Note: MintingRestrictionFn is not used in x/precisebank + // Panic errors are identical to x/bank for consistency. + acc := k.ak.GetModuleAccount(ctx, moduleName) + if acc == nil { + panic(errorsmod.Wrapf(sdkerrors.ErrUnknownAddress, "module account %s does not exist", moduleName)) + } + + if !acc.HasPermission(authtypes.Minter) { + panic(errorsmod.Wrapf(sdkerrors.ErrUnauthorized, "module account %s does not have permissions to mint tokens", moduleName)) + } + + // Ensure the coins are valid before minting + if !amt.IsValid() { + return errorsmod.Wrap(sdkerrors.ErrInvalidCoins, amt.String()) + } + + // Get non-ExtendedCoinDenom coins + passthroughCoins := amt + + extendedAmount := amt.AmountOf(types.ExtendedCoinDenom) + if extendedAmount.IsPositive() { + // Remove ExtendedCoinDenom from the coins as it is managed by x/precisebank + removeCoin := sdk.NewCoin(types.ExtendedCoinDenom, extendedAmount) + passthroughCoins = amt.Sub(removeCoin) + } + + // Coins unmanaged by x/precisebank are passed through to x/bank + if !passthroughCoins.Empty() { + if err := k.bk.MintCoins(ctx, moduleName, passthroughCoins); err != nil { + return err + } + } + + // No more processing required if no ExtendedCoinDenom + if extendedAmount.IsZero() { + return nil + } + + return k.mintExtendedCoin(ctx, moduleName, extendedAmount) +} + +// mintExtendedCoin manages the minting of extended coins, and no other coins. +func (k Keeper) mintExtendedCoin( + ctx sdk.Context, + moduleName string, + amt sdkmath.Int, +) error { + moduleAddr := k.ak.GetModuleAddress(moduleName) + + // Get current module account fractional balance - 0 if not found + fractionalAmount := k.GetFractionalBalance(ctx, moduleAddr) + + // Get separated mint amounts + integerMintAmount := amt.Quo(types.ConversionFactor()) + fractionalMintAmount := amt.Mod(types.ConversionFactor()) + + // Get new fractional balance after minting, this could be greater than + // the conversion factor and must be checked for carry over to integer mint + // amount as being set as-is may cause fractional balance exceeding max. + newFractionalBalance := fractionalAmount.Add(fractionalMintAmount) + + // If it carries over, add 1 to integer mint amount. In this case, it will + // always be 1: + // fractional amounts x and y where both x and y < ConversionFactor + // x + y < (2 * ConversionFactor) - 2 + // x + y < 1 integer amount + fractional amount + if newFractionalBalance.GTE(types.ConversionFactor()) { + // Carry over to integer mint amount + integerMintAmount = integerMintAmount.AddRaw(1) + // Subtract 1 integer equivalent amount of fractional balance. Same + // behavior as using .Mod() in this case. + newFractionalBalance = newFractionalBalance.Sub(types.ConversionFactor()) + } + + // Mint new integer amounts in x/bank - including carry over from fractional + // amount if any. + if integerMintAmount.IsPositive() { + integerMintCoin := sdk.NewCoin(types.IntegerCoinDenom, integerMintAmount) + + if err := k.bk.MintCoins( + ctx, + moduleName, + sdk.NewCoins(integerMintCoin), + ); err != nil { + return err + } + } + + // Assign new fractional balance in x/precisebank + k.SetFractionalBalance(ctx, moduleAddr, newFractionalBalance) + + // ---------------------------------------- + // Update remainder & reserves to back minted fractional coins + prevRemainder := k.GetRemainderAmount(ctx) + // Deduct new remainder with minted fractional amount + newRemainder := prevRemainder.Sub(fractionalMintAmount) + + if prevRemainder.LT(fractionalMintAmount) { + // Need additional 1 integer coin in reserve to back minted fractional + reserveMintCoins := sdk.NewCoins(sdk.NewCoin(types.IntegerCoinDenom, sdkmath.OneInt())) + if err := k.bk.MintCoins(ctx, types.ModuleName, reserveMintCoins); err != nil { + return fmt.Errorf("failed to mint %s for reserve: %w", reserveMintCoins, err) + } + + // Update remainder with value of minted integer coin. newRemainder is + // currently negative at this point. This also means that it will always + // be < conversionFactor after this operation and not require a Mod(). + newRemainder = newRemainder.Add(types.ConversionFactor()) + } + + k.SetRemainderAmount(ctx, newRemainder) + + return nil +} diff --git a/x/precisebank/keeper/mint_integration_test.go b/x/precisebank/keeper/mint_integration_test.go new file mode 100644 index 00000000..021b4100 --- /dev/null +++ b/x/precisebank/keeper/mint_integration_test.go @@ -0,0 +1,359 @@ +package keeper_test + +import ( + "fmt" + "testing" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" + + "github.com/kava-labs/kava/x/precisebank/keeper" + "github.com/kava-labs/kava/x/precisebank/testutil" + "github.com/kava-labs/kava/x/precisebank/types" + "github.com/stretchr/testify/suite" +) + +type mintIntegrationTestSuite struct { + testutil.Suite +} + +func (suite *mintIntegrationTestSuite) SetupTest() { + suite.Suite.SetupTest() +} + +func TestMintIntegrationTest(t *testing.T) { + suite.Run(t, new(mintIntegrationTestSuite)) +} + +func (suite *mintIntegrationTestSuite) TestBlockedRecipient() { + // Tests that sending funds to x/precisebank is disallowed. + // x/precisebank balance is used as the reserve funds and should not be + // directly interacted with by external modules or users. + msgServer := bankkeeper.NewMsgServerImpl(suite.BankKeeper) + + fromAddr := sdk.AccAddress{1} + + // To x/precisebank + toAddr := suite.AccountKeeper.GetModuleAddress(types.ModuleName) + amount := cs(c("ukava", 1000)) + + msg := banktypes.NewMsgSend(fromAddr, toAddr, amount) + + _, err := msgServer.Send(sdk.WrapSDKContext(suite.Ctx), msg) + suite.Require().Error(err) + + suite.Require().EqualError( + err, + fmt.Sprintf("%s is not allowed to receive funds: unauthorized", toAddr.String()), + ) +} + +func (suite *mintIntegrationTestSuite) TestMintCoins_MatchingErrors() { + // x/precisebank MintCoins should be identical to x/bank MintCoins to + // consumers. This test ensures that the panics & errors returned by + // x/precisebank are identical to x/bank. + + tests := []struct { + name string + recipientModule string + mintAmount sdk.Coins + wantErr string + wantPanic string + }{ + { + "invalid module", + "notamodule", + cs(c("ukava", 1000)), + "", + "module account notamodule does not exist: unknown address", + }, + { + "no mint permissions", + // Check app.go to ensure this module has no mint permissions + authtypes.FeeCollectorName, + cs(c("ukava", 1000)), + "", + "module account fee_collector does not have permissions to mint tokens: unauthorized", + }, + { + "invalid amount", + minttypes.ModuleName, + sdk.Coins{sdk.Coin{Denom: "ukava", Amount: sdkmath.NewInt(-100)}}, + "-100ukava: invalid coins", + "", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + // Reset + suite.SetupTest() + + if tt.wantErr == "" && tt.wantPanic == "" { + suite.Fail("test must specify either wantErr or wantPanic") + } + + if tt.wantErr != "" { + // Check x/bank MintCoins for identical error + bankErr := suite.BankKeeper.MintCoins(suite.Ctx, tt.recipientModule, tt.mintAmount) + suite.Require().Error(bankErr) + suite.Require().EqualError(bankErr, tt.wantErr, "expected error should match x/bank MintCoins error") + + pbankErr := suite.Keeper.MintCoins(suite.Ctx, tt.recipientModule, tt.mintAmount) + suite.Require().Error(pbankErr) + // Compare strings instead of errors, as error stack is still different + suite.Require().Equal( + bankErr.Error(), + pbankErr.Error(), + "x/precisebank error should match x/bank MintCoins error", + ) + } + + if tt.wantPanic != "" { + // First check the wantPanic string is correct. + // Actually specify the panic string in the test since it makes + // it more clear we are testing specific and different cases. + suite.Require().PanicsWithError(tt.wantPanic, func() { + _ = suite.BankKeeper.MintCoins(suite.Ctx, tt.recipientModule, tt.mintAmount) + }, "expected panic error should match x/bank MintCoins") + + suite.Require().PanicsWithError(tt.wantPanic, func() { + _ = suite.Keeper.MintCoins(suite.Ctx, tt.recipientModule, tt.mintAmount) + }, "x/precisebank panic should match x/bank MintCoins") + } + }) + } +} + +func (suite *mintIntegrationTestSuite) TestMintCoins() { + type mintTest struct { + mintAmount sdk.Coins + // Expected **full** balances after MintCoins(mintAmount) + wantBalance sdk.Coins + } + + tests := []struct { + name string + recipientModule string + // Instead of having a start balance, we just have a list of mints to + // both test & get into desired non-default states. + mints []mintTest + }{ + { + "passthrough - unrelated", + minttypes.ModuleName, + []mintTest{ + { + mintAmount: cs(c("busd", 1000)), + wantBalance: cs(c("busd", 1000)), + }, + }, + }, + { + "passthrough - integer denom", + minttypes.ModuleName, + []mintTest{ + { + mintAmount: cs(c(types.IntegerCoinDenom, 1000)), + wantBalance: cs(c(types.ExtendedCoinDenom, 1000000000000000)), + }, + }, + }, + { + "fractional only", + minttypes.ModuleName, + []mintTest{ + { + mintAmount: cs(c(types.ExtendedCoinDenom, 1000)), + wantBalance: cs(c(types.ExtendedCoinDenom, 1000)), + }, + { + mintAmount: cs(c(types.ExtendedCoinDenom, 1000)), + wantBalance: cs(c(types.ExtendedCoinDenom, 2000)), + }, + }, + }, + { + "exact carry", + minttypes.ModuleName, + []mintTest{ + { + mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor())), + wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor())), + }, + // Carry again - exact amount + { + mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor())), + wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(2))), + }, + }, + }, + { + "carry with extra", + minttypes.ModuleName, + []mintTest{ + // MintCoins(C + 100) + { + mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().AddRaw(100))), + wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().AddRaw(100))), + }, + // MintCoins(C + 5), total = 2C + 105 + { + mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().AddRaw(5))), + wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(2).AddRaw(105))), + }, + }, + }, + { + "integer with fractional", + minttypes.ModuleName, + []mintTest{ + { + mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5).AddRaw(100))), + wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5).AddRaw(100))), + }, + { + mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(2).AddRaw(5))), + wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(7).AddRaw(105))), + }, + }, + }, + { + "with passthrough", + minttypes.ModuleName, + []mintTest{ + { + mintAmount: cs( + ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5).AddRaw(100)), + c("busd", 1000), + ), + wantBalance: cs( + ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5).AddRaw(100)), + c("busd", 1000), + ), + }, + { + mintAmount: cs( + ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(2).AddRaw(5)), + c("meow", 40), + ), + wantBalance: cs( + ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(7).AddRaw(105)), + c("busd", 1000), + c("meow", 40), + ), + }, + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + // Reset + suite.SetupTest() + + recipientAddr := suite.AccountKeeper.GetModuleAddress(tt.recipientModule) + + for _, mt := range tt.mints { + err := suite.Keeper.MintCoins(suite.Ctx, tt.recipientModule, mt.mintAmount) + suite.Require().NoError(err) + + // ------------------------------------------------------------- + // Check FULL balances + // x/bank balances + x/precisebank balance + // Exclude "ukava" as x/precisebank balance will include it + bankCoins := suite.BankKeeper.GetAllBalances(suite.Ctx, recipientAddr) + + // Only use x/bank balances for non-ukava denoms + var denoms []string + for _, coin := range bankCoins { + // Ignore integer coins, query the extended denom instead + if coin.Denom == types.IntegerCoinDenom { + continue + } + + denoms = append(denoms, coin.Denom) + } + + // Add the extended denom to the list of denoms to balance check + // Will be included in balance check even if x/bank doesn't have + // ukava. + denoms = append(denoms, types.ExtendedCoinDenom) + + // All balance queries through x/precisebank + afterBalance := sdk.NewCoins() + for _, denom := range denoms { + coin := suite.Keeper.GetBalance(suite.Ctx, recipientAddr, denom) + afterBalance = afterBalance.Add(coin) + } + + suite.Require().Equal( + mt.wantBalance.String(), + afterBalance.String(), + "unexpected balance after minting %s to %s", + ) + + // Ensure reserve is backing all minted fractions + allInvariantsFn := keeper.AllInvariants(suite.Keeper) + res, stop := allInvariantsFn(suite.Ctx) + suite.Require().False(stop, "invariant should not be broken") + suite.Require().Empty(res, "unexpected invariant message: %s", res) + } + }) + } +} + +func FuzzMintCoins(f *testing.F) { + f.Add(int64(0)) + f.Add(int64(100)) + f.Add(types.ConversionFactor().Int64()) + f.Add(types.ConversionFactor().MulRaw(5).Int64()) + f.Add(types.ConversionFactor().MulRaw(2).AddRaw(123948723).Int64()) + + f.Fuzz(func(t *testing.T, amount int64) { + // No negative amounts + if amount < 0 { + amount = -amount + } + + // Manually setup test suite since no direct Fuzz support in test suites + suite := new(mintIntegrationTestSuite) + suite.SetT(t) + suite.SetS(suite) + suite.SetupTest() + + mintCount := int64(10) + + // Mint 10 times to include mints from non-zero balances + for i := int64(0); i < mintCount; i++ { + err := suite.Keeper.MintCoins( + suite.Ctx, + minttypes.ModuleName, + cs(c(types.ExtendedCoinDenom, amount)), + ) + suite.Require().NoError(err) + } + + // Check FULL balances + recipientAddr := suite.AccountKeeper.GetModuleAddress(minttypes.ModuleName) + bal := suite.Keeper.GetBalance(suite.Ctx, recipientAddr, types.ExtendedCoinDenom) + + suite.Require().Equalf( + amount*mintCount, + bal.Amount.Int64(), + "unexpected balance after minting %d 5 times", + amount, + ) + + // Run Invariants to ensure remainder is backing all minted fractions + // and in a valid state + allInvariantsFn := keeper.AllInvariants(suite.Keeper) + res, stop := allInvariantsFn(suite.Ctx) + suite.Require().False(stop, "invariant should not be broken") + suite.Require().Empty(res, "unexpected invariant message: %s", res) + }) +} diff --git a/x/precisebank/keeper/mint_test.go b/x/precisebank/keeper/mint_test.go new file mode 100644 index 00000000..6be41fca --- /dev/null +++ b/x/precisebank/keeper/mint_test.go @@ -0,0 +1,347 @@ +package keeper_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" + + "github.com/kava-labs/kava/x/precisebank/types" +) + +func TestMintCoins_PanicValidations(t *testing.T) { + // panic tests for invalid inputs + + tests := []struct { + name string + recipientModule string + setupFn func(td testData) + mintAmount sdk.Coins + wantPanic string + }{ + { + "invalid module", + "notamodule", + func(td testData) { + // Make module not found + td.ak.EXPECT(). + GetModuleAccount(td.ctx, "notamodule"). + Return(nil). + Once() + }, + cs(c("ukava", 1000)), + "module account notamodule does not exist: unknown address", + }, + { + "no permission", + minttypes.ModuleName, + func(td testData) { + td.ak.EXPECT(). + GetModuleAccount(td.ctx, minttypes.ModuleName). + Return(authtypes.NewModuleAccount( + authtypes.NewBaseAccountWithAddress(sdk.AccAddress{1}), + minttypes.ModuleName, + // no mint permission + )). + Once() + }, + cs(c("ukava", 1000)), + "module account mint does not have permissions to mint tokens: unauthorized", + }, + { + "has mint permission", + minttypes.ModuleName, + func(td testData) { + td.ak.EXPECT(). + GetModuleAccount(td.ctx, minttypes.ModuleName). + Return(authtypes.NewModuleAccount( + authtypes.NewBaseAccountWithAddress(sdk.AccAddress{1}), + minttypes.ModuleName, + // includes minter permission + authtypes.Minter, + )). + Once() + + // Will call x/bank MintCoins coins + td.bk.EXPECT(). + MintCoins(td.ctx, minttypes.ModuleName, cs(c("ukava", 1000))). + Return(nil). + Once() + }, + cs(c("ukava", 1000)), + "", + }, + { + "disallow minting to x/precisebank", + types.ModuleName, + func(td testData) { + // No mock setup needed since this is checked before module + // account checks + }, + cs(c("ukava", 1000)), + "module account precisebank cannot be minted to: unauthorized", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + td := NewMockedTestData(t) + tt.setupFn(td) + + if tt.wantPanic != "" { + require.PanicsWithError(t, tt.wantPanic, func() { + _ = td.keeper.MintCoins(td.ctx, tt.recipientModule, tt.mintAmount) + }) + return + } + + require.NotPanics(t, func() { + // Not testing errors, only panics for this test + _ = td.keeper.MintCoins(td.ctx, tt.recipientModule, tt.mintAmount) + }) + }) + } +} + +func TestMintCoins_Errors(t *testing.T) { + // returned errors, not panics + + tests := []struct { + name string + recipientModule string + setupFn func(td testData) + mintAmount sdk.Coins + wantError string + }{ + { + "invalid coins", + minttypes.ModuleName, + func(td testData) { + // Valid module account minter + td.ak.EXPECT(). + GetModuleAccount(td.ctx, minttypes.ModuleName). + Return(authtypes.NewModuleAccount( + authtypes.NewBaseAccountWithAddress(sdk.AccAddress{1}), + minttypes.ModuleName, + // includes minter permission + authtypes.Minter, + )). + Once() + }, + sdk.Coins{sdk.Coin{ + Denom: "ukava", + Amount: sdk.NewInt(-1000), + }}, + "-1000ukava: invalid coins", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + td := NewMockedTestData(t) + tt.setupFn(td) + + require.NotPanics(t, func() { + err := td.keeper.MintCoins(td.ctx, tt.recipientModule, tt.mintAmount) + + if tt.wantError != "" { + require.Error(t, err) + require.EqualError(t, err, tt.wantError) + return + } + + require.NoError(t, err) + }) + }) + } +} + +func TestMintCoins_ExpectedCalls(t *testing.T) { + // Tests the expected calls to the bank keeper when minting coins + + tests := []struct { + name string + // Only care about starting fractional balance. + // MintCoins() doesn't care about the previous integer balance. + startFractionalBalance sdkmath.Int + mintAmount sdk.Coins + // account x/precisebank balance (fractional amount) + wantPreciseBalance sdkmath.Int + }{ + { + "passthrough mint - integer denom", + sdkmath.ZeroInt(), + cs(c("ukava", 1000)), + sdkmath.ZeroInt(), + }, + + { + "passthrough mint - unrelated denom", + sdkmath.ZeroInt(), + cs(c("meow", 1000)), + sdkmath.ZeroInt(), + }, + { + "no carry - 0 starting fractional", + sdkmath.ZeroInt(), + cs(c(types.ExtendedCoinDenom, 1000)), + sdkmath.NewInt(1000), + }, + { + "no carry - non-zero fractional", + sdkmath.NewInt(1_000_000), + cs(c(types.ExtendedCoinDenom, 1000)), + sdkmath.NewInt(1_001_000), + }, + { + "fractional carry", + // max fractional amount + types.ConversionFactor().SubRaw(1), + cs(c(types.ExtendedCoinDenom, 1)), // +1 to carry + sdkmath.ZeroInt(), + }, + { + "fractional carry max", + // max fractional amount + max fractional amount + types.ConversionFactor().SubRaw(1), + cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().SubRaw(1))), + types.ConversionFactor().SubRaw(2), + }, + { + "integer with fractional no carry", + sdkmath.NewInt(1234), + // mint 100 fractional + cs(c(types.ExtendedCoinDenom, 100)), + sdkmath.NewInt(1234 + 100), + }, + { + "integer with fractional carry", + types.ConversionFactor().SubRaw(100), + // mint 105 fractional to carry + cs(c(types.ExtendedCoinDenom, 105)), + sdkmath.NewInt(5), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + td := NewMockedTestData(t) + + // Set initial fractional balance + // Initial integer balance doesn't matter for this test + moduleAddr := sdk.AccAddress{1} + td.keeper.SetFractionalBalance( + td.ctx, + moduleAddr, + tt.startFractionalBalance, + ) + fBal := td.keeper.GetFractionalBalance(td.ctx, moduleAddr) + require.Equal(t, tt.startFractionalBalance, fBal) + + // Always calls GetModuleAccount() to check if module exists & + // has permission + td.ak.EXPECT(). + GetModuleAccount(td.ctx, minttypes.ModuleName). + Return(authtypes.NewModuleAccount( + authtypes.NewBaseAccountWithAddress( + moduleAddr, + ), + minttypes.ModuleName, + // Include minter permissions - not testing permission in + // this test + authtypes.Minter, + )). + Once() + + // ---------------------------------------- + // Separate passthrough and extended coins + // Determine how much is passed through to x/bank + passthroughCoins := tt.mintAmount + + found, extCoins := tt.mintAmount.Find(types.ExtendedCoinDenom) + if found { + // Remove extended coin from passthrough coins + passthroughCoins = passthroughCoins.Sub(extCoins) + } else { + extCoins = sdk.NewCoin(types.ExtendedCoinDenom, sdkmath.ZeroInt()) + } + + require.Equalf( + t, + sdkmath.ZeroInt(), + passthroughCoins.AmountOf(types.ExtendedCoinDenom), + "expected pass through coins should not include %v", + types.ExtendedCoinDenom, + ) + + // ---------------------------------------- + // Set expectations for minting passthrough coins + // Only expect MintCoins to be called with passthrough coins with non-zero amount + if !passthroughCoins.Empty() { + t.Logf("Expecting MintCoins(%v)", passthroughCoins) + + td.bk.EXPECT(). + MintCoins(td.ctx, minttypes.ModuleName, passthroughCoins). + Return(nil). + Once() + } + + // ---------------------------------------- + // Set expectations for minting fractional coins + if !extCoins.IsNil() && extCoins.IsPositive() { + td.ak.EXPECT(). + GetModuleAddress(minttypes.ModuleName). + Return(moduleAddr). + Once() + + // Initial integer balance is always 0 for this test + totalNewBalance := tt.startFractionalBalance.Add(extCoins.Amount) + mintIntegerAmount := totalNewBalance.Quo(types.ConversionFactor()) + + mintCoins := cs(ci(types.IntegerCoinDenom, mintIntegerAmount)) + + // Only expect MintCoins to be called with mint coins with + // non-zero amount. + // Will fail if x/bank MintCoins is called with empty coins + if !mintCoins.Empty() { + t.Logf("Expecting MintCoins(%v)", mintCoins) + + td.bk.EXPECT(). + MintCoins(td.ctx, minttypes.ModuleName, mintCoins). + Return(nil). + Once() + } + } + + // ---------------------------------------- + // Set expectations for reserve minting when fractional amounts + // are minted & remainder is insufficient + mintFractionalAmount := extCoins.Amount.Mod(types.ConversionFactor()) + currentRemainder := td.keeper.GetRemainderAmount(td.ctx) + + remainderEnough := currentRemainder.GTE(mintFractionalAmount) + if !remainderEnough { + reserveMintCoins := cs(ci(types.IntegerCoinDenom, sdkmath.OneInt())) + td.bk.EXPECT(). + // Mints to x/precisebank + MintCoins(td.ctx, types.ModuleName, reserveMintCoins). + Return(nil). + Once() + } + + // ---------------------------------------- + // Actual call after all setup and expectations + require.NotPanics(t, func() { + err := td.keeper.MintCoins(td.ctx, minttypes.ModuleName, tt.mintAmount) + require.NoError(t, err) + }) + + // Check final fractional balance + fBal = td.keeper.GetFractionalBalance(td.ctx, moduleAddr) + require.Equal(t, tt.wantPreciseBalance, fBal) + }) + } +} diff --git a/x/precisebank/keeper/view.go b/x/precisebank/keeper/view.go index ead5a2cf..baf77bf8 100644 --- a/x/precisebank/keeper/view.go +++ b/x/precisebank/keeper/view.go @@ -23,10 +23,7 @@ func (k Keeper) GetBalance( integerAmount := spendableCoins.AmountOf(types.IntegerCoinDenom) // x/precisebank for fractional balance - fractionalAmount, found := k.GetFractionalBalance(ctx, addr) - if !found { - fractionalAmount = sdk.ZeroInt() - } + fractionalAmount := k.GetFractionalBalance(ctx, addr) // (Integer * ConversionFactor) + Fractional fullAmount := integerAmount. diff --git a/x/precisebank/testutil/fractional_balances.go b/x/precisebank/testutil/fractional_balances.go index 0eb62ddb..61eb11d3 100644 --- a/x/precisebank/testutil/fractional_balances.go +++ b/x/precisebank/testutil/fractional_balances.go @@ -135,7 +135,7 @@ func GenerateEqualFractionalBalancesWithRemainder( ) (types.FractionalBalances, sdkmath.Int) { t.Helper() - require.GreaterOrEqual(t, count, 3, "count must be at least 3 to generate both balances and remainder") + require.GreaterOrEqual(t, count, 2, "count must be at least 2 to generate both balances and remainder") countWithRemainder := count + 1