Skip to content

FLO-12: Fee Calculation Diverges From Rate Allocation Formula#288

Open
mts1715 wants to merge 10 commits intomainfrom
taras/221-FLO-12-fee-calculation-diverges-from-rate-allocation-formula
Open

FLO-12: Fee Calculation Diverges From Rate Allocation Formula#288
mts1715 wants to merge 10 commits intomainfrom
taras/221-FLO-12-fee-calculation-diverges-from-rate-allocation-formula

Conversation

@mts1715
Copy link
Copy Markdown
Contributor

@mts1715 mts1715 commented Mar 23, 2026

Closes: #221

Description

Protocol fee

Protocol fee income was calculated incorrectly in two ways:

  1. Wrong per-second rate derivation (commit 6000a1a)

The old code computed annual rates first, then converted to per-second:

// OLD — wrong: applies (1 - protocolFeeRate) to the annual rate before the per-second conversion
creditRate = debitRate * (1.0 - protocolFeeRate)
self.currentCreditRate = FlowALPMath.perSecondInterestRate(yearlyRate: creditRate)

This diverges from the correct formula because perSecondInterestRate is non-linear — scaling the annual input is not equivalent to scaling the resulting per-second rate. The fix derives per-second rates symmetrically:

// NEW — correct: scale the per-second excess, not the annual rate
self.currentDebitRate = FlowALPMath.perSecondInterestRate(yearlyRate: debitRate)
let debitRatePerSecond = self.currentDebitRate - 1.0
// FixedCurve:
self.currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate)
// KinkCurve:
self.currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate) * debit / credit
  1. Fee calculation from gross debit income instead of spread income

The old code took insurance/stability as a flat percentage of total debit income (debitIncome * rate). The correct formula is a percentage of the spread — the portion of debit income not returned to depositors:

protocolFeeIncome = debitIncome - creditIncome
insuranceFee = protocolFeeIncome * insuranceRate / (insuranceRate + stabilityFeeRate)
stabilityFee = protocolFeeIncome - insuranceFee

Stability and insurance fee

collectProtocolFees() accumulator (commit e5c3929)

Both fee types are now computed in a single TokenState.collectProtocolFees() method that accumulates into accumulatedInsuranceFeeIncome / accumulatedStabilityFeeIncome. It is called before every balance or rate mutation, so fees always settle at the rate that was in effect when they accrued. A single lastProtocolFeeCollectionTime replaces the two separate timestamps.

_withdrawInsurance and _withdrawStability now simply read and reset the accumulators rather than recomputing the formula.

Tests

  • Shared collection timestamp behavior (one timestamp, not two)
  • FixedCurve at low utilization produces zero protocol fees (correct — credit income exceeds debit income), so tests that require measurable fees switch to KinkCurve(baseRate, slope1=0, slope2=0) which guarantees positive spread at any utilization
  • Expected fee values updated to match the compound-interest formula (the linear approximation stabilityAmount = debitIncome * rate only holds for small rates)

mts1715 added 4 commits March 19, 2026 13:47
…ator

Key changes: merge separate insurance/stability timestamps and inline fee math into one collectProtocolFees() method on TokenState that accumulates both fees; _withdrawInsurance/_withdrawStability now just read and reset the accumulators; tests updated to match the new shared-timestamp behavior and require KinkCurve where FixedCurve can't produce positive fees with imbalanced credit/debit.
…calculation-diverges-from-rate-allocation-formula

# Conflicts:
#	cadence/tests/insurance_collection_formula_test.cdc
#	cadence/tests/insurance_collection_test.cdc
#	cadence/tests/stability_collection_formula_test.cdc
#	cadence/tests/stability_collection_test.cdc
…bility_midPeriodRateChange: The rewritten tests used setInterestCurveFixed at low utilization (~5%), where creditIncome >> debitIncome → protocolFeeIncome = 0 with the new formula.
@mts1715 mts1715 requested a review from UlyanaAndrukhiv March 23, 2026 16:29
@mts1715 mts1715 requested a review from a team as a code owner March 23, 2026 16:29
@mts1715 mts1715 requested a review from zhangchiqing March 23, 2026 16:30
@mts1715 mts1715 self-assigned this Mar 23, 2026
@mts1715 mts1715 requested a review from liobrasil March 23, 2026 20:40
// setup borrower with FLOW collateral
// With 0.8 CF and 1.3 target health: 1000 FLOW collateral allows borrowing ~615 MOET
// borrow = (collateral * price * CF) / targetHealth = (1000 * 1.0 * 0.8) / 1.3 ≈ 615.38
// With 0.8 CF and 1.3 target health: 15000 FLOW collateral allows borrowing ~9231 MOET
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why using a different number? is there any problem with the 1000 FLOW collateral example? otherwise, I think it will be useful to show the different result using the same input number.

Copy link
Copy Markdown
Contributor Author

@mts1715 mts1715 Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because InterestCurveFixed are used.
According to changes protocolFee = debitIncome - creditIncome
with previous values:
totalСreditBalance = 10_000 MOET
totalDebitBalance = 1_000 Flow
Ratio MOET:FLOW = 1:1

DebitYearlyRate = 0.1
CreditYEarlyRate ~ 0.088 = (1 + 0.85*0.1/31_557_600)^31_557_600

