Skip to content

Implement dust accumulation to prevent AUM fee precision loss #224

Description

@nullpointer0x00

Sibling to #123, which covers the same truncation-and-reset defect for interest accrual. This issue covers the AUM fee side.

CalculateAUMFee truncates the accrued fee to an integer coin amount via TruncateInt() on every reconciliation, and reconcileVault advances FeePeriodStart unconditionally even when the truncated fee is zero. On short accrual windows (frequent, user-triggered reconciliations during SwapIn/SwapOut), small balances, or low bips, the per-period fee is often less than one whole unit, gets truncated to 0, and the fractional remainder is discarded when FeePeriodStart resets. The result is that the protocol fee receiver collects less than the configured rate.

Note: OutstandingAumFee only carries forward whole-unit truncated fees not yet collected from the marker. The sub-unit fractional remainder is dropped inside CalculateAUMFee before it ever reaches OutstandingAumFee, so the accumulator does not prevent this loss.

Reproduction

Mirrors #123's TestCumulativeTruncationLoss: iterating the fee calc over many short windows yields less than a single bulk calc over the equivalent total duration.

func TestCumulativeAUMFeeTruncationLoss(t *testing.T) {
	aum := sdkmath.NewInt(100_000_000) // 100 tokens
	bips := uint32(50)                 // 0.50% annual
	blockTime := int64(6)              // 6 seconds per block
	iterations := 10000                // ~16 hours of blocks

	sumIterative := sdkmath.ZeroInt()
	for i := 0; i < iterations; i++ {
		feeAmt, err := interest.CalculateAUMFee(aum, bips, blockTime)
		require.NoError(t, err)
		sumIterative = sumIterative.Add(feeAmt)
	}

	totalTime := blockTime * int64(iterations)
	sumBulk, err := interest.CalculateAUMFee(aum, bips, totalTime)
	require.NoError(t, err)

	t.Logf("Iterative Sum (Actual):   %s", sumIterative)
	t.Logf("Bulk Sum (Expected):      %s", sumBulk)
	require.True(t, sumBulk.GT(sumIterative), "Iterative fee calculation lost revenue due to truncation")
}

Location

File: vault/interest/interest.go
Function: CalculateAUMFee (interest.go:89-116, truncation at interest.go:116)

Reset paths: keeper/reconcile.go:262 and keeper/reconcile.go:292 advance FeePeriodStart unconditionally; keeper/queue.go:91-96 resets the fee period on enqueue.

Proposed Solution

Mirror the dust-accumulator approach proposed for interest in #123:

  1. State: Add a persistent FeeRemainder (decimal) field to VaultAccount.
  2. Logic: When reconciling the fee:
    • Compute the precise fee (decimal).
    • Add the stored FeeRemainder from prior state.
    • Split into the integer portion (collected) and the fractional portion.
    • Persist the new fractional part as FeeRemainder.

Alternatively, do not advance FeePeriodStart when the calculated fee truncates to zero, letting the fractional portion accumulate over subsequent blocks.

Tasks

  • Add FeeRemainder field to VaultAccount proto definition.
  • Update migration scripts for state changes.
  • Modify the fee reconcile path to carry the decimal remainder across periods before truncation.
  • Add unit tests verifying iterative fee calculation matches the equivalent bulk calculation.

Related: #123 (interest counterpart).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions