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:
- State: Add a persistent
FeeRemainder (decimal) field to VaultAccount.
- 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
Related: #123 (interest counterpart).
Sibling to #123, which covers the same truncation-and-reset defect for interest accrual. This issue covers the AUM fee side.
CalculateAUMFeetruncates the accrued fee to an integer coin amount viaTruncateInt()on every reconciliation, andreconcileVaultadvancesFeePeriodStartunconditionally even when the truncated fee is zero. On short accrual windows (frequent, user-triggered reconciliations duringSwapIn/SwapOut), small balances, or low bips, the per-period fee is often less than one whole unit, gets truncated to0, and the fractional remainder is discarded whenFeePeriodStartresets. The result is that the protocol fee receiver collects less than the configured rate.Note:
OutstandingAumFeeonly carries forward whole-unit truncated fees not yet collected from the marker. The sub-unit fractional remainder is dropped insideCalculateAUMFeebefore it ever reachesOutstandingAumFee, 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.Location
File:
vault/interest/interest.goFunction:
CalculateAUMFee(interest.go:89-116, truncation atinterest.go:116)Reset paths:
keeper/reconcile.go:262andkeeper/reconcile.go:292advanceFeePeriodStartunconditionally;keeper/queue.go:91-96resets the fee period on enqueue.Proposed Solution
Mirror the dust-accumulator approach proposed for interest in #123:
FeeRemainder(decimal) field toVaultAccount.FeeRemainderfrom prior state.FeeRemainder.Alternatively, do not advance
FeePeriodStartwhen the calculated fee truncates to zero, letting the fractional portion accumulate over subsequent blocks.Tasks
FeeRemainderfield toVaultAccountproto definition.Related: #123 (interest counterpart).