Skip to content
146 changes: 97 additions & 49 deletions cadence/contracts/FlowALPModels.cdc

Large diffs are not rendered by default.

81 changes: 27 additions & 54 deletions cadence/contracts/FlowALPv0.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ access(all) contract FlowALPv0 {
/// Returns nil if the token type is not supported.
access(all) view fun getLastStabilityCollectionTime(tokenType: Type): UFix64? {
if let tokenState = self.state.getTokenState(tokenType) {
return tokenState.getLastStabilityFeeCollectionTime()
return tokenState.getLastProtocolFeeCollectionTime()
}

return nil
Expand All @@ -282,7 +282,7 @@ access(all) contract FlowALPv0 {
/// Returns nil if the token type is not supported
access(all) view fun getLastInsuranceCollectionTime(tokenType: Type): UFix64? {
if let tokenState = self.state.getTokenState(tokenType) {
return tokenState.getLastInsuranceCollectionTime()
return tokenState.getLastProtocolFeeCollectionTime()
}
return nil
}
Expand Down Expand Up @@ -1491,7 +1491,7 @@ access(all) contract FlowALPv0 {
)
}
// Collect all insurance fees accrued under the old rate before applying the new one, the new rate applies only to time elapsed from this point forward
self.updateInterestRatesAndCollectInsurance(tokenType: tokenType)
self._collectInsurance(tokenType: tokenType)

tsRef.setInsuranceRate(insuranceRate)

Expand Down Expand Up @@ -1536,7 +1536,7 @@ access(all) contract FlowALPv0 {
pre {
self.isTokenSupported(tokenType: tokenType): "Unsupported token type"
}
self.updateInterestRatesAndCollectInsurance(tokenType: tokenType)
self._collectInsurance(tokenType: tokenType)
}

/// Updates the per-deposit limit fraction for a given token (fraction in [0,1])
Expand Down Expand Up @@ -1602,7 +1602,7 @@ access(all) contract FlowALPv0 {
?? panic("Invariant: token state missing")

// Collect all stability fees accrued under the old rate before applying the new one, the new rate applies only to time elapsed from this point forward
self.updateInterestRatesAndCollectStability(tokenType: tokenType)
self._collectStability(tokenType: tokenType)

tsRef.setStabilityFeeRate(stabilityFeeRate)

Expand Down Expand Up @@ -1647,7 +1647,7 @@ access(all) contract FlowALPv0 {
pre {
self.isTokenSupported(tokenType: tokenType): "Unsupported token type"
}
self.updateInterestRatesAndCollectStability(tokenType: tokenType)
self._collectStability(tokenType: tokenType)
}

/// Regenerates deposit capacity for all supported token types
Expand Down Expand Up @@ -1888,7 +1888,7 @@ access(all) contract FlowALPv0 {
/// This method should be called periodically to ensure rates are current and fee amounts are collected.
///
/// @param tokenType: The token type to update rates for
access(self) fun updateInterestRatesAndCollectStability(tokenType: Type) {
access(self) fun _collectStability(tokenType: Type) {
let tokenState = self._borrowUpdatedTokenState(type: tokenType)
tokenState.updateInterestRates()

Expand All @@ -1901,7 +1901,7 @@ access(all) contract FlowALPv0 {
let reserveRef = self.state.borrowReserve(tokenType)!

// Collect stability and get token vault
if let collectedVault <- self._collectStability(tokenState: tokenState, reserveVault: reserveRef) {
if let collectedVault <- self._withdrawStability(tokenState: tokenState, reserveVault: reserveRef) {
let collectedBalance = collectedVault.balance
// Deposit collected token into stability fund
if !self.state.hasStabilityFund(tokenType) {
Expand All @@ -1915,7 +1915,7 @@ access(all) contract FlowALPv0 {
poolUUID: self.uuid,
tokenType: tokenType.identifier,
stabilityAmount: collectedBalance,
collectionTime: tokenState.getLastStabilityFeeCollectionTime()
collectionTime: tokenState.getLastProtocolFeeCollectionTime()
)
}
}
Expand All @@ -1928,52 +1928,39 @@ access(all) contract FlowALPv0 {
/// fees will not be settled under the old rate. When reserves eventually recover, the entire
/// elapsed window — including the period before the rate change — will be collected under the
/// new rate, causing over- or under-collection for that period.
access(self) fun _collectInsurance(
access(self) fun _withdrawInsurance(
tokenState: auth(FlowALPModels.EImplementation) &{FlowALPModels.TokenState},
reserveVault: auth(FungibleToken.Withdraw) &{FungibleToken.Vault},
oraclePrice: UFix64,
maxDeviationBps: UInt16
): @MOET.Vault? {
let currentTime = getCurrentBlock().timestamp

if tokenState.getInsuranceRate() == 0.0 {
tokenState.setLastInsuranceCollectionTime(currentTime)
return nil
}

let timeElapsed = currentTime - tokenState.getLastInsuranceCollectionTime()
if timeElapsed <= 0.0 {
return nil
}

let debitIncome = tokenState.getTotalDebitBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentDebitRate(), timeElapsed) - 1.0)
let insuranceAmount = debitIncome * UFix128(tokenState.getInsuranceRate())
let insuranceAmountUFix64 = FlowALPMath.toUFix64RoundDown(insuranceAmount)
tokenState.accumulateProtocolFees()
let insuranceAmount = tokenState.getCollectInsuranceAmount()

if insuranceAmountUFix64 == 0.0 {
tokenState.setLastInsuranceCollectionTime(currentTime)
if insuranceAmount == 0.0 {
return nil
}

if insuranceAmountUFix64 > reserveVault.balance {
if insuranceAmount > reserveVault.balance {
// do not collect the insurance fee if the reserve doesn't have enough tokens to cover the full amount
return nil
}

let insuranceVault <- reserveVault.withdraw(amount: insuranceAmountUFix64)
let insuranceVault <- reserveVault.withdraw(amount: insuranceAmount)
let insuranceSwapper = tokenState.getInsuranceSwapper() ?? panic("missing insurance swapper")

assert(insuranceSwapper.inType() == reserveVault.getType(), message: "Insurance swapper input type must be same as reserveVault")
assert(insuranceSwapper.outType() == Type<@MOET.Vault>(), message: "Insurance swapper must output MOET")

let quote = insuranceSwapper.quoteOut(forProvided: insuranceAmountUFix64, reverse: false)
let quote = insuranceSwapper.quoteOut(forProvided: insuranceAmount, reverse: false)
let dexPrice = quote.outAmount / quote.inAmount
assert(
FlowALPMath.dexOraclePriceDeviationInRange(dexPrice: dexPrice, oraclePrice: oraclePrice, maxDeviationBps: maxDeviationBps),
message: "DEX/oracle price deviation exceeds \(maxDeviationBps)bps. Dex price: \(dexPrice), Oracle price: \(oraclePrice)",
)
var moetVault <- insuranceSwapper.swap(quote: quote, inVault: <-insuranceVault) as! @MOET.Vault
tokenState.setLastInsuranceCollectionTime(currentTime)
tokenState.resetCollectInsuranceAmount()
return <-moetVault
}

Expand All @@ -1985,39 +1972,25 @@ access(all) contract FlowALPv0 {
/// fees will not be settled under the old rate. When reserves eventually recover, the entire
/// elapsed window — including the period before the rate change — will be collected under the
/// new rate, causing over- or under-collection for that period.
access(self) fun _collectStability(
access(self) fun _withdrawStability(
tokenState: auth(FlowALPModels.EImplementation) &{FlowALPModels.TokenState},
reserveVault: auth(FungibleToken.Withdraw) &{FungibleToken.Vault}
): @{FungibleToken.Vault}? {
let currentTime = getCurrentBlock().timestamp

if tokenState.getStabilityFeeRate() == 0.0 {
tokenState.setLastStabilityFeeCollectionTime(currentTime)
return nil
}

let timeElapsed = currentTime - tokenState.getLastStabilityFeeCollectionTime()
if timeElapsed <= 0.0 {
return nil
}

let stabilityFeeRate = UFix128(tokenState.getStabilityFeeRate())
let interestIncome = tokenState.getTotalDebitBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentDebitRate(), timeElapsed) - 1.0)
let stabilityAmount = interestIncome * stabilityFeeRate
let stabilityAmountUFix64 = FlowALPMath.toUFix64RoundDown(stabilityAmount)
tokenState.accumulateProtocolFees()
let stabilityAmount = tokenState.getCollectStabilityAmount()

if stabilityAmountUFix64 == 0.0 {
tokenState.setLastStabilityFeeCollectionTime(currentTime)
if stabilityAmount == 0.0 {
return nil
}

if stabilityAmountUFix64 > reserveVault.balance {
if stabilityAmount > reserveVault.balance {
// do not collect the stability fee if the reserve doesn't have enough tokens to cover the full amount
return nil
}

let stabilityVault <- reserveVault.withdraw(amount: stabilityAmountUFix64)
tokenState.setLastStabilityFeeCollectionTime(currentTime)
let stabilityVault <- reserveVault.withdraw(amount: stabilityAmount)
tokenState.resetCollectStabilityAmount()
return <-stabilityVault
}

Expand Down Expand Up @@ -2116,7 +2089,7 @@ access(all) contract FlowALPv0 {
/// This method should be called periodically to ensure rates are current and insurance is collected.
///
/// @param tokenType: The token type to update rates for
access(self) fun updateInterestRatesAndCollectInsurance(tokenType: Type) {
access(self) fun _collectInsurance(tokenType: Type) {
let tokenState = self._borrowUpdatedTokenState(type: tokenType)
tokenState.updateInterestRates()

Expand All @@ -2130,7 +2103,7 @@ access(all) contract FlowALPv0 {
if let reserveRef = self.state.borrowReserve(tokenType) {
// Collect insurance and get MOET vault
let oraclePrice = self.config.getPriceOracle().price(ofToken: tokenType)!
if let collectedMOET <- self._collectInsurance(
if let collectedMOET <- self._withdrawInsurance(
tokenState: tokenState,
reserveVault: reserveRef,
oraclePrice: oraclePrice,
Expand All @@ -2144,7 +2117,7 @@ access(all) contract FlowALPv0 {
poolUUID: self.uuid,
tokenType: tokenType.identifier,
insuranceAmount: collectedMOETBalance,
collectionTime: tokenState.getLastInsuranceCollectionTime()
collectionTime: tokenState.getLastProtocolFeeCollectionTime()
)
}
}
Expand Down
49 changes: 29 additions & 20 deletions cadence/tests/insurance_collection_formula_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ fun test_collectInsurance_success_fullAmount() {
createPosition(admin: PROTOCOL_ACCOUNT, signer: lp, amount: 10000.0, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false)

// 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

// borrow = (collateral * price * CF) / targetHealth = (15000 * 1.0 * 0.8) / 1.3 ≈ 9230.77
let borrower = Test.createAccount()
setupMoetVault(borrower, beFailed: false)
transferFlowTokens(to: borrower, amount: 1000.0)
transferFlowTokens(to: borrower, amount: 15000.0)

// borrower deposits FLOW and auto-borrows MOET (creates debit balance ~615 MOET)
createPosition(admin: PROTOCOL_ACCOUNT, signer: borrower, amount: 1000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true)
// borrower deposits 15000 FLOW and auto-borrows MOET (creates debit balance ~9231 MOET)
createPosition(admin: PROTOCOL_ACCOUNT, signer: borrower, amount: 15000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true)

// setup protocol account with MOET vault for the swapper
setupMoetVault(PROTOCOL_ACCOUNT, beFailed: false)
Expand All @@ -61,8 +61,7 @@ fun test_collectInsurance_success_fullAmount() {
let swapperResult = setInsuranceSwapper(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, priceRatio: 1.0)
Test.expect(swapperResult, Test.beSucceeded())

// set 10% annual debit rate
// insurance is calculated on debit income, not debit balance
// set 10% annual debit rate; credit rate = 0.1 × (1 − 0.15) = 0.085
setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.1)

// set insurance rate (10% of debit income)
Expand Down Expand Up @@ -90,25 +89,35 @@ fun test_collectInsurance_success_fullAmount() {
let reserveBalanceAfter = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER)
Test.assert(reserveBalanceAfter < reserveBalanceBefore, message: "Reserves should have decreased after collection")

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

// collectInsurance accumulates both insurance AND stability in one call,
// and only insurance was withdrawn, with insuranceRate=0.1.
let stabilityFundBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) ?? 0.0
Test.assertEqual(0.0, stabilityFundBalance)
let amountWithdrawnFromReserves = reserveBalanceBefore - reserveBalanceAfter
// verify the amount withdrawn from reserves equals the collected amount (1:1 swap ratio)
Test.assertEqual(amountWithdrawnFromReserves, collectedAmount)
// Total withdrawn = insurance (→ fund via swap with 1:1 ratio)
Test.assertEqual(amountWithdrawnFromReserves, collectedInsuranceAmount)

// verify last insurance collection time was updated to current block timestamp
let currentTimestamp = getBlockTimestamp()
let lastInsuranceCollectionTime = getLastInsuranceCollectionTime(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER)
Test.assertEqual(currentTimestamp, lastInsuranceCollectionTime!)

// verify formula: insuranceAmount = debitIncome * insuranceRate
// where debitIncome = totalDebitBalance * (currentDebitRate^timeElapsed - 1.0)
// = (1.0 + 0.1 / 31_557_600)^31_557_600 = 1.10517091665
// debitBalance ≈ 615.38 MOET
// With 10% annual debit rate over 1 year: debitIncome ≈ 615.38 * (1.10517091665 - 1) ≈ 64.72
// Insurance = debitIncome * 0.1 ≈ 6.472 MOET

let expectedCollectedAmount = 6.472
Test.assert(equalWithinVariance(expectedCollectedAmount, collectedAmount, 0.001),
message: "Insurance collected should be around \(expectedCollectedAmount) but current \(collectedAmount)")
// verify formula (index-based, accounts for credit offset):
// protocolFee = debitIncome - creditIncome
// insuranceAmount = protocolFee × insuranceRate / totalProtocolFeeRate
//
// debitBalance ≈ 15000 × 0.8 / 1.3 ≈ 9230.77 MOET
// creditBalance = 10000 MOET
// debitGrowth = e^0.1 ≈ 1.10517091807 (e^rate ≈ (1 + rate/N)^N for big N)
// creditGrowth = e^(0.085) ≈ 1.0887170667 (creditRate = debitRate × (1 − 0.15) = 0.085)
// debitIncome = 9230.77 × 0.10517091807 ≈ 970.808555393
// creditIncome = 10000 × 0.0887170667 ≈ 887.170667
// protocolFee = 970.808555393 - 887.170667 = 83.637888393 MOET
// insuranceAmt = 83.637888393 × 0.1 / 0.15 ≈ 55.758 MOET
//
let expectedCollectedAmount = 55.758

Test.assert(equalWithinVariance(expectedCollectedAmount, collectedInsuranceAmount, 0.001), message: "Insurance collected should be around \(expectedCollectedAmount) but current \(collectedInsuranceAmount)")
}
Loading
Loading