debitIncome = 100, creditIncome = 880 => debitIncome < creditIncome => protocolFee == 0 => stability and insurance fee amounts ==0

It's possible another fix: change InterestCurveFixed into InterestCurveKink like it were done at https://github.com/onflow/FlowALP/pull/288/changes#r2994137845

// setup borrower with FLOW collateral
// With 0.8 CF and 1.3 target health: 1000 FLOW collateral allows borrowing ~615 MOET
// borrow = (collateral * price * CF) / targetHealth = (1000 * 1.0 * 0.8) / 1.3 ≈ 615.38
// With 0.8 CF and 1.3 target health: 15000 FLOW collateral allows borrowing ~9231 MOET
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the new number in this tests make a difference? If not, it's better keep the original number, so that it's easier to compare the difference in results.

Copy link
Copy Markdown
Contributor Author

@mts1715 mts1715 Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

access(EImplementation) fun setStabilityFeeRate(_ rate: UFix64)

/// Sets the last stability fee collection timestamp. See getLastStabilityFeeCollectionTime for additional details.
access(EImplementation) fun setLastStabilityFeeCollectionTime(_ lastStabilityFeeCollectionTime: UFix64)
Copy link
Copy Markdown
Contributor

@UlyanaAndrukhiv UlyanaAndrukhiv Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest adding setLastProtocolFeeCollectionTime

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setLastProtocolFeeCollectionTime won't be used as part of interface
changes of lastProtocolFeeCollectionTime only inside collectProtocolFees method.
so there is no need to add setter to interface

setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.1)
// set 10% annual debit rate using KinkCurve (slope1=0/slope2=0 → constant rate regardless of
// utilization), ensuring protocolFeeIncome > 0 at the ~6% utilization of this test setup.
setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what’s the reason for changing the interest curve in this and other tests?

Copy link
Copy Markdown
Contributor Author

@mts1715 mts1715 Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_collectInsurance_success_fullAmount
10_000 MOET credit balance
1_000 FLOW debit balance

for interest curve fixed debit_income < credit_income => protocol_fee = 0 => stability and insurance fee = 0.

let collectedAmount = finalInsuranceBalance - initialInsuranceBalance
let collectedInsuranceAmount = finalInsuranceBalance - initialInsuranceBalance

// collectProtocolFees withdraws both insurance AND stability in one call.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can see in the code and docs, collectProtocolFees doesn’t actually withdraw the insurance or stability fees - it just accumulates them. So it seems like the stabilityFundBalance should remain 0, since nothing is withdrawn. Based on that, I would expect amountWithdrawnFromReserves to be equal only to collectedInsuranceAmount.

/// Accumulates insurance and stability fee income for the elapsed time since the last call.
/// Updates lastProtocolFeeCollectionTime to the current block timestamp.
/// Must be called before any balance or rate change to settle fees at current rates.
access(EImplementation) fun collectProtocolFees() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix 0ff2f42

let stabilityFundBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) ?? 0.0
let amountWithdrawnFromReserves = reserveBalanceBefore - reserveBalanceAfter
Test.assertEqual(amountWithdrawnFromReserves, finalInsuranceBalance)
Test.assertEqual(amountWithdrawnFromReserves, finalInsuranceBalance + stabilityFundBalance)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same comment


```
protocolFeeRate = insuranceRate + stabilityFeeRate
debitRatePerSecond = perSecondInterestRate(yearlyRate: debitRate) - 1.0
Copy link
Copy Markdown
Contributor

@UlyanaAndrukhiv UlyanaAndrukhiv Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my perspective, the line debitRatePerSecond = perSecondInterestRate(yearlyRate: debitRate) - 1.0 is a bit confusing: perSecondInterestRate() represents a per-second multiplication factor which is > 1.0, not a per-second multiplication rate. In this line, we derive a rate by subtracting 1.0 which is correct, but not clear according to naming.

In general, the naming currentDebitRate, currentCreditRate and perSecondInterestRate does not seem appropriate for what they actually represent. Since they are greater than 1.0, they behave more like multiplicative factors rather than rates.

@zhangchiqing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zhangchiqing
factor = rate + 1
I propose rename:
currentDebitRate/currentCreditRate -> currentDebitFactor/currentCreditFactor
perSecondInterestRate -> perSecondInterestFactor

mts1715 and others added 2 commits March 27, 2026 13:25
Co-authored-by: Leo Zhang <zhangchiqing@gmail.com>
Co-authored-by: Leo Zhang <zhangchiqing@gmail.com>
@jordanschalm
Copy link
Copy Markdown
Member

Hey @mts1715, we just finished discussing a change to how total debit/credit balances and stability/insurance fees are calculated that will probably impact the work in this PR.

The summary is:

  • total debit/credit balances should be tracked as scaled balances, in the implementation
  • then, conceptually, total debit/credit balance fields reflect true balances, including interest accrual.
  • We define protocol fees in terms of actual reserve balance: protocolFee = reserveBalance + totalTrueDebitBalance - totalTrueCreditBalance

There is a notion doc here with notes from the meeting (essentially just the summary above).

I'm working on fleshing this out to a more rigorous design doc, and will let you know when that is done. In the meantime, I would pause work on this PR. Sorry for the interruption.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FLO-12: Fee Calculation Diverges From Rate Allocation Formula

4 participants