mirror of
https://github.com/0glabs/0g-chain.git
synced 2024-12-24 15:25:18 +00:00
feat(x/precisebank): Implement BurnCoins (#1934)
Implement & test BurnCoins method
This commit is contained in:
parent
af5eea690b
commit
38230d35e3
1
Makefile
1
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
|
||||
|
169
x/precisebank/keeper/burn.go
Normal file
169
x/precisebank/keeper/burn.go
Normal file
@ -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
|
||||
}
|
485
x/precisebank/keeper/burn_integration_test.go
Normal file
485
x/precisebank/keeper/burn_integration_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
161
x/precisebank/keeper/burn_test.go
Normal file
161
x/precisebank/keeper/burn_test.go
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user