Skip to content
4 changes: 4 additions & 0 deletions .changelog/unreleased/improvements/224-aum-fee-dust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
* Accumulate the uncollected AUM fee remainder across reconciliation periods so frequent, short accrual windows no longer discard protocol revenue to truncation [#224](https://github.com/provlabs/vault/issues/224).
* Carry the underlying that floors to zero payment-denom units forward in `fee_remainder` instead of dropping it, so a coarse payment denom no longer loses whole underlying units between collections [#224](https://github.com/provlabs/vault/issues/224).
* Fold the carried fee remainder into the non-mutating valuation path so share NAV, mint, and redeem calculations recognize the same whole-unit fee liability the collection path will realize [#224](https://github.com/provlabs/vault/issues/224).
* Validate that a vault's persisted fee remainder is a non-negative decimal, allowing it to exceed one whole underlying unit when the payment denom is coarser than the underlying asset [#224](https://github.com/provlabs/vault/issues/224).
219 changes: 152 additions & 67 deletions api/provlabs/vault/v1/vault.pulsar.go

Large diffs are not rendered by default.

36 changes: 25 additions & 11 deletions interest/interest.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,40 +79,54 @@ func CalculateInterestEarned(principal sdk.Coin, rate string, periodSeconds int6
return interestAmountDec.TruncateInt(), nil
}

// CalculateAUMFee computes the technology fee based on the vault's AUM and configured basis points.
// CalculateAUMFeeDec computes the technology fee based on the vault's AUM and configured
// basis points, returning the precise decimal result without truncation.
//
// Formula:
//
// Fee = (AUM * (bips / 10000) * duration) / 31536000 (SecondsPerYear)
//
// Returns the fee as a truncated sdkmath.Int.
func CalculateAUMFee(aum sdkmath.Int, bips uint32, duration int64) (fee sdkmath.Int, err error) {
// Callers that collect the fee should carry the fractional part across periods (see the
// vault's fee_remainder) so repeated truncation does not discard protocol revenue.
func CalculateAUMFeeDec(aum sdkmath.Int, bips uint32, duration int64) (fee sdkmath.LegacyDec, err error) {
defer func() {
if rec := recover(); rec != nil {
fee = sdkmath.Int{}
fee = sdkmath.LegacyDec{}
err = fmt.Errorf("aum fee calculation overflow (aum=%s, bips=%d, duration=%d): %v", aum, bips, duration, rec)
}
}()

if aum.IsNegative() {
return sdkmath.Int{}, errors.New("aum cannot be negative")
return sdkmath.LegacyDec{}, errors.New("aum cannot be negative")
}
if duration < 0 {
return sdkmath.Int{}, errors.New("duration cannot be negative")
return sdkmath.LegacyDec{}, errors.New("duration cannot be negative")
}
if duration == 0 || aum.IsZero() || bips == 0 {
return sdkmath.ZeroInt(), nil
return sdkmath.LegacyZeroDec(), nil
}

rate := sdkmath.LegacyNewDec(int64(bips)).Quo(sdkmath.LegacyNewDec(10_000))

// Fee = (AUM * rate * duration) / SecondsPerYear
aumDec := sdkmath.LegacyNewDecFromInt(aum)

durationDec := sdkmath.LegacyNewDec(duration)
yearDec := sdkmath.LegacyNewDec(SecondsPerYear)

feeDec := aumDec.Mul(rate).Mul(durationDec).Quo(yearDec)
return aumDec.Mul(rate).Mul(durationDec).Quo(yearDec), nil
}

// CalculateAUMFee computes the technology fee based on the vault's AUM and configured basis points.
//
// Formula:
//
// Fee = (AUM * (bips / 10000) * duration) / 31536000 (SecondsPerYear)
//
// Returns the fee as a truncated sdkmath.Int. Use CalculateAUMFeeDec when the fractional
// part must be preserved across reconciliation periods.
func CalculateAUMFee(aum sdkmath.Int, bips uint32, duration int64) (sdkmath.Int, error) {
feeDec, err := CalculateAUMFeeDec(aum, bips, duration)
if err != nil {
return sdkmath.Int{}, err
}
return feeDec.TruncateInt(), nil
}

Expand Down
120 changes: 120 additions & 0 deletions interest/interest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,126 @@ func TestCalculateAUMFee(t *testing.T) {
}
}

func TestCalculateAUMFeeDec(t *testing.T) {
tests := []struct {
name string
aum sdkmath.Int
bips uint32
duration int64
expectedFee sdkmath.LegacyDec
expectErr bool
expectedErrContains string
}{
{
name: "zero AUM",
aum: sdkmath.ZeroInt(),
bips: 15,
duration: interest.SecondsPerYear,
expectedFee: sdkmath.LegacyZeroDec(),
},
{
name: "zero duration",
aum: sdkmath.NewInt(1_000_000),
bips: 15,
duration: 0,
expectedFee: sdkmath.LegacyZeroDec(),
},
{
name: "zero bips",
aum: sdkmath.NewInt(1_000_000),
bips: 0,
duration: interest.SecondsPerYear,
expectedFee: sdkmath.LegacyZeroDec(),
},
{
name: "1 year at 15 bps (1,000,000 AUM)",
aum: sdkmath.NewInt(1_000_000),
bips: 15,
duration: interest.SecondsPerYear,
expectedFee: sdkmath.LegacyNewDec(1_500),
},
{
name: "sub-unit fee is preserved as a fraction, not truncated to zero",
aum: sdkmath.NewInt(100),
bips: 15,
duration: interest.SecondsPerYear,
expectedFee: sdkmath.LegacyMustNewDecFromStr("0.15"),
},
{
name: "negative duration errors",
aum: sdkmath.NewInt(1_000_000),
bips: 15,
duration: -1,
expectErr: true,
expectedErrContains: "duration cannot be negative",
},
{
name: "negative aum errors",
aum: sdkmath.NewInt(-1_000_000),
bips: 15,
duration: interest.SecondsPerYear,
expectErr: true,
expectedErrContains: "aum cannot be negative",
},
{
name: "near-max AUM over a one year period overflows the decimal multiply",
aum: sdkmath.NewIntFromBigInt(new(big.Int).Lsh(big.NewInt(1), 255)),
bips: 10_000,
duration: interest.SecondsPerYear,
expectErr: true,
expectedErrContains: "overflow",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fee, err := interest.CalculateAUMFeeDec(tc.aum, tc.bips, tc.duration)
if tc.expectErr {
require.Errorf(t, err, "test case %q: expected an error but got none", tc.name)
require.Containsf(t, err.Error(), tc.expectedErrContains, "test case %q: error %q should contain %q", tc.name, err, tc.expectedErrContains)
require.Truef(t, fee.IsNil(), "test case %q: fee should be the zero-value LegacyDec on error, got %s", tc.name, fee)
} else {
require.NoErrorf(t, err, "test case %q: unexpected error during AUM fee decimal calculation", tc.name)
require.Truef(t, tc.expectedFee.Equal(fee), "test case %q: fee amount mismatch; expected %s, got %s", tc.name, tc.expectedFee, fee)
}
})
}
}

func TestCumulativeAUMFeeTruncationLoss(t *testing.T) {
aum := sdkmath.NewInt(100_000_000)
bips := uint32(50)
blockTime := int64(6)
iterations := 10_000

sumIterativeInt := sdkmath.ZeroInt()
sumIterativeDec := sdkmath.LegacyZeroDec()
for i := range iterations {
feeInt, err := interest.CalculateAUMFee(aum, bips, blockTime)
require.NoErrorf(t, err, "iteration %d: truncated fee calculation failed", i)
sumIterativeInt = sumIterativeInt.Add(feeInt)

feeDec, err := interest.CalculateAUMFeeDec(aum, bips, blockTime)
require.NoErrorf(t, err, "iteration %d: decimal fee calculation failed", i)
sumIterativeDec = sumIterativeDec.Add(feeDec)
}

totalTime := blockTime * int64(iterations)
bulkDec, err := interest.CalculateAUMFeeDec(aum, bips, totalTime)
require.NoError(t, err, "bulk decimal fee calculation failed")

require.Truef(t, sumIterativeInt.IsZero(),
"per-period integer truncation should collect nothing over short windows, got %s", sumIterativeInt)
require.Truef(t, bulkDec.TruncateInt().IsPositive(),
"bulk calculation should accrue a positive whole fee, got %s", bulkDec)
require.Truef(t, sumIterativeDec.GT(sumIterativeInt.ToLegacyDec()),
"decimal accumulation should retain the fee that integer truncation discards (dec=%s, int=%s)", sumIterativeDec, sumIterativeInt)

drift := bulkDec.Sub(sumIterativeDec).Abs()
require.Truef(t, drift.LT(sdkmath.LegacyMustNewDecFromStr("0.000001")),
"decimal accumulation should match the bulk calculation within rounding, drift=%s", drift)
}

func TestCalculateExpiration(t *testing.T) {
startTime := int64(1752764321)
denom := "vault"
Expand Down
30 changes: 30 additions & 0 deletions keeper/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,36 @@ func (k Keeper) MigrateVaultAccountPaymentDenomDefaults(ctx sdk.Context) error {
return nil
}

// migrateVaultFeeRemainderDefaults normalizes the FeeRemainder field on legacy
// VaultAccount state created before the field existed.
//
// The AUM fee dust accumulator (see https://github.com/ProvLabs/vault/issues/224)
// carries the sub-unit fractional fee in FeeRemainder across reconciliation periods.
// Vaults persisted before this field existed decode it as an empty string. Runtime
// code already treats empty as zero, so this migration is a cosmetic normalization
// that gives every vault an explicit, parseable value.
//
// It runs as part of the Migrator.Migrate1to2 handler and is idempotent; vaults
// that already carry a value are left unchanged.
func (k Keeper) migrateVaultFeeRemainderDefaults(ctx sdk.Context) error {
zero := math.LegacyZeroDec().String()

for _, acc := range k.AuthKeeper.GetAllAccounts(ctx) {
v, ok := acc.(*types.VaultAccount)
if !ok {
continue
}
if v.FeeRemainder == "" {
v.FeeRemainder = zero
if err := k.SetVaultAccount(ctx, v); err != nil {
return fmt.Errorf("failed to update vault account %s during fee remainder migration: %w", v.Address, err)
}
}
}

return nil
}

// migrateInternalNAVSeedFromMarker seeds the Internal NAV table for every vault
// that will need an Internal NAV entry once the pricing engine switches to
// Internal-NAV-only reads.
Expand Down
94 changes: 94 additions & 0 deletions keeper/migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,100 @@ func (s *TestSuite) TestKeeper_MigrateVaultAccountPaymentDenomDefaults() {
s.Require().Equal("uylds.fcc", gotVaultA2.PaymentDenom, "vault A PaymentDenom should remain set to UnderlyingAsset after second migration run")
}

func (s *TestSuite) TestKeeper_MigrateVaultFeeRemainderDefaults() {
createVault := func(shareDenom, denom, feeRemainder string) sdk.AccAddress {
addr := types.GetVaultAddress(shareDenom)
vault := &types.VaultAccount{
BaseAccount: authtypes.NewBaseAccountWithAddress(addr),
Admin: s.adminAddr.String(),
TotalShares: sdk.NewCoin(shareDenom, sdkmath.ZeroInt()),
UnderlyingAsset: denom,
PaymentDenom: denom,
CurrentInterestRate: types.ZeroInterestRate,
DesiredInterestRate: types.ZeroInterestRate,
FeeRemainder: feeRemainder,
}
acct := s.simApp.AccountKeeper.NewAccount(s.ctx, vault)
vaultAcct, ok := acct.(*types.VaultAccount)
s.Require().True(ok, "new account should return a *types.VaultAccount for a VaultAccount prototype")
s.simApp.AccountKeeper.SetAccount(s.ctx, vaultAcct)
return addr
}

getVault := func(addr sdk.AccAddress) *types.VaultAccount {
acc := s.simApp.AccountKeeper.GetAccount(s.ctx, addr)
s.Require().NotNil(acc, "vault account %s should exist", addr)
v, ok := acc.(*types.VaultAccount)
s.Require().True(ok, "account %s should be a VaultAccount", addr)
return v
}

zero := sdkmath.LegacyZeroDec().String()

tests := []struct {
name string
shareDenom string
denom string
initialRemainder string
expectedRemainder string
}{
{
name: "empty remainder is normalized to explicit zero",
shareDenom: "feeremshareEmpty",
denom: "uatom",
initialRemainder: "",
expectedRemainder: zero,
},
{
name: "existing remainder is preserved",
shareDenom: "feeremshareSet",
denom: "uusdc",
initialRemainder: "0.250000000000000000",
expectedRemainder: "0.250000000000000000",
},
}

for _, tc := range tests {
s.Run(tc.name, func() {
s.SetupTest()
addr := createVault(tc.shareDenom, tc.denom, tc.initialRemainder)

err := keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx)
s.Require().NoError(err, "migration should not return an error for case %s", tc.name)

s.Require().Equal(tc.expectedRemainder, getVault(addr).FeeRemainder,
"fee remainder mismatch after migration for case %s", tc.name)
})
}

s.Run("non-vault accounts are left untouched", func() {
s.SetupTest()
nonVaultAddr := sdk.AccAddress([]byte("non-vault-account-addr____"))
s.simApp.AccountKeeper.SetAccount(s.ctx, s.simApp.AccountKeeper.NewAccountWithAddress(s.ctx, nonVaultAddr))

err := keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx)
s.Require().NoError(err, "migration should not error when a non-vault account exists")

got := s.simApp.AccountKeeper.GetAccount(s.ctx, nonVaultAddr)
s.Require().NotNil(got, "non-vault account should still exist after migration")
_, ok := got.(*types.VaultAccount)
s.Require().False(ok, "non-vault account should not be converted to a VaultAccount during migration")
})

s.Run("migration is idempotent across consecutive runs", func() {
s.SetupTest()
emptyAddr := createVault("feeremshareIdempotentEmpty", "uatom", "")
existingAddr := createVault("feeremshareIdempotentSet", "uusdc", "0.250000000000000000")

migrator := keeper.NewMigrator(s.simApp.VaultKeeper)
s.Require().NoError(migrator.Migrate1to2(s.ctx), "first migration run should not error")
s.Require().NoError(migrator.Migrate1to2(s.ctx), "second migration run should be idempotent and not error")

s.Require().Equal(zero, getVault(emptyAddr).FeeRemainder, "normalized fee remainder should remain zero after a second migration run")
s.Require().Equal("0.250000000000000000", getVault(existingAddr).FeeRemainder, "existing fee remainder should remain unchanged after a second migration run")
})
}

// TestKeeper_MigrateInternalNAVSeedFromMarker exercises the migration that seeds
// the Internal NAV table from Marker NAVs for vaults whose payment_denom differs
// from underlying_asset. Each subtest sets up a fresh fleet so cross-test state
Expand Down
14 changes: 9 additions & 5 deletions keeper/migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ func NewMigrator(k *Keeper) Migrator {
return Migrator{keeper: k}
}

// Migrate1to2 advances the vault module from ConsensusVersion 1 to 2 by
// seeding the Internal NAV table from Marker NAVs and defaulting nav_authority
// to the vault admin when unset. The work is delegated to the unexported
// migrateInternalNAVSeedFromMarker, which is idempotent across retries.
// Migrate1to2 advances the vault module from ConsensusVersion 1 to 2 by seeding
// the Internal NAV table from Marker NAVs, defaulting nav_authority to the vault
// admin when unset, and normalizing the FeeRemainder field on legacy VaultAccount
// state to an explicit zero. The work is delegated to unexported helpers, each of
// which is idempotent across retries.
func (m Migrator) Migrate1to2(ctx sdk.Context) error {
return m.keeper.migrateInternalNAVSeedFromMarker(ctx)
if err := m.keeper.migrateInternalNAVSeedFromMarker(ctx); err != nil {
return err
}
return m.keeper.migrateVaultFeeRemainderDefaults(ctx)
}
Loading
Loading