0g-chain/x/precisebank/keeper/mint_test.go
drklee3 1743cf5275
fix(x/precisebank): Ensure exact reserve balance on integer carry when minting (#1932)
Fix reserve minting an extra coin when the recipient module both carries fractional over to integer balance AND remainder is insufficient. Adjusts fractional carry to simply send from reserve, instead of doing an additional mint. Add invariant to ensure reserve matches exactly with fractional balances + remainder, failing on both insufficient and excess funds.
2024-06-20 15:20:13 -07:00

367 lines
9.6 KiB
Go

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 reserve minting when fractional amounts
// are minted & remainder is insufficient
mintFractionalAmount := extCoins.Amount.Mod(types.ConversionFactor())
currentRemainder := td.keeper.GetRemainderAmount(td.ctx)
causesIntegerCarry := fBal.Add(mintFractionalAmount).GTE(types.ConversionFactor())
remainderEnough := currentRemainder.GTE(mintFractionalAmount)
// Optimization: Carry & insufficient remainder is directly minted
if causesIntegerCarry && !remainderEnough {
extCoins = extCoins.AddAmount(types.ConversionFactor())
}
// ----------------------------------------
// 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
mintIntegerAmount := extCoins.Amount.Quo(types.ConversionFactor())
// Minted coins does NOT include roll-over, simply excludes
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()
}
}
if causesIntegerCarry && remainderEnough {
td.bk.EXPECT().
SendCoinsFromModuleToModule(
td.ctx,
types.ModuleName,
minttypes.ModuleName,
cs(c(types.IntegerCoinDenom, 1)),
).
Return(nil).
Once()
}
if !remainderEnough && !causesIntegerCarry {
reserveMintCoins := cs(c(types.IntegerCoinDenom, 1))
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)
})
}
}