From 38230d35e3012b9917de5cdd902c13cb86dba6a7 Mon Sep 17 00:00:00 2001 From: drklee3 Date: Thu, 20 Jun 2024 15:02:23 -0700 Subject: [PATCH] feat(x/precisebank): Implement BurnCoins (#1934) Implement & test BurnCoins method --- Makefile | 1 + x/precisebank/keeper/burn.go | 169 ++++++ x/precisebank/keeper/burn_integration_test.go | 485 ++++++++++++++++++ x/precisebank/keeper/burn_test.go | 161 ++++++ x/precisebank/keeper/keeper.go | 5 - x/precisebank/types/expected_keepers.go | 1 + x/precisebank/types/mocks/MockBankKeeper.go | 49 ++ 7 files changed, 866 insertions(+), 5 deletions(-) create mode 100644 x/precisebank/keeper/burn.go create mode 100644 x/precisebank/keeper/burn_integration_test.go create mode 100644 x/precisebank/keeper/burn_test.go diff --git a/Makefile b/Makefile index 9dd72fdd..c9ea8619 100644 --- a/Makefile +++ b/Makefile @@ -334,6 +334,7 @@ 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=FuzzBurnCoins ./x/precisebank/keeper @$(GO_BIN) test $(FUZZLDFLAGS) -run NOTAREALTEST -v -fuzztime 10s -fuzz=FuzzSendCoins ./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 diff --git a/x/precisebank/keeper/burn.go b/x/precisebank/keeper/burn.go new file mode 100644 index 00000000..38750270 --- /dev/null +++ b/x/precisebank/keeper/burn.go @@ -0,0 +1,169 @@ +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" +) + +// BurnCoins burns coins deletes coins from the balance of the module account. +// It will panic if the module account does not exist or is unauthorized. +func (k Keeper) BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error { + // Custom protection for x/precisebank, no external module should be able to + // affect reserves. + if moduleName == types.ModuleName { + panic(errorsmod.Wrapf(sdkerrors.ErrUnauthorized, "module account %s cannot be burned from", moduleName)) + } + + // 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.Burner) { + panic(errorsmod.Wrapf(sdkerrors.ErrUnauthorized, "module account %s does not have permissions to burn tokens", moduleName)) + } + + // Ensure the coins are valid before burning + 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.BurnCoins(ctx, moduleName, passthroughCoins); err != nil { + return err + } + } + + // No more processing required if no ExtendedCoinDenom + if extendedAmount.IsZero() { + return nil + } + + return k.burnExtendedCoin(ctx, moduleName, extendedAmount) +} + +// burnExtendedCoin burns the fractional amount of the ExtendedCoinDenom from the module account. +func (k Keeper) burnExtendedCoin( + ctx sdk.Context, + moduleName string, + amt sdkmath.Int, +) error { + // Get the module address + moduleAddr := k.ak.GetModuleAddress(moduleName) + + // We only need the fractional balance to burn coins, as integer burns will + // return errors on insufficient funds. + prevFractionalBalance := k.GetFractionalBalance(ctx, moduleAddr) + + // Get remainder amount first to optimize direct burn. + prevRemainder := k.GetRemainderAmount(ctx) + + // ------------------------------------------------------------------------- + // Pure stateless calculations + + integerBurnAmount := amt.Quo(types.ConversionFactor()) + fractionalBurnAmount := amt.Mod(types.ConversionFactor()) + + // newFractionalBalance can be negative if fractional balance is insufficient. + newFractionalBalance := prevFractionalBalance.Sub(fractionalBurnAmount) + + // If true, fractional balance is insufficient and will require an integer + // borrow. + requiresBorrow := newFractionalBalance.IsNegative() + + // Add to new remainder with burned fractional amount. + newRemainder := prevRemainder.Add(fractionalBurnAmount) + + // If true, remainder has accumulated enough fractional amounts to burn 1 + // integer coin. + overflowingRemainder := newRemainder.GTE(types.ConversionFactor()) + + // ------------------------------------------------------------------------- + // Stateful operations for burn + + // Not enough fractional balance: + // 1. If the new remainder incurs an additional reserve burn, we can just + // burn an additional integer coin from the account directly instead as + // an optimization. + // 2. If the new remainder is still under conversion factor (no extra + // reserve burn) then we need to transfer 1 integer coin to the reserve + // for the integer borrow. + + // Case #1: (optimization) direct burn instead of borrow (reserve transfer) + // & reserve burn. No additional reserve burn would be necessary after this. + if requiresBorrow && overflowingRemainder { + newFractionalBalance = newFractionalBalance.Add(types.ConversionFactor()) + newRemainder = newRemainder.Sub(types.ConversionFactor()) + + integerBurnAmount = integerBurnAmount.AddRaw(1) + } + + // Case #2: Transfer 1 integer coin to reserve for integer borrow to ensure + // reserve fully backs the fractional amount. + if requiresBorrow && !overflowingRemainder { + newFractionalBalance = newFractionalBalance.Add(types.ConversionFactor()) + + // Transfer 1 integer coin to reserve to cover the borrowed fractional + // amount. SendCoinsFromModuleToModule will return an error if the + // module account has insufficient funds and an error with the full + // extended balance will be returned. + borrowCoin := sdk.NewCoin(types.IntegerCoinDenom, sdkmath.OneInt()) + if err := k.bk.SendCoinsFromModuleToModule( + ctx, + moduleName, + types.ModuleName, // borrowed integer is transferred to reserve + sdk.NewCoins(borrowCoin), + ); err != nil { + return k.updateInsufficientFundsError(ctx, moduleAddr, amt, err) + } + } + + // Case #3: Does not require borrow, but remainder has accumulated enough + // fractional amounts to burn 1 integer coin. + if !requiresBorrow && overflowingRemainder { + reserveBurnCoins := sdk.NewCoins(sdk.NewCoin(types.IntegerCoinDenom, sdkmath.OneInt())) + if err := k.bk.BurnCoins(ctx, types.ModuleName, reserveBurnCoins); err != nil { + return fmt.Errorf("failed to burn %s for reserve: %w", reserveBurnCoins, err) + } + + newRemainder = newRemainder.Sub(types.ConversionFactor()) + } + + // Case #4: No additional work required, no borrow needed and no additional + // reserve burn + + // Burn the integer amount - this may include the extra optimization burn + // from case #1 + if !integerBurnAmount.IsZero() { + coin := sdk.NewCoin(types.IntegerCoinDenom, integerBurnAmount) + if err := k.bk.BurnCoins(ctx, moduleName, sdk.NewCoins(coin)); err != nil { + return k.updateInsufficientFundsError(ctx, moduleAddr, amt, err) + } + } + + // Assign new fractional balance in x/precisebank + k.SetFractionalBalance(ctx, moduleAddr, newFractionalBalance) + + // Update remainder for burned fractional coins + k.SetRemainderAmount(ctx, newRemainder) + + return nil +} diff --git a/x/precisebank/keeper/burn_integration_test.go b/x/precisebank/keeper/burn_integration_test.go new file mode 100644 index 00000000..c9b5b67f --- /dev/null +++ b/x/precisebank/keeper/burn_integration_test.go @@ -0,0 +1,485 @@ +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" + ibctransfertypes "github.com/cosmos/ibc-go/v7/modules/apps/transfer/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 burnIntegrationTestSuite struct { + testutil.Suite +} + +func (suite *burnIntegrationTestSuite) SetupTest() { + suite.Suite.SetupTest() +} + +func TestBurnIntegrationTest(t *testing.T) { + suite.Run(t, new(burnIntegrationTestSuite)) +} + +func (suite *burnIntegrationTestSuite) TestBurnCoins_MatchingErrors() { + // x/precisebank BurnCoins should be identical to x/bank BurnCoins to + // consumers. This test ensures that the panics & errors returned by + // x/precisebank are identical to x/bank. + + tests := []struct { + name string + recipientModule string + setupFn func() + burnAmount sdk.Coins + wantErr string + wantPanic string + }{ + { + "invalid module", + "notamodule", + func() {}, + cs(c("ukava", 1000)), + "", + "module account notamodule does not exist: unknown address", + }, + { + "no burn permissions", + // Check app.go to ensure this module has no burn permissions + authtypes.FeeCollectorName, + func() {}, + cs(c("ukava", 1000)), + "", + "module account fee_collector does not have permissions to burn tokens: unauthorized", + }, + { + "invalid amount", + // Has burn permissions so it goes to the amt check + ibctransfertypes.ModuleName, + func() {}, + sdk.Coins{sdk.Coin{Denom: "ukava", Amount: sdkmath.NewInt(-100)}}, + "-100ukava: invalid coins", + "", + }, + { + "insufficient balance - empty", + ibctransfertypes.ModuleName, + func() {}, + cs(c("ukava", 1000)), + "spendable balance is smaller than 1000ukava: insufficient funds", + "", + }, + } + + 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 BurnCoins for identical error + bankErr := suite.BankKeeper.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount) + suite.Require().Error(bankErr) + suite.Require().EqualError(bankErr, tt.wantErr, "expected error should match x/bank BurnCoins error") + + pbankErr := suite.Keeper.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount) + 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 BurnCoins 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.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount) + }, "expected panic error should match x/bank BurnCoins") + + suite.Require().PanicsWithError(tt.wantPanic, func() { + _ = suite.Keeper.BurnCoins(suite.Ctx, tt.recipientModule, tt.burnAmount) + }, "x/precisebank panic should match x/bank BurnCoins") + } + }) + } +} + +func (suite *burnIntegrationTestSuite) TestBurnCoins() { + tests := []struct { + name string + startBalance sdk.Coins + burnCoins sdk.Coins + wantBalance sdk.Coins + wantErr string + }{ + { + "passthrough - unrelated", + cs(c("meow", 1000)), + cs(c("meow", 1000)), + cs(), + "", + }, + { + "passthrough - integer denom", + cs(c(types.IntegerCoinDenom, 2000)), + cs(c(types.IntegerCoinDenom, 1000)), + cs(c(types.ExtendedCoinDenom, 1000000000000000)), + "", + }, + { + "fractional only - no borrow", + cs(c(types.ExtendedCoinDenom, 1000)), + cs(c(types.ExtendedCoinDenom, 500)), + cs(c(types.ExtendedCoinDenom, 500)), + "", + }, + { + "fractional burn - borrows", + cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().AddRaw(100))), + cs(c(types.ExtendedCoinDenom, 500)), + cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().SubRaw(400))), + "", + }, + { + "error - insufficient integer balance", + cs(ci(types.ExtendedCoinDenom, types.ConversionFactor())), + cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(2))), + cs(), + // Returns correct error with akava balance (rewrites Bank BurnCoins err) + "spendable balance 1000000000000akava is smaller than 2000000000000akava: insufficient funds", + }, + { + "error - insufficient fractional, borrow", + cs(c(types.ExtendedCoinDenom, 1000)), + cs(c(types.ExtendedCoinDenom, 2000)), + cs(), + // Error from SendCoins to reserve + "spendable balance 1000akava is smaller than 2000akava: insufficient funds", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + // Reset + suite.SetupTest() + + moduleName := ibctransfertypes.ModuleName + recipientAddr := suite.AccountKeeper.GetModuleAddress(moduleName) + + // Start balance + err := suite.Keeper.MintCoins(suite.Ctx, moduleName, tt.startBalance) + suite.Require().NoError(err) + + // Burn + err = suite.Keeper.BurnCoins(suite.Ctx, moduleName, tt.burnCoins) + if tt.wantErr != "" { + suite.Require().Error(err) + suite.Require().EqualError(err, tt.wantErr) + return + } + + suite.Require().NoError(err) + + // ------------------------------------------------------------- + // Check FULL balances + // x/bank balances + x/precisebank balance + // Exclude "ukava" as x/precisebank balance will include it + afterBalance := suite.GetAllBalances(recipientAddr) + + suite.Require().Equal( + tt.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 (suite *burnIntegrationTestSuite) TestBurnCoins_Remainder() { + // This tests a series of small burns to ensure the remainder is both + // updated correctly and reserve is correctly updated. This only burns from + // 1 single account. + + reserveAddr := suite.AccountKeeper.GetModuleAddress(types.ModuleName) + + moduleName := ibctransfertypes.ModuleName + moduleAddr := suite.AccountKeeper.GetModuleAddress(moduleName) + + startCoins := cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5))) + + // Start balance + err := suite.Keeper.MintCoins( + suite.Ctx, + moduleName, + startCoins, + ) + suite.Require().NoError(err) + + burnAmt := types.ConversionFactor().QuoRaw(10) + burnCoins := cs(ci(types.ExtendedCoinDenom, burnAmt)) + + // Burn 0.1 until balance is 0 + for { + reserveBalBefore := suite.Keeper.GetBalance( + suite.Ctx, + reserveAddr, + types.IntegerCoinDenom, + ) + + balBefore := suite.Keeper.GetBalance( + suite.Ctx, + moduleAddr, + types.ExtendedCoinDenom, + ) + remainderBefore := suite.Keeper.GetRemainderAmount(suite.Ctx) + + // ---------------------------------------- + // Burn + err := suite.Keeper.BurnCoins( + suite.Ctx, + moduleName, + burnCoins, + ) + suite.Require().NoError(err) + + // ---------------------------------------- + // Checks + remainderAfter := suite.Keeper.GetRemainderAmount(suite.Ctx) + balAfter := suite.Keeper.GetBalance( + suite.Ctx, + moduleAddr, + types.ExtendedCoinDenom, + ) + reserveBalAfter := suite.Keeper.GetBalance( + suite.Ctx, + reserveAddr, + types.IntegerCoinDenom, + ) + + suite.Require().Equal( + balBefore.Amount.Sub(burnAmt).String(), + balAfter.Amount.String(), + "balance should decrease by burn amount", + ) + + // Remainder should be updated correctly + suite.Require().Equal( + remainderBefore.Add(burnAmt).Mod(types.ConversionFactor()), + remainderAfter, + ) + + // If remainder has exceeded (then rolled over), reserve should be updated + if remainderAfter.LT(remainderBefore) { + suite.Require().Equal( + reserveBalBefore.Amount.SubRaw(1).String(), + reserveBalAfter.Amount.String(), + "reserve should decrease by 1 if remainder exceeds ConversionFactor", + ) + } + + // No more to burn + if balAfter.Amount.IsZero() { + break + } + } + + // Run Invariants to ensure remainder is backing all fractions correctly + res, stop := keeper.AllInvariants(suite.Keeper)(suite.Ctx) + suite.Require().False(stop, "invariant should not be broken") + suite.Require().Empty(res, "unexpected invariant message: %s", res) +} + +func (suite *burnIntegrationTestSuite) TestBurnCoins_Spread_Remainder() { + // This tests a series of small burns to ensure the remainder is both + // updated correctly and reserve is correctly updated. This burns from + // a series of multiple accounts, to test when the remainder is modified + // by multiple accounts. + + reserveAddr := suite.AccountKeeper.GetModuleAddress(types.ModuleName) + burnerModuleName := ibctransfertypes.ModuleName + burnerAddr := suite.AccountKeeper.GetModuleAddress(burnerModuleName) + + accCount := 20 + startCoins := cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().MulRaw(5))) + + addrs := []sdk.AccAddress{} + + for i := 0; i < accCount; i++ { + addr := sdk.AccAddress(fmt.Sprintf("addr%d", i)) + suite.MintToAccount(addr, startCoins) + + addrs = append(addrs, addr) + } + + burnAmt := types.ConversionFactor().QuoRaw(10) + burnCoins := cs(ci(types.ExtendedCoinDenom, burnAmt)) + + // Burn 0.1 from each account + for _, addr := range addrs { + reserveBalBefore := suite.Keeper.GetBalance( + suite.Ctx, + reserveAddr, + types.IntegerCoinDenom, + ) + + balBefore := suite.Keeper.GetBalance( + suite.Ctx, + addr, + types.ExtendedCoinDenom, + ) + remainderBefore := suite.Keeper.GetRemainderAmount(suite.Ctx) + + // ---------------------------------------- + // Send & Burn + err := suite.Keeper.SendCoins( + suite.Ctx, + addr, + burnerAddr, + burnCoins, + ) + suite.Require().NoError(err) + + err = suite.Keeper.BurnCoins( + suite.Ctx, + burnerModuleName, + burnCoins, + ) + suite.Require().NoError(err) + + // ---------------------------------------- + // Checks + remainderAfter := suite.Keeper.GetRemainderAmount(suite.Ctx) + balAfter := suite.Keeper.GetBalance( + suite.Ctx, + addr, + types.ExtendedCoinDenom, + ) + reserveBalAfter := suite.Keeper.GetBalance( + suite.Ctx, + reserveAddr, + types.IntegerCoinDenom, + ) + + suite.Require().Equal( + balBefore.Amount.Sub(burnAmt).String(), + balAfter.Amount.String(), + "balance should decrease by burn amount", + ) + + // Remainder should be updated correctly + suite.Require().Equal( + remainderBefore.Add(burnAmt).Mod(types.ConversionFactor()), + remainderAfter, + ) + + suite.T().Logf("acc: %s", string(addr.Bytes())) + suite.T().Logf("acc bal: %s -> %s", balBefore, balAfter) + suite.T().Logf("remainder: %s -> %s", remainderBefore, remainderAfter) + suite.T().Logf("reserve: %v -> %v", reserveBalBefore, reserveBalAfter) + + // Reserve will change when: + // 1. Account needs to borrow from integer (transfers to reserve) + // 2. Remainder meets or exceeds conversion factor (burn 1 from reserve) + reserveIncrease := sdkmath.ZeroInt() + + // Does account need to borrow from integer? + if balBefore.Amount.Mod(types.ConversionFactor()).LT(burnAmt) { + reserveIncrease = reserveIncrease.AddRaw(1) + } + + // If remainder has exceeded (then rolled over), burn additional 1 + if remainderBefore.Add(burnAmt).GTE(types.ConversionFactor()) { + reserveIncrease = reserveIncrease.SubRaw(1) + } + + suite.Require().Equal( + reserveBalBefore.Amount.Add(reserveIncrease).String(), + reserveBalAfter.Amount.String(), + "reserve should be updated by remainder and borrowing", + ) + + // Run Invariants to ensure remainder is backing all fractions correctly + res, stop := keeper.AllInvariants(suite.Keeper)(suite.Ctx) + suite.Require().False(stop, "invariant should not be broken") + suite.Require().Empty(res, "unexpected invariant message: %s", res) + } +} + +func FuzzBurnCoins(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(burnIntegrationTestSuite) + suite.SetT(t) + suite.SetS(suite) + suite.SetupTest() + + burnCount := int64(10) + + // Has both mint & burn permissions + moduleName := ibctransfertypes.ModuleName + recipientAddr := suite.AccountKeeper.GetModuleAddress(moduleName) + + // Start balance + err := suite.Keeper.MintCoins( + suite.Ctx, + moduleName, + cs(ci(types.ExtendedCoinDenom, sdkmath.NewInt(amount).MulRaw(burnCount))), + ) + suite.Require().NoError(err) + + // Burn multiple times to ensure different balance scenarios + for i := int64(0); i < burnCount; i++ { + err := suite.Keeper.BurnCoins( + suite.Ctx, + moduleName, + cs(c(types.ExtendedCoinDenom, amount)), + ) + suite.Require().NoError(err) + } + + // Check FULL balances + balAfter := suite.Keeper.GetBalance(suite.Ctx, recipientAddr, types.ExtendedCoinDenom) + + suite.Require().Equalf( + int64(0), + balAfter.Amount.Int64(), + "all coins should be burned, got %d", + balAfter.Amount.Int64(), + ) + + // Run Invariants to ensure remainder is backing all fractions correctly + 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/burn_test.go b/x/precisebank/keeper/burn_test.go new file mode 100644 index 00000000..98cd49bf --- /dev/null +++ b/x/precisebank/keeper/burn_test.go @@ -0,0 +1,161 @@ +package keeper_test + +import ( + "fmt" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/kava-labs/kava/x/precisebank/types" + "github.com/stretchr/testify/require" +) + +// Testing module name for mocked GetModuleAccount() +const burnerModuleName = "burner-module" + +func TestBurnCoins_PanicValidations(t *testing.T) { + // panic tests for invalid inputs + + tests := []struct { + name string + recipientModule string + setupFn func(td testData) + burnAmount 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", + burnerModuleName, + func(td testData) { + td.ak.EXPECT(). + GetModuleAccount(td.ctx, burnerModuleName). + Return(authtypes.NewModuleAccount( + authtypes.NewBaseAccountWithAddress(sdk.AccAddress{1}), + burnerModuleName, + // no burn permission + )). + Once() + }, + cs(c("ukava", 1000)), + fmt.Sprintf("module account %s does not have permissions to burn tokens: unauthorized", burnerModuleName), + }, + { + "has burn permission", + burnerModuleName, + func(td testData) { + td.ak.EXPECT(). + GetModuleAccount(td.ctx, burnerModuleName). + Return(authtypes.NewModuleAccount( + authtypes.NewBaseAccountWithAddress(sdk.AccAddress{1}), + burnerModuleName, + // includes burner permission + authtypes.Burner, + )). + Once() + + // Will call x/bank BurnCoins coins + td.bk.EXPECT(). + BurnCoins(td.ctx, burnerModuleName, cs(c("ukava", 1000))). + Return(nil). + Once() + }, + cs(c("ukava", 1000)), + "", + }, + { + "disallow burning from 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 burned from: 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.BurnCoins(td.ctx, tt.recipientModule, tt.burnAmount) + }) + return + } + + require.NotPanics(t, func() { + // Not testing errors, only panics for this test + _ = td.keeper.BurnCoins(td.ctx, tt.recipientModule, tt.burnAmount) + }) + }) + } +} + +func TestBurnCoins_Errors(t *testing.T) { + // returned errors, not panics + + tests := []struct { + name string + recipientModule string + setupFn func(td testData) + burnAmount sdk.Coins + wantError string + }{ + { + "invalid coins", + burnerModuleName, + func(td testData) { + // Valid module account burner + td.ak.EXPECT(). + GetModuleAccount(td.ctx, burnerModuleName). + Return(authtypes.NewModuleAccount( + authtypes.NewBaseAccountWithAddress(sdk.AccAddress{1}), + burnerModuleName, + // includes burner permission + authtypes.Burner, + )). + 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.BurnCoins(td.ctx, tt.recipientModule, tt.burnAmount) + + if tt.wantError != "" { + require.Error(t, err) + require.EqualError(t, err, tt.wantError) + return + } + + require.NoError(t, err) + }) + }) + } +} diff --git a/x/precisebank/keeper/keeper.go b/x/precisebank/keeper/keeper.go index bf35f94c..bb2e6c93 100644 --- a/x/precisebank/keeper/keeper.go +++ b/x/precisebank/keeper/keeper.go @@ -3,7 +3,6 @@ package keeper 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" @@ -36,7 +35,3 @@ func NewKeeper( ak: ak, } } - -func (k Keeper) BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error { - panic("unimplemented") -} diff --git a/x/precisebank/types/expected_keepers.go b/x/precisebank/types/expected_keepers.go index ffcad7a6..0a9e3327 100644 --- a/x/precisebank/types/expected_keepers.go +++ b/x/precisebank/types/expected_keepers.go @@ -24,6 +24,7 @@ type BankKeeper interface { SendCoins(ctx sdk.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error + SendCoinsFromModuleToModule(ctx sdk.Context, senderModule string, recipientModule string, amt sdk.Coins) error MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error diff --git a/x/precisebank/types/mocks/MockBankKeeper.go b/x/precisebank/types/mocks/MockBankKeeper.go index b52140a8..41233d8b 100644 --- a/x/precisebank/types/mocks/MockBankKeeper.go +++ b/x/precisebank/types/mocks/MockBankKeeper.go @@ -466,6 +466,55 @@ func (_c *MockBankKeeper_SendCoinsFromModuleToAccount_Call) RunAndReturn(run fun return _c } +// SendCoinsFromModuleToModule provides a mock function with given fields: ctx, senderModule, recipientModule, amt +func (_m *MockBankKeeper) SendCoinsFromModuleToModule(ctx types.Context, senderModule string, recipientModule string, amt types.Coins) error { + ret := _m.Called(ctx, senderModule, recipientModule, amt) + + if len(ret) == 0 { + panic("no return value specified for SendCoinsFromModuleToModule") + } + + var r0 error + if rf, ok := ret.Get(0).(func(types.Context, string, string, types.Coins) error); ok { + r0 = rf(ctx, senderModule, recipientModule, amt) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBankKeeper_SendCoinsFromModuleToModule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendCoinsFromModuleToModule' +type MockBankKeeper_SendCoinsFromModuleToModule_Call struct { + *mock.Call +} + +// SendCoinsFromModuleToModule is a helper method to define mock.On call +// - ctx types.Context +// - senderModule string +// - recipientModule string +// - amt types.Coins +func (_e *MockBankKeeper_Expecter) SendCoinsFromModuleToModule(ctx interface{}, senderModule interface{}, recipientModule interface{}, amt interface{}) *MockBankKeeper_SendCoinsFromModuleToModule_Call { + return &MockBankKeeper_SendCoinsFromModuleToModule_Call{Call: _e.mock.On("SendCoinsFromModuleToModule", ctx, senderModule, recipientModule, amt)} +} + +func (_c *MockBankKeeper_SendCoinsFromModuleToModule_Call) Run(run func(ctx types.Context, senderModule string, recipientModule string, amt types.Coins)) *MockBankKeeper_SendCoinsFromModuleToModule_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(types.Context), args[1].(string), args[2].(string), args[3].(types.Coins)) + }) + return _c +} + +func (_c *MockBankKeeper_SendCoinsFromModuleToModule_Call) Return(_a0 error) *MockBankKeeper_SendCoinsFromModuleToModule_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBankKeeper_SendCoinsFromModuleToModule_Call) RunAndReturn(run func(types.Context, string, string, types.Coins) error) *MockBankKeeper_SendCoinsFromModuleToModule_Call { + _c.Call.Return(run) + return _c +} + // SpendableCoins provides a mock function with given fields: ctx, addr func (_m *MockBankKeeper) SpendableCoins(ctx types.Context, addr types.AccAddress) types.Coins { ret := _m.Called(ctx, addr)