0g-chain/x/precisebank/keeper/burn_integration_test.go
drklee3 9de9de671e
feat(x/precisebank): Display 0 reserve balance to module consumers (#1958)
Module reserve represents fractional balances, so it should be hidden to consumers to not have a misleading total balance that doubles the fractional balances. This modifies GetBalance() and SpendableCoin() to always return zero coins when fetching the reserve address balance for fractional amounts.
2024-07-10 11:14:17 -07:00

509 lines
14 KiB
Go

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"
banktypes "github.com/cosmos/cosmos-sdk/x/bank/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)
intCoinAmt := tt.burnCoins.AmountOf(types.IntegerCoinDenom).
Mul(types.ConversionFactor())
fraCoinAmt := tt.burnCoins.AmountOf(types.ExtendedCoinDenom)
totalExtCoinAmt := intCoinAmt.Add(fraCoinAmt)
events := suite.Ctx.EventManager().Events()
extendedEvent := banktypes.NewCoinBurnEvent(
recipientAddr,
sdk.NewCoins(sdk.NewCoin(
types.ExtendedCoinDenom,
totalExtCoinAmt,
)),
)
if totalExtCoinAmt.IsZero() {
suite.Require().NotContains(events, extendedEvent)
} else {
suite.Require().Contains(events, extendedEvent)
}
})
}
}
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.BankKeeper.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.BankKeeper.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.BankKeeper.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.BankKeeper.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)
})
}