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.
This commit is contained in:
drklee3 2024-06-20 15:20:13 -07:00 committed by GitHub
parent 9aef8e4971
commit 1743cf5275
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 326 additions and 35 deletions

View File

@ -14,6 +14,7 @@ func RegisterInvariants(
k Keeper,
bk types.BankKeeper,
) {
ir.RegisterRoute(types.ModuleName, "reserve-backs-fractions", ReserveBacksFractionsInvariant(k))
ir.RegisterRoute(types.ModuleName, "balance-remainder-total", BalancedFractionalTotalInvariant(k))
ir.RegisterRoute(types.ModuleName, "valid-fractional-balances", ValidFractionalAmountsInvariant(k))
ir.RegisterRoute(types.ModuleName, "valid-remainder-amount", ValidRemainderAmountInvariant(k))
@ -23,7 +24,12 @@ func RegisterInvariants(
// AllInvariants runs all invariants of the X/precisebank module.
func AllInvariants(k Keeper) sdk.Invariant {
return func(ctx sdk.Context) (string, bool) {
res, stop := BalancedFractionalTotalInvariant(k)(ctx)
res, stop := ReserveBacksFractionsInvariant(k)(ctx)
if stop {
return res, stop
}
res, stop = BalancedFractionalTotalInvariant(k)(ctx)
if stop {
return res, stop
}
@ -47,6 +53,47 @@ func AllInvariants(k Keeper) sdk.Invariant {
}
}
// ReserveBacksFractionsInvariant checks that the total amount of backing
// coins in the reserve is equal to the total amount of fractional balances,
// such that the backing is always available to redeem all fractional balances
// and there are no extra coins in the reserve that are not backing any
// fractional balances.
func ReserveBacksFractionsInvariant(k Keeper) sdk.Invariant {
return func(ctx sdk.Context) (string, bool) {
var (
msg string
broken bool
)
fractionalBalSum := k.GetTotalSumFractionalBalances(ctx)
remainderAmount := k.GetRemainderAmount(ctx)
// Get the total amount of backing coins in the reserve
moduleAddr := k.ak.GetModuleAddress(types.ModuleName)
reserveIntegerBalance := k.bk.GetBalance(ctx, moduleAddr, types.IntegerCoinDenom)
reserveExtendedBalance := reserveIntegerBalance.Amount.Mul(types.ConversionFactor())
// The total amount of backing coins in the reserve should be equal to
// fractional balances + remainder amount
totalRequiredBacking := fractionalBalSum.Add(remainderAmount)
broken = !reserveExtendedBalance.Equal(totalRequiredBacking)
msg = fmt.Sprintf(
"%s reserve balance %s mismatches %s (fractional balances %s + remainder %s)\n",
types.ExtendedCoinDenom,
reserveExtendedBalance,
totalRequiredBacking,
fractionalBalSum,
remainderAmount,
)
return sdk.FormatInvariant(
types.ModuleName, "module reserve backing total fractional balances",
msg,
), broken
}
}
// ValidFractionalAmountsInvariant checks that all individual fractional
// balances are valid.
func ValidFractionalAmountsInvariant(k Keeper) sdk.Invariant {

View File

@ -0,0 +1,129 @@
package keeper_test
import (
"testing"
sdkmath "cosmossdk.io/math"
sdk "github.com/cosmos/cosmos-sdk/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 invariantsIntegrationTestSuite struct {
testutil.Suite
}
func (suite *invariantsIntegrationTestSuite) SetupTest() {
suite.Suite.SetupTest()
}
func TestInvariantsIntegrationTest(t *testing.T) {
suite.Run(t, new(invariantsIntegrationTestSuite))
}
func (suite *invariantsIntegrationTestSuite) FundReserve(amt sdkmath.Int) {
coins := sdk.NewCoins(sdk.NewCoin(types.IntegerCoinDenom, amt))
err := suite.BankKeeper.MintCoins(suite.Ctx, types.ModuleName, coins)
suite.Require().NoError(err)
}
func (suite *invariantsIntegrationTestSuite) TestReserveBackingFractionalInvariant() {
tests := []struct {
name string
setupFn func(ctx sdk.Context, k keeper.Keeper)
wantBroken bool
wantMsg string
}{
{
"valid - empty state",
func(_ sdk.Context, _ keeper.Keeper) {},
false,
"",
},
{
"valid - fractional balances, no remainder",
func(ctx sdk.Context, k keeper.Keeper) {
k.SetFractionalBalance(ctx, sdk.AccAddress{1}, types.ConversionFactor().QuoRaw(2))
k.SetFractionalBalance(ctx, sdk.AccAddress{2}, types.ConversionFactor().QuoRaw(2))
// 1 integer backs same amount fractional
suite.FundReserve(sdk.NewInt(1))
},
false,
"",
},
{
"valid - fractional balances, with remainder",
func(ctx sdk.Context, k keeper.Keeper) {
k.SetFractionalBalance(ctx, sdk.AccAddress{1}, types.ConversionFactor().QuoRaw(2))
k.SetRemainderAmount(ctx, types.ConversionFactor().QuoRaw(2))
// 1 integer backs same amount fractional including remainder
suite.FundReserve(sdk.NewInt(1))
},
false,
"",
},
{
"invalid - no fractional balances, non-zero remainder",
func(ctx sdk.Context, k keeper.Keeper) {
k.SetRemainderAmount(ctx, types.ConversionFactor().QuoRaw(2))
},
true,
"precisebank: module reserve backing total fractional balances invariant\nakava reserve balance 0 mismatches 500000000000 (fractional balances 0 + remainder 500000000000)\n\n",
},
{
"invalid - insufficient reserve backing",
func(ctx sdk.Context, k keeper.Keeper) {
amt := types.ConversionFactor().QuoRaw(2)
// 0.5 int coins x 4
k.SetFractionalBalance(ctx, sdk.AccAddress{1}, amt)
k.SetFractionalBalance(ctx, sdk.AccAddress{2}, amt)
k.SetFractionalBalance(ctx, sdk.AccAddress{3}, amt)
k.SetRemainderAmount(ctx, amt)
// Needs 2 to back 0.5 x 4
suite.FundReserve(sdk.NewInt(1))
},
true,
"precisebank: module reserve backing total fractional balances invariant\nakava reserve balance 1000000000000 mismatches 2000000000000 (fractional balances 1500000000000 + remainder 500000000000)\n\n",
},
{
"invalid - excess reserve backing",
func(ctx sdk.Context, k keeper.Keeper) {
amt := types.ConversionFactor().QuoRaw(2)
// 0.5 int coins x 4
k.SetFractionalBalance(ctx, sdk.AccAddress{1}, amt)
k.SetFractionalBalance(ctx, sdk.AccAddress{2}, amt)
k.SetFractionalBalance(ctx, sdk.AccAddress{3}, amt)
k.SetRemainderAmount(ctx, amt)
// Needs 2 to back 0.5 x 4
suite.FundReserve(sdk.NewInt(3))
},
true,
"precisebank: module reserve backing total fractional balances invariant\nakava reserve balance 3000000000000 mismatches 2000000000000 (fractional balances 1500000000000 + remainder 500000000000)\n\n",
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
// Reset each time
suite.SetupTest()
tt.setupFn(suite.Ctx, suite.Keeper)
invariantFn := keeper.ReserveBacksFractionsInvariant(suite.Keeper)
msg, broken := invariantFn(suite.Ctx)
if tt.wantBroken {
suite.Require().True(broken, "invariant should be broken but is not")
suite.Require().Equal(tt.wantMsg, msg)
} else {
suite.Require().Falsef(broken, "invariant should not be broken but is: %s", msg)
}
})
}
}

View File

@ -63,13 +63,28 @@ func (k Keeper) MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) err
return k.mintExtendedCoin(ctx, moduleName, extendedAmount)
}
// mintExtendedCoin manages the minting of extended coins, and no other coins.
// mintExtendedCoin manages the minting of only extended coins. This also
// handles integer carry over from fractional balance to integer balance if
// necessary depending on the fractional balance and minting amount. Ensures
// that the reserve fully backs the additional minted amount, minting any extra
// reserve integer coins if necessary.
// 4 Cases:
// 1. NO integer carry over, >= 0 remainder - no reserve mint
// 2. NO integer carry over, negative remainder - mint 1 to reserve
// 3. Integer carry over, >= 0 remainder
// - Transfer 1 integer from reserve -> account
//
// 4. Integer carry over, negative remainder
// - Transfer 1 integer from reserve -> account
// - Mint 1 to reserve
// Optimization:
// - Increase direct account mint amount by 1, no extra reserve mint
func (k Keeper) mintExtendedCoin(
ctx sdk.Context,
moduleName string,
recipientModuleName string,
amt sdkmath.Int,
) error {
moduleAddr := k.ak.GetModuleAddress(moduleName)
moduleAddr := k.ak.GetModuleAddress(recipientModuleName)
// Get current module account fractional balance - 0 if not found
fractionalAmount := k.GetFractionalBalance(ctx, moduleAddr)
@ -78,19 +93,57 @@ func (k Keeper) mintExtendedCoin(
integerMintAmount := amt.Quo(types.ConversionFactor())
fractionalMintAmount := amt.Mod(types.ConversionFactor())
// Get previous remainder amount, as we need to it before carry calculation
// for the optimization path.
prevRemainder := k.GetRemainderAmount(ctx)
// Deduct new remainder with minted fractional amount. This will result in
// two cases:
// 1. Zero or positive remainder: remainder is sufficient to back the minted
// fractional amount. Reserve is also sufficient to back the minted amount
// so no additional reserve integer coin is needed.
// 2. Negative remainder: remainder is insufficient to back the minted
// fractional amount. Reserve will need to be increased to back the mint
// amount.
newRemainder := prevRemainder.Sub(fractionalMintAmount)
// 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:
// Case #3 - Integer carry, remainder is sufficient (0 or positive)
if newFractionalBalance.GTE(types.ConversionFactor()) && newRemainder.GTE(sdkmath.ZeroInt()) {
// Carry should send from reserve -> account, instead of minting an
// extra integer coin. Otherwise doing an extra mint will require a burn
// from reserves to maintain exact backing.
carryCoin := sdk.NewCoin(types.IntegerCoinDenom, sdkmath.OneInt())
// SendCoinsFromModuleToModule allows for sending coins even if the
// recipient module account is blocked.
if err := k.bk.SendCoinsFromModuleToModule(
ctx,
types.ModuleName,
recipientModuleName,
sdk.NewCoins(carryCoin),
); err != nil {
return err
}
}
// Case #4 - Integer carry, remainder is insufficient
// This is the optimization path where the integer mint amount is increased
// by 1, instead of doing both a reserve -> account transfer and reserve mint.
if newFractionalBalance.GTE(types.ConversionFactor()) && newRemainder.IsNegative() {
integerMintAmount = integerMintAmount.AddRaw(1)
}
// If it carries over, adjust the fractional balance to account for the
// previously added 1 integer amount.
// 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())
@ -103,7 +156,7 @@ func (k Keeper) mintExtendedCoin(
if err := k.bk.MintCoins(
ctx,
moduleName,
recipientModuleName,
sdk.NewCoins(integerMintCoin),
); err != nil {
return err
@ -115,20 +168,28 @@ func (k Keeper) mintExtendedCoin(
// ----------------------------------------
// 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
// Mint an additional reserve integer coin if remainder is insufficient.
// The remainder is the amount of fractional coins that can be minted and
// still be fully backed by reserve. If the remainder is less than the
// minted fractional amount, then the reserve needs to be increased to
// back the additional fractional amount.
// Optimization: This is only done when the integer amount does NOT carry,
// as a direct account mint is done instead of integer carry transfer +
// insufficient remainder reserve mint.
wasCarried := fractionalAmount.Add(fractionalMintAmount).GTE(types.ConversionFactor())
if prevRemainder.LT(fractionalMintAmount) && !wasCarried {
// Always only 1 integer coin, as fractionalMintAmount < ConversionFactor
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 will be negative if prevRemainder < fractionalMintAmount.
// This needs to be adjusted back to the corresponding positive value. The
// remainder will be always < conversionFactor after add if it is negative.
if newRemainder.IsNegative() {
newRemainder = newRemainder.Add(types.ConversionFactor())
}

View File

@ -177,6 +177,39 @@ func (suite *mintIntegrationTestSuite) TestMintCoins() {
},
},
},
{
"fractional only with carry",
minttypes.ModuleName,
[]mintTest{
{
// Start with (1/4 * 3) = 0.75
mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().QuoRaw(4).MulRaw(3))),
wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().QuoRaw(4).MulRaw(3))),
},
{
// Add another 0.50 to incur carry to test reserve on carry
mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().QuoRaw(2))),
wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().QuoRaw(4).MulRaw(5))),
},
},
},
{
"fractional only, resulting in exact carry and 0 remainder",
minttypes.ModuleName,
[]mintTest{
// mint 0.5, acc = 0.5, reserve = 1
{
mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().QuoRaw(2))),
wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().QuoRaw(2))),
},
// mint another 0.5, acc = 1, reserve = 0
// Reserve actually goes down by 1 for integer carry
{
mintAmount: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor().QuoRaw(2))),
wantBalance: cs(ci(types.ExtendedCoinDenom, types.ConversionFactor())),
},
},
},
{
"exact carry",
minttypes.ModuleName,
@ -299,9 +332,8 @@ func (suite *mintIntegrationTestSuite) TestMintCoins() {
// 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)
msg, stop := allInvariantsFn(suite.Ctx)
suite.Require().Falsef(stop, "invariant should not be broken: %s", msg)
}
})
}
@ -311,6 +343,7 @@ func FuzzMintCoins(f *testing.F) {
f.Add(int64(0))
f.Add(int64(100))
f.Add(types.ConversionFactor().Int64())
f.Add(types.ConversionFactor().QuoRaw(2).Int64())
f.Add(types.ConversionFactor().MulRaw(5).Int64())
f.Add(types.ConversionFactor().MulRaw(2).AddRaw(123948723).Int64())
@ -328,6 +361,8 @@ func FuzzMintCoins(f *testing.F) {
mintCount := int64(10)
suite.T().Logf("minting %d %d times", amount, mintCount)
// Mint 10 times to include mints from non-zero balances
for i := int64(0); i < mintCount; i++ {
err := suite.Keeper.MintCoins(
@ -345,15 +380,15 @@ func FuzzMintCoins(f *testing.F) {
suite.Require().Equalf(
amount*mintCount,
bal.Amount.Int64(),
"unexpected balance after minting %d 5 times",
"unexpected balance after minting %d %d times",
amount,
mintCount,
)
// 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)
res, stop := keeper.AllInvariants(suite.Keeper)(suite.Ctx)
suite.False(stop, "invariant should not be broken")
suite.Empty(res, "unexpected invariant message")
})
}

View File

@ -289,6 +289,20 @@ func TestMintCoins_ExpectedCalls(t *testing.T) {
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() {
@ -298,9 +312,9 @@ func TestMintCoins_ExpectedCalls(t *testing.T) {
Once()
// Initial integer balance is always 0 for this test
totalNewBalance := tt.startFractionalBalance.Add(extCoins.Amount)
mintIntegerAmount := totalNewBalance.Quo(types.ConversionFactor())
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
@ -316,15 +330,20 @@ func TestMintCoins_ExpectedCalls(t *testing.T) {
}
}
// ----------------------------------------
// 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)
if causesIntegerCarry && remainderEnough {
td.bk.EXPECT().
SendCoinsFromModuleToModule(
td.ctx,
types.ModuleName,
minttypes.ModuleName,
cs(c(types.IntegerCoinDenom, 1)),
).
Return(nil).
Once()
}
remainderEnough := currentRemainder.GTE(mintFractionalAmount)
if !remainderEnough {
reserveMintCoins := cs(ci(types.IntegerCoinDenom, sdkmath.OneInt()))
if !remainderEnough && !causesIntegerCarry {
reserveMintCoins := cs(c(types.IntegerCoinDenom, 1))
td.bk.EXPECT().
// Mints to x/precisebank
MintCoins(td.ctx, types.ModuleName, reserveMintCoins).