From 6000a1ab49237471d693d39052bfd3318e2ac3d2 Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Thu, 19 Mar 2026 13:47:54 +0100 Subject: [PATCH 1/9] changes at fees calculation formula and per seconds rates --- cadence/contracts/FlowALPModels.cdc | 23 +++++++++-------------- cadence/contracts/FlowALPv0.cdc | 14 ++++++++++---- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index 42f60eb5..51739086 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -1401,33 +1401,28 @@ access(all) contract FlowALPModels { let insuranceRate = UFix128(self.insuranceRate) let stabilityFeeRate = UFix128(self.stabilityFeeRate) - var creditRate: UFix128 = 0.0 // Total protocol cut as a percentage of debit interest income let protocolFeeRate = insuranceRate + stabilityFeeRate + self.currentDebitRate = FlowALPMath.perSecondInterestRate(yearlyRate: debitRate) + let debitRatePerSecond = self.currentDebitRate - 1.0 // Two calculation paths based on curve type: - // 1. FixedCurve: simple spread model (creditRate = debitRate * (1 - protocolFeeRate)) + // 1. FixedCurve: simple spread model // Used for stable assets like MOET where rates are governance-controlled // 2. KinkCurve (and others): reserve factor model // Insurance and stability are percentages of interest income, not a fixed spread if self.interestCurve.getType() == Type() { - // FixedRate path: creditRate = debitRate * (1 - protocolFeeRate)) - // This provides a fixed, predictable spread between borrower and lender rates - creditRate = debitRate * (1.0 - protocolFeeRate) + // FixedRate path: creditRatePerSec = debitRatePerSec * (1 - protocolFeeRate) + self.currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate) } else { // KinkCurve path (and any other curves): reserve factor model - // protocolFeeAmount = debitIncome * protocolFeeRate (percentage of income) - // creditRate = (debitIncome - protocolFeeAmount) / totalCreditBalance - let debitIncome = self.totalDebitBalance * debitRate - let protocolFeeAmount = debitIncome * protocolFeeRate - + // creditRatePerSec = debitRatePerSec * (1 - protocolFeeRate) * totalDebit / totalCredit if self.totalCreditBalance > 0.0 { - creditRate = (debitIncome - protocolFeeAmount) / self.totalCreditBalance + self.currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate) * self.totalDebitBalance / self.totalCreditBalance + } else { + self.currentCreditRate = 1.0 } } - - self.currentCreditRate = FlowALPMath.perSecondInterestRate(yearlyRate: creditRate) - self.currentDebitRate = FlowALPMath.perSecondInterestRate(yearlyRate: debitRate) } /// Updates the credit and debit interest indices for elapsed time since last update. diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 950a3421..c3797d26 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1934,7 +1934,11 @@ access(all) contract FlowALPv0 { } let debitIncome = tokenState.getTotalDebitBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentDebitRate(), timeElapsed) - 1.0) - let insuranceAmount = debitIncome * UFix128(tokenState.getInsuranceRate()) + let creditIncome = tokenState.getTotalCreditBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentCreditRate(), timeElapsed) - 1.0) + assert(debitIncome >= creditIncome) + let protocolFeeIncome = debitIncome - creditIncome + let totalProtocolFeeRate = UFix128(tokenState.getInsuranceRate() + tokenState.getStabilityFeeRate()) + let insuranceAmount = protocolFeeIncome * UFix128(tokenState.getInsuranceRate()) / totalProtocolFeeRate let insuranceAmountUFix64 = FlowALPMath.toUFix64RoundDown(insuranceAmount) if insuranceAmountUFix64 == 0.0 { @@ -1988,9 +1992,11 @@ access(all) contract FlowALPv0 { return nil } - let stabilityFeeRate = UFix128(tokenState.getStabilityFeeRate()) - let interestIncome = tokenState.getTotalDebitBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentDebitRate(), timeElapsed) - 1.0) - let stabilityAmount = interestIncome * stabilityFeeRate + let debitIncome = tokenState.getTotalDebitBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentDebitRate(), timeElapsed) - 1.0) + let creditIncome = tokenState.getTotalCreditBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentCreditRate(), timeElapsed) - 1.0) + let protocolFee: UFix128 = debitIncome > creditIncome ? debitIncome - creditIncome : 0.0 + let totalProtocolFeeRate = UFix128(tokenState.getInsuranceRate()) + UFix128(tokenState.getStabilityFeeRate()) + let stabilityAmount = protocolFee * UFix128(tokenState.getStabilityFeeRate()) / totalProtocolFeeRate let stabilityAmountUFix64 = FlowALPMath.toUFix64RoundDown(stabilityAmount) if stabilityAmountUFix64 == 0.0 { From e5c39294d838b4ee4b30e3a5f93eca3433be35f9 Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Mon, 23 Mar 2026 16:03:55 +0100 Subject: [PATCH 2/9] unify insurance and stability into single collectProtocolFees accumulator 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. --- cadence/contracts/FlowALPModels.cdc | 123 +++++++++++++----- cadence/contracts/FlowALPv0.cdc | 83 ++++-------- .../insurance_collection_formula_test.cdc | 54 ++++---- cadence/tests/insurance_collection_test.cdc | 17 ++- .../stability_collection_formula_test.cdc | 51 ++++---- cadence/tests/stability_collection_test.cdc | 16 ++- 6 files changed, 191 insertions(+), 153 deletions(-) diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index 51739086..e37886bb 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -947,17 +947,30 @@ access(all) contract FlowALPModels { /// The annual insurance rate applied to total debit when computing credit interest (default 0.1%) access(all) view fun getInsuranceRate(): UFix64 - /// Timestamp of the last insurance collection for this token. - access(all) view fun getLastInsuranceCollectionTime(): UFix64 - /// Swapper used to convert this token to MOET for insurance collection. access(all) view fun getInsuranceSwapper(): {DeFiActions.Swapper}? /// The stability fee rate to calculate stability (default 0.05, 5%). access(all) view fun getStabilityFeeRate(): UFix64 - /// Timestamp of the last stability collection for this token. - access(all) view fun getLastStabilityFeeCollectionTime(): UFix64 + /// Timestamp of the last protocol fee collection for this token. + access(all) view fun getLastProtocolFeeCollectionTime(): UFix64 + + /// Returns the accumulated insurance fee income as UFix64, ready for collection. + access(all) view fun getCollectInsuranceAmount(): UFix64 + + /// Resets the accumulated insurance fee income to zero after successful collection. + access(EImplementation) fun resetCollectInsuranceAmount() + + /// Returns the accumulated stability fee income as UFix64, ready for collection. + access(all) view fun getCollectStabilityAmount(): UFix64 + + /// Resets the accumulated stability fee income to zero after successful collection. + access(EImplementation) fun resetCollectStabilityAmount() + + /// Accumulates protocol fees (insurance + stability) for elapsed time since last collection. + /// Called before any balance or rate change to capture fees at the current rates and balances. + access(EImplementation) fun collectProtocolFees() /// Per-position limit fraction of capacity (default 0.05 i.e., 5%) access(all) view fun getDepositLimitFraction(): UFix64 @@ -993,9 +1006,6 @@ access(all) contract FlowALPModels { /// Sets the insurance rate. See getInsuranceRate for additional details. access(EImplementation) fun setInsuranceRate(_ rate: UFix64) - /// Sets the last insurance collection timestamp. See getLastInsuranceCollectionTime for additional details. - access(EImplementation) fun setLastInsuranceCollectionTime(_ lastInsuranceCollectionTime: UFix64) - /// Sets the insurance swapper. See getInsuranceSwapper for additional details. /// If non-nil, the swapper must accept this token type as input and output MOET. access(EImplementation) fun setInsuranceSwapper(_ swapper: {DeFiActions.Swapper}?) @@ -1018,9 +1028,6 @@ access(all) contract FlowALPModels { /// Sets the stability fee rate. See getStabilityFeeRate for additional details. access(EImplementation) fun setStabilityFeeRate(_ rate: UFix64) - /// Sets the last stability fee collection timestamp. See getLastStabilityFeeCollectionTime for additional details. - access(EImplementation) fun setLastStabilityFeeCollectionTime(_ lastStabilityFeeCollectionTime: UFix64) - /// Sets the deposit capacity. See getDepositCapacity for additional details. access(EImplementation) fun setDepositCapacity(_ capacity: UFix64) @@ -1112,14 +1119,16 @@ access(all) contract FlowALPModels { access(self) var interestCurve: {FlowALPInterestRates.InterestCurve} /// The annual insurance rate applied to total debit when computing credit interest (default 0.1%) access(self) var insuranceRate: UFix64 - /// Timestamp of the last insurance collection for this token. - access(self) var lastInsuranceCollectionTime: UFix64 /// Swapper used to convert this token to MOET for insurance collection. access(self) var insuranceSwapper: {DeFiActions.Swapper}? /// The stability fee rate to calculate stability (default 0.05, 5%). access(self) var stabilityFeeRate: UFix64 - /// Timestamp of the last stability collection for this token. - access(self) var lastStabilityFeeCollectionTime: UFix64 + /// Timestamp of the last protocol fee accumulation (shared by insurance and stability). + access(self) var lastProtocolFeeCollectionTime: UFix64 + /// Accrued stability fee income not yet withdrawn from reserves. + access(self) var accumulatedStabilityFeeIncome: UFix128 + /// Accrued insurance fee income not yet withdrawn from reserves. + access(self) var accumulatedInsuranceFeeIncome: UFix128 /// Per-position limit fraction of capacity (default 0.05 i.e., 5%) access(self) var depositLimitFraction: UFix64 /// The rate at which depositCapacity can increase over time. This is a tokens per hour rate, @@ -1158,10 +1167,11 @@ access(all) contract FlowALPModels { self.currentDebitRate = 1.0 self.interestCurve = interestCurve self.insuranceRate = 0.0 - self.lastInsuranceCollectionTime = getCurrentBlock().timestamp self.insuranceSwapper = nil self.stabilityFeeRate = 0.05 - self.lastStabilityFeeCollectionTime = getCurrentBlock().timestamp + self.lastProtocolFeeCollectionTime = getCurrentBlock().timestamp + self.accumulatedStabilityFeeIncome = 0.0 + self.accumulatedInsuranceFeeIncome = 0.0 self.depositLimitFraction = 0.05 self.depositRate = depositRate self.depositCapacity = depositCapacityCap @@ -1223,11 +1233,6 @@ access(all) contract FlowALPModels { return self.insuranceRate } - /// Returns the timestamp of the last insurance collection for this token. - access(all) view fun getLastInsuranceCollectionTime(): UFix64 { - return self.lastInsuranceCollectionTime - } - /// Returns the swapper used to convert this token to MOET for insurance collection. access(all) view fun getInsuranceSwapper(): {DeFiActions.Swapper}? { return self.insuranceSwapper @@ -1238,9 +1243,29 @@ access(all) contract FlowALPModels { return self.stabilityFeeRate } - /// Returns the timestamp of the last stability fee collection for this token. - access(all) view fun getLastStabilityFeeCollectionTime(): UFix64 { - return self.lastStabilityFeeCollectionTime + /// Returns the timestamp of the last protocol fee collection for this token. + access(all) view fun getLastProtocolFeeCollectionTime(): UFix64 { + return self.lastProtocolFeeCollectionTime + } + + /// Returns the accumulated insurance fee income as UFix64, ready for collection. + access(all) view fun getCollectInsuranceAmount(): UFix64 { + return FlowALPMath.toUFix64RoundDown(self.accumulatedInsuranceFeeIncome) + } + + /// Resets the accumulated insurance fee income to zero after successful collection. + access(EImplementation) fun resetCollectInsuranceAmount() { + self.accumulatedInsuranceFeeIncome = 0.0 + } + + /// Returns the accumulated stability fee income as UFix64, ready for collection. + access(all) view fun getCollectStabilityAmount(): UFix64 { + return FlowALPMath.toUFix64RoundDown(self.accumulatedStabilityFeeIncome) + } + + /// Resets the accumulated stability fee income to zero after successful collection. + access(EImplementation) fun resetCollectStabilityAmount() { + self.accumulatedStabilityFeeIncome = 0.0 } /// Returns the per-position limit fraction of capacity (default 0.05 i.e., 5%). @@ -1282,12 +1307,9 @@ access(all) contract FlowALPModels { /// Sets the insurance rate. See TokenState.setInsuranceRate. access(EImplementation) fun setInsuranceRate(_ rate: UFix64) { + self.collectProtocolFees() self.insuranceRate = rate - } - - /// Sets the last insurance collection timestamp. See TokenState.setLastInsuranceCollectionTime. - access(EImplementation) fun setLastInsuranceCollectionTime(_ lastInsuranceCollectionTime: UFix64) { - self.lastInsuranceCollectionTime = lastInsuranceCollectionTime + self.updateForUtilizationChange() } /// Sets the insurance swapper. See TokenState.setInsuranceSwapper. @@ -1329,12 +1351,9 @@ access(all) contract FlowALPModels { /// Sets the stability fee rate. See TokenState.setStabilityFeeRate. access(EImplementation) fun setStabilityFeeRate(_ rate: UFix64) { + self.collectProtocolFees() self.stabilityFeeRate = rate - } - - /// Sets the last stability fee collection timestamp. See TokenState.setLastStabilityFeeCollectionTime. - access(EImplementation) fun setLastStabilityFeeCollectionTime(_ lastStabilityFeeCollectionTime: UFix64) { - self.lastStabilityFeeCollectionTime = lastStabilityFeeCollectionTime + self.updateForUtilizationChange() } /// Sets the deposit capacity. See TokenState.setDepositCapacity. @@ -1344,6 +1363,7 @@ access(all) contract FlowALPModels { /// Sets the interest curve. Recalculates interest rates immediately. See TokenState.setInterestCurve. access(EImplementation) fun setInterestCurve(_ curve: {FlowALPInterestRates.InterestCurve}) { + self.collectProtocolFees() self.interestCurve = curve // Update rates immediately to reflect the new curve self.updateInterestRates() @@ -1481,12 +1501,14 @@ access(all) contract FlowALPModels { /// Increases total credit balance by the given amount and recalculates interest rates. access(EImplementation) fun increaseCreditBalance(by amount: UFix128) { + self.collectProtocolFees() self.totalCreditBalance = self.totalCreditBalance + amount self.updateForUtilizationChange() } /// Decreases total credit balance by the given amount (floored at 0) and recalculates interest rates. access(EImplementation) fun decreaseCreditBalance(by amount: UFix128) { + self.collectProtocolFees() if amount >= self.totalCreditBalance { self.totalCreditBalance = 0.0 } else { @@ -1497,12 +1519,14 @@ access(all) contract FlowALPModels { /// Increases total debit balance by the given amount and recalculates interest rates. access(EImplementation) fun increaseDebitBalance(by amount: UFix128) { + self.collectProtocolFees() self.totalDebitBalance = self.totalDebitBalance + amount self.updateForUtilizationChange() } /// Decreases total debit balance by the given amount (floored at 0) and recalculates interest rates. access(EImplementation) fun decreaseDebitBalance(by amount: UFix128) { + self.collectProtocolFees() if amount >= self.totalDebitBalance { self.totalDebitBalance = 0.0 } else { @@ -1510,6 +1534,35 @@ access(all) contract FlowALPModels { } self.updateForUtilizationChange() } + + /// 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() { + let currentTime = getCurrentBlock().timestamp + + let totalProtocolFeeRate = self.insuranceRate + self.stabilityFeeRate + if totalProtocolFeeRate == 0.0 { + self.lastProtocolFeeCollectionTime = currentTime + return + } + + let timeElapsed = currentTime - self.lastProtocolFeeCollectionTime + if timeElapsed <= 0.0 { + return + } + + let debitIncome = self.totalDebitBalance * (FlowALPMath.powUFix128(self.currentDebitRate, timeElapsed) - 1.0) + let creditIncome = self.totalCreditBalance * (FlowALPMath.powUFix128(self.currentCreditRate, timeElapsed) - 1.0) + let protocolFeeIncome: UFix128 = debitIncome > creditIncome ? debitIncome - creditIncome : 0.0 + + let insuranceFeeAmount = protocolFeeIncome * UFix128(self.insuranceRate) / UFix128(totalProtocolFeeRate) + let stabilityFeeAmount = protocolFeeIncome - insuranceFeeAmount + + self.accumulatedInsuranceFeeIncome = self.accumulatedInsuranceFeeIncome + insuranceFeeAmount + self.accumulatedStabilityFeeIncome = self.accumulatedStabilityFeeIncome + stabilityFeeAmount + self.lastProtocolFeeCollectionTime = currentTime + } } /* --- POOL STATE --- */ diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index c3797d26..03df0179 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -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 @@ -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 } @@ -1530,7 +1530,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]) @@ -1634,7 +1634,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 @@ -1875,7 +1875,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() @@ -1888,7 +1888,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) { @@ -1902,7 +1902,7 @@ access(all) contract FlowALPv0 { poolUUID: self.uuid, tokenType: tokenType.identifier, stabilityAmount: collectedBalance, - collectionTime: tokenState.getLastStabilityFeeCollectionTime() + collectionTime: tokenState.getLastProtocolFeeCollectionTime() ) } } @@ -1915,56 +1915,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 creditIncome = tokenState.getTotalCreditBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentCreditRate(), timeElapsed) - 1.0) - assert(debitIncome >= creditIncome) - let protocolFeeIncome = debitIncome - creditIncome - let totalProtocolFeeRate = UFix128(tokenState.getInsuranceRate() + tokenState.getStabilityFeeRate()) - let insuranceAmount = protocolFeeIncome * UFix128(tokenState.getInsuranceRate()) / totalProtocolFeeRate - let insuranceAmountUFix64 = FlowALPMath.toUFix64RoundDown(insuranceAmount) + tokenState.collectProtocolFees() + 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 } @@ -1976,41 +1959,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 debitIncome = tokenState.getTotalDebitBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentDebitRate(), timeElapsed) - 1.0) - let creditIncome = tokenState.getTotalCreditBalance() * (FlowALPMath.powUFix128(tokenState.getCurrentCreditRate(), timeElapsed) - 1.0) - let protocolFee: UFix128 = debitIncome > creditIncome ? debitIncome - creditIncome : 0.0 - let totalProtocolFeeRate = UFix128(tokenState.getInsuranceRate()) + UFix128(tokenState.getStabilityFeeRate()) - let stabilityAmount = protocolFee * UFix128(tokenState.getStabilityFeeRate()) / totalProtocolFeeRate - let stabilityAmountUFix64 = FlowALPMath.toUFix64RoundDown(stabilityAmount) + tokenState.collectProtocolFees() + 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 } @@ -2109,7 +2076,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() @@ -2123,7 +2090,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, @@ -2137,7 +2104,7 @@ access(all) contract FlowALPv0 { poolUUID: self.uuid, tokenType: tokenType.identifier, insuranceAmount: collectedMOETBalance, - collectionTime: tokenState.getLastInsuranceCollectionTime() + collectionTime: tokenState.getLastProtocolFeeCollectionTime() ) } } diff --git a/cadence/tests/insurance_collection_formula_test.cdc b/cadence/tests/insurance_collection_formula_test.cdc index 27c7cc39..73b6723e 100644 --- a/cadence/tests/insurance_collection_formula_test.cdc +++ b/cadence/tests/insurance_collection_formula_test.cdc @@ -43,15 +43,15 @@ fun test_collectInsurance_success_fullAmount() { // LP deposits MOET (creates credit balance, provides borrowing liquidity) 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 + // setup borrower with enough FLOW so MOET debit > MOET credit × (1 − pFeeRate). + // MOET credit = 10000, pFeeRate = 0.15 → threshold = 8500. + // 15000 FLOW × 0.8 CF / 1.3 target health ≈ 9231 MOET debit. 9231 > 8500 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) @@ -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) @@ -90,34 +89,43 @@ 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 + // collectProtocolFees withdraws both insurance AND stability in one call. + // With insuranceRate=0.1 and stabilityFeeRate=0.05 (default), both are withdrawn. + let stabilityFundBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) ?? 0.0 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) + stability (kept as MOET) + Test.assertEqual(amountWithdrawnFromReserves, collectedInsuranceAmount + stabilityFundBalance) // 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 - + // 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 + // // NOTE: // We intentionally do not use `equalWithinVariance` with `defaultUFixVariance` here. // The default variance is designed for deterministic math, but insurance collection // depends on block timestamps, which can differ slightly between test runs. // A larger, time-aware tolerance is required. let tolerance = 0.001 - let expectedCollectedAmount = 6.472 - let diff = expectedCollectedAmount > collectedAmount - ? expectedCollectedAmount - collectedAmount - : collectedAmount - expectedCollectedAmount + let expectedCollectedAmount = 55.758 + let diff = expectedCollectedAmount > collectedInsuranceAmount + ? expectedCollectedAmount - collectedInsuranceAmount + : collectedInsuranceAmount - expectedCollectedAmount - Test.assert(diff < tolerance, message: "Insurance collected should be around \(expectedCollectedAmount) but current \(collectedAmount)") + Test.assert(diff < tolerance, message: "Insurance collected should be around \(expectedCollectedAmount) but current \(collectedInsuranceAmount)") } diff --git a/cadence/tests/insurance_collection_test.cdc b/cadence/tests/insurance_collection_test.cdc index 02c58649..b59b8e30 100644 --- a/cadence/tests/insurance_collection_test.cdc +++ b/cadence/tests/insurance_collection_test.cdc @@ -157,8 +157,9 @@ fun test_collectInsurance_insufficientReserves() { Test.assertEqual(0.0, finalInsuranceBalance) Test.assertEqual(reserveBalanceBefore, reserveBalanceAfter) - // time should not change - Test.assertEqual(lastCollectionTimeBefore, lastCollectionTimeAfter) + // In the accumulator model, collectProtocolFees() is always called and updates the timestamp + // even when reserves are insufficient. Fees accumulate in the accumulator until reserves recover. + Test.assert(lastCollectionTimeAfter! > lastCollectionTimeBefore!, message: "Timestamp should be updated even on failed collection") } @@ -238,8 +239,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 directly + // 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) @@ -262,9 +262,12 @@ fun test_collectInsurance_success_fullAmount() { let reserveBalanceAfter = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER) Test.assert(reserveBalanceAfter < reserveBalanceBefore, message: "Reserves should have decreased after collection") - // verify the amount withdrawn from reserves equals the insurance fund balance (1:1 swap ratio) + // verify the total amount withdrawn from reserves equals insurance collected (1:1 swap ratio). + // collectInsurance only drains the insurance accumulator; stability remains in its accumulator + // until collectStability is called separately. + let stabilityFundBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) ?? 0.0 let amountWithdrawnFromReserves = reserveBalanceBefore - reserveBalanceAfter - Test.assertEqual(amountWithdrawnFromReserves, finalInsuranceBalance) + Test.assertEqual(amountWithdrawnFromReserves, finalInsuranceBalance + stabilityFundBalance) // verify last insurance collection time was updated to current block timestamp let currentTimestamp = getBlockTimestamp() @@ -427,7 +430,7 @@ fun test_collectInsurance_dexOracleSlippageProtection() { let swapperResult = setInsuranceSwapper(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, priceRatio: 0.5) Test.expect(swapperResult, Test.beSucceeded()) - // set 10% annual debit rate and 10% insurance rate + // set 10% annual debit rate; credit rate = debitRate × (1 − protocolFeeRate) = 0.1 × 0.85 setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) let rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.1) Test.expect(rateResult, Test.beSucceeded()) diff --git a/cadence/tests/stability_collection_formula_test.cdc b/cadence/tests/stability_collection_formula_test.cdc index 00e8a0d6..58ca6393 100644 --- a/cadence/tests/stability_collection_formula_test.cdc +++ b/cadence/tests/stability_collection_formula_test.cdc @@ -43,18 +43,17 @@ fun test_collectStability_success_fullAmount() { // LP deposits MOET (creates credit balance, provides borrowing liquidity) 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 + // setup borrower with enough FLOW so MOET debit > MOET credit × (1 − pFeeRate). + // MOET credit = 10000, stabilityFeeRate = 0.1 → pFeeRate = 0.1, threshold = 9000. + // 15000 FLOW × 0.8 CF / 1.3 target health ≈ 9231 MOET debit. 9231 > 9000 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) - // set 10% annual debit rate - // stability is calculated on interest income, not debit balance directly + // set 10% annual debit rate; credit rate = 0.1 × (1 − 0.1) = 0.09 setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.1) // set stability fee rate (10% of interest income) @@ -84,10 +83,12 @@ fun test_collectStability_success_fullAmount() { let reserveBalanceAfter = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER) Test.assert(reserveBalanceAfter < reserveBalanceBefore, message: "Reserves should have decreased after collection") - let collectedAmount = finalStabilityBalance! - initialStabilityBalance! + // initialStabilityBalance may be nil if the first collection collected nothing (fee ≈ 0) + let collectedAmount = (finalStabilityBalance ?? 0.0) - (initialStabilityBalance ?? 0.0) let amountWithdrawnFromReserves = reserveBalanceBefore - reserveBalanceAfter - // verify the amount withdrawn from reserves equals the collected amount + // With insuranceRate=0 (default), all protocolFee goes to stability, nothing to insurance. + // So amountWithdrawnFromReserves == stabilityCollected == collectedAmount. Test.assertEqual(amountWithdrawnFromReserves, collectedAmount) // verify last stability collection time was updated to current block timestamp @@ -95,21 +96,23 @@ fun test_collectStability_success_fullAmount() { let lastStabilityCollectionTime = getLastStabilityCollectionTime(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) Test.assertEqual(currentTimestamp, lastStabilityCollectionTime!) - // verify formula: stabilityAmount = interestIncome * stabilityFeeRate - // where interestIncome = 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: interestIncome ≈ 615.38 * (1.10517091665 - 1) ≈ 64.72 - // Stability = interestIncome * 0.1 ≈ 6.472 MOET - - // NOTE: - // We intentionally do not use `equalWithinVariance` with `defaultUFixVariance` here. - // The default variance is designed for deterministic math, but insurance collection - // depends on block timestamps, which can differ slightly between test runs. - // A larger, time-aware tolerance is required. + // verify formula (index-based, accounts for credit offset): + // protocolFee = debitIncome - creditIncome + // stabilityAmount = protocolFee × stabilityFeeRate / totalProtocolFeeRate + // + // debitBalance ≈ 15000 × 0.8 / 1.3 ≈ 9230.76923077 MOET + // creditBalance = 10000 MOET + // debitGrowth = e^0.1 ≈ 1.10517091808 + // creditGrowth = e^0.09 ≈ 1.09417428371 (creditRate = debitRate × (1 − 0.1) = 0.09) + // debitIncome = 9230.76923077 × 0.10517091808 ≈ 970.8084 + // creditIncome = 10000 × 0.09417428371 ≈ 941.7428 + // protocolFee ≈ 29.0656 MOET + // stabilityAmt = 29.0656 × 0.1 / 0.1 = 29.065637485 MOET (all to stability since insuranceRate=0) + // + // NOTE: block timestamps vary slightly between runs, so we use a 0.5 MOET tolerance. let tolerance = 0.001 - let expectedCollectedAmount = 6.472 - let diff = expectedCollectedAmount > collectedAmount + let expectedCollectedAmount = 29.065 + let diff = expectedCollectedAmount > collectedAmount ? expectedCollectedAmount - collectedAmount : collectedAmount - expectedCollectedAmount diff --git a/cadence/tests/stability_collection_test.cdc b/cadence/tests/stability_collection_test.cdc index cd515cf2..0986d986 100644 --- a/cadence/tests/stability_collection_test.cdc +++ b/cadence/tests/stability_collection_test.cdc @@ -122,8 +122,9 @@ fun test_collectStability_insufficientReserves() { Test.assertEqual(nil, finalStabilityBalance) Test.assertEqual(reserveBalanceBefore, reserveBalanceAfter) - // time should not change - Test.assertEqual(lastCollectionTimeBefore, lastCollectionTimeAfter) + // In the accumulator model, collectProtocolFees() is always called and updates the timestamp + // even when reserves are insufficient. Fees accumulate in the accumulator until reserves recover. + Test.assert(lastCollectionTimeAfter! > lastCollectionTimeBefore!, message: "Timestamp should be updated even on failed collection") } // ----------------------------------------------------------------------------- @@ -211,10 +212,13 @@ fun test_collectStability_multipleTokens() { // Then borrow FLOW (creates FLOW debit balance) borrowFromPosition(signer: flowBorrower, positionId: 3, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, amount: 500.0, beFailed: false) - // set 10% annual debit rates - // Stability is calculated on interest income, not debit balance directly - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.1) - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) + // set interest rates using KinkCurve (baseRate=10% annual). + // KinkCurve guarantees protocolFee > 0 at any utilization because creditRate scales + // with debit/credit ratio: protocolFee ≈ debitIncome × protocolFeeRate, always positive. + // FixedCurve would require debit/credit > (1 - feeRate) — impossible here because + // moetBorrower's FLOW collateral deposit inflates FLOW credit balance. + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, optimalUtilization: 0.8, baseRate: 0.1, slope1: 0.1, slope2: 0.5) + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, optimalUtilization: 0.8, baseRate: 0.1, slope1: 0.1, slope2: 0.5) // set different stability fee rates for each token type (percentage of interest income) let moetRateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, stabilityFeeRate: 0.1) // 10% From c5cf4db8add803203904703dd5f6123aeb6adf97 Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Mon, 23 Mar 2026 17:16:51 +0100 Subject: [PATCH 3/9] =?UTF-8?q?fix=20failures=20test=5FcollectStability=5F?= =?UTF-8?q?multipleTokens=20and=20test=5FcollectStability=5FmidPeriodRateC?= =?UTF-8?q?hange:=20The=20rewritten=20tests=20used=20setInterestCurveFixed?= =?UTF-8?q?=20at=20low=20utilization=20(~5%),=20where=20creditIncome=20>>?= =?UTF-8?q?=20debitIncome=20=E2=86=92=20protocolFeeIncome=20=3D=200=20with?= =?UTF-8?q?=20the=20new=20formula.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cadence/contracts/FlowALPv0.cdc | 4 +- cadence/tests/stability_collection_test.cdc | 55 ++++++++++++--------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index ad2bb842..034ff127 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -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) @@ -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) diff --git a/cadence/tests/stability_collection_test.cdc b/cadence/tests/stability_collection_test.cdc index 4e4b8708..100938fc 100644 --- a/cadence/tests/stability_collection_test.cdc +++ b/cadence/tests/stability_collection_test.cdc @@ -166,10 +166,12 @@ fun test_collectStability_tinyAmount_roundsToZero_returnsNil() { // ----------------------------------------------------------------------------- access(all) fun test_collectStability_multipleTokens() { - // set 10% annual debit rates - // Stability is calculated on interest income, not debit balance directly - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.1) - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) + // set 10% annual debit rates using KinkCurve with slope1=0/slope2=0 so the rate is + // constant at baseRate=0.1 regardless of utilization. This guarantees + // creditIncome = debitIncome * (1 - protocolFeeRate) and therefore + // protocolFeeIncome > 0 even at low utilization. + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) let moetRateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, stabilityFeeRate: 0.1) Test.expect(moetRateResult, Test.beSucceeded()) @@ -318,8 +320,10 @@ fun test_collectStability_zeroRate_returnsNil() { // ----------------------------------------------------------------------------- access(all) fun test_collectStability_midPeriodRateChange() { - // set interest curve - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) + // set interest curve — KinkCurve with slope1=0/slope2=0 gives constant 10% debit rate, + // which ensures creditIncome = debitIncome * (1 - protocolFeeRate) at any utilization + // so the formula stabilityAmount = debitIncome * stabilityFeeRate holds exactly. + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) // provide FLOW liquidity so the borrower can actually borrow let lp = Test.createAccount() @@ -355,18 +359,18 @@ fun test_collectStability_midPeriodRateChange() { // Advance ONE_YEAR Test.moveTime(by: ONE_YEAR) - // Phase 1 expected stability calculation: - // yearly rate = 0.1 (yearly debit rate, FixedCurve) - // stabilityFeeRate1 = 0.05 (default) + // Phase 1 expected stability calculation (KinkCurve compound formula): + // stabilityFeeRate1 = 0.05 (default), insuranceRate = 0.0 + // protocolFeeRate = 0.05 + // U = totalDebit/totalCredit = 500/10000 = 0.05 // - // stabilityIncome_1 = totalDebitBalance * (pow(perSecondDebitRate, timeElapsed) - 1.0) - // perSecondRate = 1 + (yearlyRate / 31_557_600) - // stabilityAmount = stabilityIncome * stabilityFeeRate - // - // perSecondRate = 1 + (0.1 / 31557600) = 1.00000000317 - // stabilityIncome_1 = 500 * (1.00000000317^31557600 - 1) = 52.58545895 FLOW - // stabilityAmount_1 = stabilityIncome_1 * stabilityFeeRate1 = 52.58545895 * 0.05 = 2.62927294 - let expectedStabilityAmountAfterPhase1 = 2.62927294 + // debitRatePerSec = 0.1 / 31557600 + // debitIncome = 500 * (pow(1 + debitRatePerSec, ONE_YEAR) - 1) ≈ 52.58545895 + // creditIncome = 10000 * (pow(1 + debitRatePerSec * (1-0.05) * 0.05, ONE_YEAR) - 1) + // ≈ 10000 * (e^0.00475 - 1) ≈ 47.61283 + // protocolFeeIncome = debitIncome - creditIncome ≈ 4.97263 + // stabilityAmount_1 = protocolFeeIncome (insuranceRate=0, so 100% to stability) + let expectedStabilityAmountAfterPhase1 = 4.97246762 // change the stability fee rate to 20% for phase 2 var rateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, stabilityFeeRate: 0.2) @@ -388,14 +392,17 @@ fun test_collectStability_midPeriodRateChange() { // Advance another ONE_YEAR Test.moveTime(by: ONE_YEAR) - // Phase 2 expected stability calculation: - // yearly rate = 0.1 (yearly debit rate, FixedCurve) - // stabilityFeeRate2 = 0.2 (fraction of debit income) + // Phase 2 expected stability calculation (KinkCurve compound formula): + // stabilityFeeRate2 = 0.2, insuranceRate = 0.0 + // protocolFeeRate = 0.2 + // U = 500/10000 = 0.05 (unchanged) // - // totalDebitBalance = 500 FLOW (scaled balance — does not compound, index does) - // stabilityIncome_2 = 500 * (1.00000000317^31557600 - 1) = 52.58545895 FLOW - // stabilityAmount_2 = stabilityIncome_2 * stabilityFeeRate2 = 52.58545895 * 0.2 = 10.51709179 - let expectedStabilityAmountAfterPhase2 = 10.51709179 + // debitIncome = 500 * (pow(1 + debitRatePerSec, ONE_YEAR) - 1) ≈ 52.58545895 + // creditIncome = 10000 * (pow(1 + debitRatePerSec * (1-0.2) * 0.05, ONE_YEAR) - 1) + // ≈ 10000 * (e^0.004 - 1) ≈ 40.08011 + // protocolFeeIncome = debitIncome - creditIncome ≈ 12.50535 + // stabilityAmount_2 = protocolFeeIncome (insuranceRate=0, so 100% to stability) + let expectedStabilityAmountAfterPhase2 = 12.50535218 // change the stability rate to 25% rateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, stabilityFeeRate: 0.25) From e6c6b429322c4b206eed4078cc60f16d451b7525 Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Mon, 23 Mar 2026 18:04:57 +0100 Subject: [PATCH 4/9] documentation fix --- .../tests/withdraw_stability_funds_test.cdc | 6 +- docs/interest_rate_and_protocol_fees.md | 177 ++++++++++-------- 2 files changed, 102 insertions(+), 81 deletions(-) diff --git a/cadence/tests/withdraw_stability_funds_test.cdc b/cadence/tests/withdraw_stability_funds_test.cdc index 8da94f1d..aa33c63e 100644 --- a/cadence/tests/withdraw_stability_funds_test.cdc +++ b/cadence/tests/withdraw_stability_funds_test.cdc @@ -56,8 +56,10 @@ fun setupStabilityFundWithBalance(): UFix64 { // borrower deposits FLOW and auto-borrows MOET (creates debit balance) createPosition(admin: PROTOCOL_ACCOUNT, signer: borrower, amount: 1000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) - // set 10% annual debit rate (stability is calculated on interest income) - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.1) + // set 10% annual debit rate using KinkCurve with slope1=0/slope2=0 so the rate is + // constant at baseRate=0.1 regardless of utilization, ensuring protocolFeeIncome > 0 + // even at the ~6% utilization created by this test setup. + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) // set stability fee rate (10% of interest income) let rateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, stabilityFeeRate: 0.1) diff --git a/docs/interest_rate_and_protocol_fees.md b/docs/interest_rate_and_protocol_fees.md index 3ecf29ee..d4449437 100644 --- a/docs/interest_rate_and_protocol_fees.md +++ b/docs/interest_rate_and_protocol_fees.md @@ -8,11 +8,11 @@ The FlowALP protocol uses a dual-rate interest system: - **Debit Rate**: The interest rate charged to borrowers - **Credit Rate**: The interest rate paid to lenders (depositors) -The credit rate is calculated as the debit income minus protocol fees. Protocol fees consist of two components: +The credit rate is derived from the debit rate after deducting protocol fees. Protocol fees consist of two components: - **Insurance Fee**: Collected and converted to MOET to build a permanent insurance reserve for covering bad debt - **Stability Fee**: Collected in native tokens and available for governance withdrawal to ensure MOET stability -Both fees are deducted from interest income to protect the protocol and fund operations. +Both fees come out of the spread between debit and credit income — they do not reduce the borrower's rate, they reduce the fraction of that income returned to lenders. ## Protocol Fee Components @@ -20,15 +20,15 @@ Both fees are deducted from interest income to protect the protocol and fund ope #### Insurance Fund -The insurance fund serves as the protocol's **reserve for covering bad debt**, acting as the liquidator of last resort. A percentage of lender interest income is collected as interest accrues and swapped to MOET, building a safety buffer that grows over time. When there are liquidations that aren't able to be covered and would normally create bad debt in the protocol, the MOET is swapped for that specific asset to cover that delta of bad debt. These funds are **never withdrawable** by governance and exist solely to protect lenders from losses. +The insurance fund serves as the protocol's **reserve for covering bad debt**, acting as the liquidator of last resort. A percentage of protocol spread income is collected as interest accrues and swapped to MOET, building a safety buffer that grows over time. When there are liquidations that aren't able to be covered and would normally create bad debt in the protocol, the MOET is swapped for that specific asset to cover that delta of bad debt. These funds are **never withdrawable** by governance and exist solely to protect lenders from losses. #### Stability Fund -The stability fund provides **flexible funding for MOET stability operations**. A percentage of lender interest income is collected and held in native token vaults (FLOW, USDC, etc.), with each token type having its own separate vault. These funds can be withdrawn by the governance committee via `withdrawStabilityFund()` to improve the stability of MOET at their discretion—whether by adding liquidity to specific pools, repurchasing MOET if it's trading under peg compared to the underlying basket of assets, or other stabilization strategies. All withdrawals emit `StabilityFundWithdrawn` events for public accountability and transparency. +The stability fund provides **flexible funding for MOET stability operations**. A percentage of protocol spread income is collected and held in native token vaults (FLOW, USDC, etc.), with each token type having its own separate vault. These funds can be withdrawn by the governance committee via `withdrawStabilityFund()` to improve the stability of MOET at their discretion—whether by adding liquidity to specific pools, repurchasing MOET if it's trading under peg compared to the underlying basket of assets, or other stabilization strategies. All withdrawals emit `StabilityFundWithdrawn` events for public accountability and transparency. ### Fee Deduction from Lender Returns -Both fees are deducted from the interest income that would otherwise go to lenders: +Both fees are deducted from the spread income that would otherwise go to lenders: ## Interest Rate Calculation @@ -125,46 +125,44 @@ This is the mechanism that helps protect withdrawal liquidity under stress. ### 2. Credit Rate Calculation -The credit rate (deposit nominal yearly rate) is derived from debit-side income after protocol fees. +The credit rate is derived from the **per-second** debit rate, not the nominal annual rate directly. This is important: the protocol computes per-second rates first and then scales the credit side, not the other way around. Shared definitions: ``` protocolFeeRate = insuranceRate + stabilityFeeRate +debitRatePerSecond = perSecondInterestRate(yearlyRate: debitRate) - 1.0 ``` and `protocolFeeRate` must be `< 1.0`. For **FixedCurve** (used for stable assets like MOET): ``` -creditRate = debitRate * (1 - protocolFeeRate) +currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate) ``` -This gives a simple spread model between borrow and lend nominal yearly rates. +The per-second credit excess is the debit excess scaled down by `(1 - protocolFeeRate)`. This is a fixed spread at the per-second level, independent of utilization. -For **KinkCurve** and other non-fixed curves: +For **KinkCurve** and other non-fixed curves (reserve factor model): ``` -debitIncome = totalDebitBalance * debitRate -protocolFeeRate = insuranceRate + stabilityFeeRate -protocolFeeAmount = debitIncome * protocolFeeRate -creditRate = (debitIncome - protocolFeeAmount) / totalCreditBalance +currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate) * totalDebitBalance / totalCreditBalance ``` -This computes lender yield from actual debit-side income, after reserve deductions. +The per-second credit excess is further scaled by the utilization ratio (`totalDebit / totalCredit`). This ensures the total interest paid by borrowers equals the total interest earned by lenders plus protocol fees, regardless of the utilization level. **Important**: The combined `insuranceRate + stabilityFeeRate` must be less than 1.0 to avoid underflow in credit rate calculation. This is enforced by preconditions when setting either rate. ### 3. Per-Second Rate Conversion -Both credit and debit rates are converted from nominal annual rates to per-second compounding rates: +The nominal annual debit rate is converted to a per-second compounding rate: ``` -perSecondRate = (yearlyRate / secondsInYear) + 1.0 +perSecondDebitRate = (yearlyRate / secondsInYear) + 1.0 ``` Where `secondsInYear = 31_557_600` (365.25 days × 24 hours × 60 minutes × 60 seconds). -This conversion allows for discrete per-second compounding of interest over time. +The credit rate is **not** converted from an annual rate independently — it is derived directly in per-second terms from the per-second debit rate (see Section 2 above). Important terminology: the configured `yearlyRate` is a **nominal yearly rate**, not a promise that a balance will grow by exactly that percentage over one calendar year. For positive fixed rates, the effective one-year growth is slightly higher because of compounding. For variable curves, realized growth also depends on when utilization changes and the rate is recomputed. @@ -220,34 +218,68 @@ This design allows: Interest indices are updated whenever: 1. A user interacts with the protocol (deposit, withdraw, borrow, repay) 2. `updateForTimeChange()` is called explicitly -3. `updateInterestRatesAndCollectInsurance()` or `updateInterestRatesAndCollectStability()` is called The update calculates the time elapsed since `lastUpdate` and compounds the interest indices accordingly. When rates are variable, realized growth over a period depends on the sequence of utilization changes and the rate recomputations they trigger, so the displayed yearly rate should not be interpreted as an exact promised one-year payoff. +## Protocol Fee Accumulation + +### `collectProtocolFees()` Accumulator + +Insurance and stability fees are both computed in a single `TokenState.collectProtocolFees()` method. It is called automatically before every balance or rate mutation to ensure fees settle at the rate that was in effect when they accrued: + +``` +timeElapsed = currentTime - lastProtocolFeeCollectionTime +debitIncome = totalDebitBalance * (currentDebitRate ^ timeElapsed - 1.0) +creditIncome = totalCreditBalance * (currentCreditRate ^ timeElapsed - 1.0) +protocolFeeIncome = max(0, debitIncome - creditIncome) + +insuranceFeeAmount = protocolFeeIncome * insuranceRate / (insuranceRate + stabilityFeeRate) +stabilityFeeAmount = protocolFeeIncome - insuranceFeeAmount + +accumulatedInsuranceFeeIncome += insuranceFeeAmount +accumulatedStabilityFeeIncome += stabilityFeeAmount +lastProtocolFeeCollectionTime = currentTime +``` + +`protocolFeeIncome` is the spread between what borrowers pay and what lenders earn. At any utilization level, this is a positive number as long as `protocolFeeRate > 0`. At zero utilization (no borrowers) or zero protocol fee rate, `protocolFeeIncome = 0`. + +The two accumulators are read and reset by `_withdrawInsurance` and `_withdrawStability` respectively. + +### When Fees Are Settled + +`collectProtocolFees()` is triggered on every mutation that could change the fee calculation: +- `increaseCreditBalance` / `decreaseCreditBalance` +- `increaseDebitBalance` / `decreaseDebitBalance` +- `setInterestCurve` +- `setInsuranceRate` +- `setStabilityFeeRate` + +This means changing any rate always settles all accrued fees at the **old** rate first, then applies the new rate going forward. + ## Insurance Collection Mechanism ### Overview -The insurance mechanism collects a percentage of interest income over time, swaps it from the underlying token to MOET, and deposits it into a **permanent, non-withdrawable** protocol insurance fund. This fund accumulates over time and can be used to cover protocol losses or other insurance-related purposes. +The insurance mechanism collects a share of protocol spread income over time, swaps it from the underlying token to MOET, and deposits it into a **permanent, non-withdrawable** protocol insurance fund. This fund accumulates over time and can be used to cover protocol losses or other insurance-related purposes. ### Insurance Rate -Each token has a configurable `insuranceRate` (default: 0.0) that represents the fraction of accrued interest income that should be collected as insurance when fees are settled. +Each token has a configurable `insuranceRate` (default: 0.0) that represents the fraction of protocol spread income allocated to insurance when fees are settled. ### Collection Process -Insurance is collected through the `collectInsurance()` function on `TokenState`, which: +Insurance is collected through `collectInsurance()` function on `Pool` in `FlowALPv0`, which: -1. **Calculates Accrued Insurance**: +1. **Reads the Accumulated Insurance**: ``` - timeElapsed = currentTime - lastInsuranceCollectionTime - debitIncome = totalDebitBalance * (currentDebitRate ^ timeElapsed - 1.0) - insuranceAmount = debitIncome * insuranceRate + insuranceAmount = tokenState.accumulatedInsuranceFeeIncome + (reset to 0 after reading) ``` + The accumulation itself happens in `collectProtocolFees()` (see above). 2. **Withdraws from Reserves**: - Withdraws the calculated insurance amount from the token's reserve vault - - If reserves are insufficient, collects only what's available + - If reserves are insufficient, no collection occurs and the accumulated amount remains for the next attempt 3. **Swaps to MOET**: - Uses the token's configured `insuranceSwapper` to swap from the underlying token to MOET @@ -258,28 +290,6 @@ Insurance is collected through the `collectInsurance()` function on `TokenState` - The collected MOET is deposited into the protocol's insurance fund - This fund grows as insurance is collected and is never withdrawable -### Integration with Rate Updates - -Insurance collection is integrated with interest rate updates through `updateInterestRatesAndCollectInsurance()`: - -```cadence -access(self) fun updateInterestRatesAndCollectInsurance(tokenType: Type) { - // 1. Update interest rates - tokenState.updateInterestRates() - - // 2. Collect insurance - if let collectedMOET <- tokenState.collectInsurance(reserveVault: reserveRef) { - // 3. Deposit into insurance fund - insuranceFund.deposit(from: <-collectedMOET) - } -} -``` - -This ensures that: -- Interest rates are recalculated based on current pool state -- Insurance is collected proportionally to time elapsed -- Collected MOET is automatically deposited into the insurance fund - ### Configuration Requirements Before setting a non-zero insurance rate, an insurance swapper must be configured: @@ -312,26 +322,26 @@ The Pool maintains a single `insuranceFund` vault that stores all collected MOET ### Overview -The stability fee mechanism collects a percentage of interest income over time and holds it in **native token vaults** that are **withdrawable by governance**. These funds are intended to be used for ensuring the stability of MOET. +The stability fee mechanism collects a share of protocol spread income over time and holds it in **native token vaults** that are **withdrawable by governance**. These funds are intended to be used for ensuring the stability of MOET. ### Stability Fee Rate -Each token has a configurable `stabilityFeeRate` (default: 0.05 or 5%) that represents the fraction of accrued interest income that should be collected as stability fees when they are settled. +Each token has a configurable `stabilityFeeRate` (default: 0.05 or 5%) that represents the fraction of protocol spread income allocated to stability fees when they are settled. ### Collection Process -Stability fees are collected through the `collectStability()` function on `TokenState`, which: +Stability fees are collected through `_withdrawStability()` in `FlowALPv0`, which: -1. **Calculates Accrued Stability Fee**: +1. **Reads the Accumulated Stability Fee**: ``` - timeElapsed = currentTime - lastStabilityFeeCollectionTime - interestIncome = totalDebitBalance * (currentDebitRate ^ timeElapsed - 1.0) - stabilityAmount = interestIncome * stabilityFeeRate + stabilityAmount = tokenState.accumulatedStabilityFeeIncome + (reset to 0 after reading) ``` + The accumulation itself happens in `collectProtocolFees()` (see above). 2. **Withdraws from Reserves**: - Withdraws the calculated stability amount from the token's reserve vault - - If reserves are insufficient, collects only what's available + - If reserves are insufficient, no collection occurs and the accumulated amount remains for the next attempt 3. **Deposits to Stability Fund**: - The collected tokens are deposited into the token-specific stability fund vault @@ -362,26 +372,33 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. ## Example Flow -### Scenario: Protocol with Active Lending +### Scenario: Protocol with Active Lending (KinkCurve) 1. **Initial State**: - Total credit balance (lender deposits): 10,000 FLOW - - Total debit balance (borrower debt): 8,000 FLOW - - Debit rate: 10% nominal yearly rate - - Insurance rate: 0.1% (of interest income) - - Stability fee rate: 5% (of interest income) - -2. **After 1 Year**: - - Current debit rate = 0.10 / 31_557_600 + 1.0 = 1.00000000316880... - - Debit income: 8,000 × (1.00000000316880^31_557_600 - 1.0) = 841.37 FLOW - - Insurance collection: 841.37 × 0.001 = 0.841 FLOW → converted to MOET - - Stability collection: 841.37 × 0.05 = 42.07 FLOW → kept as FLOW - - Net to lenders: 841.37 - 0.841 - 42.07 = 798.46 FLOW - - Effective lender yield over the year: 798.46 / 10,000 = 7.98% - -3. **Fund Accumulation**: - - Insurance fund: +0.841 FLOW worth of MOET (permanent, for bad debt coverage) - - Stability fund (FLOW): +42.07 FLOW (available for MOET stability operations) + - Total debit balance (borrower debt): 8,000 FLOW → utilization U = 0.8 + - Debit nominal yearly rate: 10% + - Insurance rate: 0.1% (`0.001`) + - Stability fee rate: 5% (`0.05`) + - `protocolFeeRate = 0.001 + 0.05 = 0.051` + +2. **Per-Second Rates**: + - `debitRatePerSec = 0.10 / 31_557_600 ≈ 3.169e-9` + - `currentDebitRate = 1 + 3.169e-9` + - KinkCurve path: `currentCreditRate = 1 + 3.169e-9 × (1 - 0.051) × 0.8 = 1 + 2.406e-9` + +3. **After 1 Year**: + - `debitIncome = 8,000 × (perSecondDebitRate ^ 31_557_600 − 1) ≈ 841.37 FLOW` + - `creditIncome = 10,000 × (perSecondCreditRate ^ 31_557_600 − 1) ≈ 792.54 FLOW` + - `protocolFeeIncome = 841.37 − 792.54 = 48.83 FLOW` + - `insuranceFee = 48.83 × 0.001 / 0.051 ≈ 0.957 FLOW` → converted to MOET + - `stabilityFee = 48.83 × 0.050 / 0.051 ≈ 47.87 FLOW` → kept as FLOW + - Net lender return = creditIncome = 792.54 FLOW + - Effective lender yield over the year: 792.54 / 10,000 ≈ 7.93% + +4. **Fund Accumulation**: + - Insurance fund: +0.957 FLOW worth of MOET (permanent, for bad debt coverage) + - Stability fund (FLOW): +47.87 FLOW (available for MOET stability operations) ## Key Design Decisions @@ -397,11 +414,13 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. 6. **Stability Flexibility**: Keeping stability funds withdrawable in native tokens gives governance flexibility to defend MOET's peg through various strategies. -7. **Time-Based Collection**: Both insurance and stability fees are collected based on time elapsed, ensuring consistent accumulation regardless of transaction frequency. +7. **Spread-Based Fee Formula**: Fees are taken from `debitIncome - creditIncome` (the spread), not from gross debit income. This ensures that at zero utilization or zero protocol fee rate no fees are collected, and that the fee split between insurance and stability is always exact regardless of curve type. + +8. **Single `collectProtocolFees()` Accumulator**: A single shared accumulator and timestamp for both fees ensures they always use the same elapsed-time window and that a rate change for one fee type does not inadvertently double-count or skip the other. -8. **Token-Specific Swappers**: Each token can have its own insurance swapper, allowing flexibility in how different tokens are converted to MOET. +9. **Token-Specific Swappers**: Each token can have its own insurance swapper, allowing flexibility in how different tokens are converted to MOET. -9. **Unified Insurance Fund**: All collected MOET goes into a single fund, providing a centralized insurance reserve for the protocol. +10. **Unified Insurance Fund**: All collected MOET goes into a single fund, providing a centralized insurance reserve for the protocol. ## Security Considerations @@ -409,7 +428,7 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. - **Swapper Validation**: Insurance swappers are validated when set to ensure they output MOET - **Bidirectional Constraints**: Cannot set non-zero insurance rate without swapper; cannot remove swapper with non-zero rate - **Reserve Checks**: Both collection mechanisms check that sufficient reserves exist before withdrawing -- **Timestamp Tracking**: Separate timestamps (`lastInsuranceCollectionTime`, `lastStabilityFeeCollectionTime`) prevent double-counting +- **Timestamp Tracking**: A single `lastProtocolFeeCollectionTime` shared by both fees prevents double-counting across the insurance/stability split - **Precision**: Uses UFix128 for internal calculations to maintain precision during compounding - **Access Control**: Only governance (EGovernance entitlement) can modify rates or withdraw stability funds - **Insurance Lock**: No withdrawal function exists for insurance fund @@ -420,7 +439,7 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. | Parameter | Description | Default | |-----------|-------------|---------| -| `insuranceRate` | Percentage of interest income collected for insurance | 0.0 | +| `insuranceRate` | Fraction of protocol spread income allocated to insurance | 0.0 | | `insuranceSwapper` | Swapper to convert tokens to MOET (required before enabling) | nil | These parameters allow the protocol to adjust insurance collection based on risk assessment and market conditions. @@ -429,7 +448,7 @@ These parameters allow the protocol to adjust insurance collection based on risk | Parameter | Description | Default | |-----------|-------------|---------| -| `stabilityFeeRate` | Percentage of interest income collected for stability | 0.05 (5%) | +| `stabilityFeeRate` | Fraction of protocol spread income allocated to stability fees | 0.05 (5%) | ### Governance Functions From a3791781c6067cfc480769a841791bdfed6497cd Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Mon, 23 Mar 2026 18:23:26 +0100 Subject: [PATCH 5/9] minor fixes --- .../tests/insurance_collection_formula_test.cdc | 17 ++++------------- .../tests/stability_collection_formula_test.cdc | 14 ++++---------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/cadence/tests/insurance_collection_formula_test.cdc b/cadence/tests/insurance_collection_formula_test.cdc index 73b6723e..61313a6d 100644 --- a/cadence/tests/insurance_collection_formula_test.cdc +++ b/cadence/tests/insurance_collection_formula_test.cdc @@ -43,9 +43,9 @@ fun test_collectInsurance_success_fullAmount() { // LP deposits MOET (creates credit balance, provides borrowing liquidity) createPosition(admin: PROTOCOL_ACCOUNT, signer: lp, amount: 10000.0, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) - // setup borrower with enough FLOW so MOET debit > MOET credit × (1 − pFeeRate). - // MOET credit = 10000, pFeeRate = 0.15 → threshold = 8500. - // 15000 FLOW × 0.8 CF / 1.3 target health ≈ 9231 MOET debit. 9231 > 8500 + // setup borrower with FLOW collateral + // With 0.8 CF and 1.3 target health: 15000 FLOW collateral allows borrowing ~9231 MOET + // 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: 15000.0) @@ -116,16 +116,7 @@ fun test_collectInsurance_success_fullAmount() { // protocolFee = 970.808555393 - 887.170667 = 83.637888393 MOET // insuranceAmt = 83.637888393 × 0.1 / 0.15 ≈ 55.758 MOET // - // NOTE: - // We intentionally do not use `equalWithinVariance` with `defaultUFixVariance` here. - // The default variance is designed for deterministic math, but insurance collection - // depends on block timestamps, which can differ slightly between test runs. - // A larger, time-aware tolerance is required. - let tolerance = 0.001 let expectedCollectedAmount = 55.758 - let diff = expectedCollectedAmount > collectedInsuranceAmount - ? expectedCollectedAmount - collectedInsuranceAmount - : collectedInsuranceAmount - expectedCollectedAmount - Test.assert(diff < tolerance, message: "Insurance collected should be around \(expectedCollectedAmount) but current \(collectedInsuranceAmount)") + Test.assert(equalWithinVariance(expectedCollectedAmount, collectedInsuranceAmount, 0.001), message: "Insurance collected should be around \(expectedCollectedAmount) but current \(collectedInsuranceAmount)") } diff --git a/cadence/tests/stability_collection_formula_test.cdc b/cadence/tests/stability_collection_formula_test.cdc index 58ca6393..7183cd36 100644 --- a/cadence/tests/stability_collection_formula_test.cdc +++ b/cadence/tests/stability_collection_formula_test.cdc @@ -43,9 +43,9 @@ fun test_collectStability_success_fullAmount() { // LP deposits MOET (creates credit balance, provides borrowing liquidity) createPosition(admin: PROTOCOL_ACCOUNT, signer: lp, amount: 10000.0, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) - // setup borrower with enough FLOW so MOET debit > MOET credit × (1 − pFeeRate). - // MOET credit = 10000, stabilityFeeRate = 0.1 → pFeeRate = 0.1, threshold = 9000. - // 15000 FLOW × 0.8 CF / 1.3 target health ≈ 9231 MOET debit. 9231 > 9000 + // setup borrower with FLOW collateral + // With 0.8 CF and 1.3 target health: 15000 FLOW collateral allows borrowing ~9231 MOET + // 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: 15000.0) @@ -109,12 +109,6 @@ fun test_collectStability_success_fullAmount() { // protocolFee ≈ 29.0656 MOET // stabilityAmt = 29.0656 × 0.1 / 0.1 = 29.065637485 MOET (all to stability since insuranceRate=0) // - // NOTE: block timestamps vary slightly between runs, so we use a 0.5 MOET tolerance. - let tolerance = 0.001 let expectedCollectedAmount = 29.065 - let diff = expectedCollectedAmount > collectedAmount - ? expectedCollectedAmount - collectedAmount - : collectedAmount - expectedCollectedAmount - - Test.assert(diff < tolerance, message: "Stability collected should be around \(expectedCollectedAmount) but current \(collectedAmount)") + Test.assert(equalWithinVariance(expectedCollectedAmount, collectedAmount, 0.001), message: "Stability collected should be around \(expectedCollectedAmount) but current \(collectedAmount)") } \ No newline at end of file From edd56a929c7dac863043ac87b5f722ca6e70ab07 Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Mon, 23 Mar 2026 18:40:23 +0100 Subject: [PATCH 6/9] fix tests --- cadence/tests/insurance_collection_test.cdc | 66 ++++++++++----------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/cadence/tests/insurance_collection_test.cdc b/cadence/tests/insurance_collection_test.cdc index cd0dbfb5..cd8d4506 100644 --- a/cadence/tests/insurance_collection_test.cdc +++ b/cadence/tests/insurance_collection_test.cdc @@ -247,10 +247,11 @@ 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; credit rate = 0.1 × (1 − 0.15) = 0.085 - 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) - // set insurance rate (10% of debit income) + // set insurance rate (10% of spread income) let rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, insuranceRate: 0.1) Test.expect(rateResult, Test.beSucceeded()) @@ -338,10 +339,10 @@ fun test_collectInsurance_multipleTokens() { let flowSwapperResult = setInsuranceSwapper(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, priceRatio: 1.0) Test.expect(flowSwapperResult, Test.beSucceeded()) - // set 10% annual debit rates - // Insurance is calculated on debit income, not debit balance directly - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.1) - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) + // set 10% annual debit rates using KinkCurve (slope1=0/slope2=0 → constant rate), ensuring + // protocolFeeIncome > 0 at the low utilization levels created by this test setup. + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) // set different insurance rates for each token type (percentage of debit income) let moetRateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, insuranceRate: 0.1) // 10% @@ -438,8 +439,9 @@ fun test_collectInsurance_dexOracleSlippageProtection() { let swapperResult = setInsuranceSwapper(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, priceRatio: 0.5) Test.expect(swapperResult, Test.beSucceeded()) - // set 10% annual debit rate; credit rate = debitRate × (1 − protocolFeeRate) = 0.1 × 0.85 - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) + // set 10% annual debit rate using KinkCurve (slope1=0/slope2=0 → constant rate), ensuring + // protocolFeeIncome > 0 at 20% utilization so the slippage check is actually triggered. + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) let rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.1) Test.expect(rateResult, Test.beSucceeded()) @@ -479,8 +481,9 @@ fun test_collectInsurance_midPeriodRateChange() { let swapperResult = setInsuranceSwapper(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, priceRatio: 1.0) Test.expect(swapperResult, Test.beSucceeded()) - // set interest curve - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) + // set interest curve — KinkCurve with slope1=0/slope2=0 gives constant 10% debit rate, + // ensuring protocolFeeIncome > 0 at any utilization (5% here: 500 debit / 10000 credit). + setInterestCurveKink(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, optimalUtilization: 0.9, baseRate: 0.1, slope1: 0.0, slope2: 0.0) // set insurance rate var rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.1) Test.expect(rateResult, Test.beSucceeded()) @@ -519,18 +522,17 @@ fun test_collectInsurance_midPeriodRateChange() { // Advance to ONE_YEAR Test.moveTime(by: ONE_YEAR) - // Phase 1 expected insurance calculation: - // yearly rate = 0.1 (yearly debit rate, FixedCurve) - // insuranceRate1 = 0.1 (fraction of debit income) + // Phase 1 expected insurance calculation (KinkCurve compound formula): + // insuranceRate1 = 0.1, stabilityFeeRate = 0.05 (default), protocolFeeRate = 0.15 + // U = 500/10000 = 0.05 // - // debitIncome_1 = totalDebitBalance * (pow(perSecondDebitRate, timeElapsed) - 1.0) - // perSecondRate = 1 + (yearlyRate / 31_557_600) - // insuranceAmount = debitIncome * insuranceRate - // - // perSecondRate = 1 + (0.1 / 31557600) = 1.00000000317 - // debitIncome_1 = 500 * (1.00000000317^31557600 - 1) = 52.58545895 FLOW - // insuranceAmount = debitIncome_1 * insurRate1 = 52.58545895 * 0.1 = 5.25854589 - let expectedCollectedInsuranceAmountAfterPhase1 = 5.25854589 + // debitRatePerSec = 0.1 / 31557600 + // debitIncome = 500 * (pow(1 + debitRatePerSec, ONE_YEAR) - 1) ≈ 52.58545895 + // creditIncome = 10000 * (pow(1 + debitRatePerSec * (1-0.15) * 0.05, ONE_YEAR) - 1) + // ≈ 10000 * (e^0.00425 - 1) ≈ 42.59044 + // protocolFeeIncome = debitIncome - creditIncome ≈ 9.99502 + // insuranceFeeAmount_1 = protocolFeeIncome * 0.1 / 0.15 ≈ 6.66334 + let expectedCollectedInsuranceAmountAfterPhase1 = 6.66334 // change the insurance rate to 20% for phase 2 rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.2) @@ -549,18 +551,16 @@ fun test_collectInsurance_midPeriodRateChange() { Test.moveTime(by: ONE_YEAR) - // Phase 2 expected insurance calculation: - // yearly rate = 0.1 (yearly debit rate, FixedCurve) - // insuranceRate2 = 0.2 (fraction of debit income) - // - // debitIncome_2 = totalDebitBalance * (pow(perSecondDebitRate, timeElapsed) - 1.0) - // perSecondRate = 1 + (yearlyRate / 31_557_600) - // insuranceAmount_2 = debitIncome * insuranceRate2 + // Phase 2 expected insurance calculation (KinkCurve compound formula): + // insuranceRate2 = 0.2, stabilityFeeRate = 0.05 (default), protocolFeeRate = 0.25 + // U = 0.05 (unchanged) // - // perSecondRate = 1 + (0.1 / 31557600) = 1.00000000317 - // debitIncome_2 = 500 * (1.00000000317^31557600 - 1) = 52.58545895 FLOW - // insuranceAmount_2 = debitIncome_2 * insuranceRate2 = 52.58545895 * 0.2 = 10.51709179 - let expectedCollectedInsuranceAmountAfterPhase2 = 10.51709179 + // debitIncome = 500 * (pow(1 + debitRatePerSec, ONE_YEAR) - 1) ≈ 52.58545895 + // creditIncome = 10000 * (pow(1 + debitRatePerSec * (1-0.25) * 0.05, ONE_YEAR) - 1) + // ≈ 10000 * (e^0.00375 - 1) ≈ 37.57040 + // protocolFeeIncome = debitIncome - creditIncome ≈ 15.01506 + // insuranceFeeAmount_2 = protocolFeeIncome * 0.2 / 0.25 ≈ 12.01205 + let expectedCollectedInsuranceAmountAfterPhase2 = 12.01205 // change the insurance rate to 25% rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.25) From 41828a54d6f7edd458e680a0199df8686726249e Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Fri, 27 Mar 2026 13:25:08 -0300 Subject: [PATCH 7/9] Apply suggestion from @zhangchiqing Co-authored-by: Leo Zhang --- cadence/contracts/FlowALPModels.cdc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index e37886bb..305f39af 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -1538,6 +1538,9 @@ access(all) contract FlowALPModels { /// 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. + /// Note: This function only accrues fee income and does not withdraw it from the reserve. + /// This is intentional—if the protocol becomes insolvent, fees should remain in the reserve + /// and continue to accumulate until the protocol recovers, rather than being withdrawn. access(EImplementation) fun collectProtocolFees() { let currentTime = getCurrentBlock().timestamp From aafbae2c589448fa6ab8ee1daa07e7e2ed70fbd7 Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Fri, 27 Mar 2026 14:30:30 -0300 Subject: [PATCH 8/9] Apply suggestions from code review Co-authored-by: Leo Zhang --- cadence/contracts/FlowALPModels.cdc | 5 ++++- docs/interest_rate_and_protocol_fees.md | 21 +++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index 305f39af..2bdff6b6 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -1541,7 +1541,10 @@ access(all) contract FlowALPModels { /// Note: This function only accrues fee income and does not withdraw it from the reserve. /// This is intentional—if the protocol becomes insolvent, fees should remain in the reserve /// and continue to accumulate until the protocol recovers, rather than being withdrawn. - access(EImplementation) fun collectProtocolFees() { + /// Note: This function only accrues fee income and does not withdraw it from the reserve. + /// This is intentional—if the protocol becomes insolvent, fees should remain in the reserve + /// and continue to accumulate until the protocol recovers, rather than being withdrawn. + access(EImplementation) fun accumulateProtocolFees() { let currentTime = getCurrentBlock().timestamp let totalProtocolFeeRate = self.insuranceRate + self.stabilityFeeRate diff --git a/docs/interest_rate_and_protocol_fees.md b/docs/interest_rate_and_protocol_fees.md index d4449437..6eeaef21 100644 --- a/docs/interest_rate_and_protocol_fees.md +++ b/docs/interest_rate_and_protocol_fees.md @@ -24,7 +24,7 @@ The insurance fund serves as the protocol's **reserve for covering bad debt**, a #### Stability Fund -The stability fund provides **flexible funding for MOET stability operations**. A percentage of protocol spread income is collected and held in native token vaults (FLOW, USDC, etc.), with each token type having its own separate vault. These funds can be withdrawn by the governance committee via `withdrawStabilityFund()` to improve the stability of MOET at their discretion—whether by adding liquidity to specific pools, repurchasing MOET if it's trading under peg compared to the underlying basket of assets, or other stabilization strategies. All withdrawals emit `StabilityFundWithdrawn` events for public accountability and transparency. +The stability fund provides **flexible funding for MOET stability operations**. A percentage of protocol spread income (borrower income − lender cost) is collected and held in native token vaults (FLOW, USDC, etc.), with each token type having its own separate vault. These funds can be withdrawn by the governance committee via `withdrawStabilityFund()` to improve the stability of MOET at their discretion—whether by adding liquidity to specific pools, repurchasing MOET if it's trading under peg compared to the underlying basket of assets, or other stabilization strategies. All withdrawals emit `StabilityFundWithdrawn` events for public accountability and transparency. ### Fee Deduction from Lender Returns @@ -138,14 +138,16 @@ and `protocolFeeRate` must be `< 1.0`. For **FixedCurve** (used for stable assets like MOET): ``` -currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate) +creditRatePerSecond = debitRatePerSecond * (1.0 - protocolFeeRate) +currentCreditRate = 1.0 + creditRatePerSecond ``` The per-second credit excess is the debit excess scaled down by `(1 - protocolFeeRate)`. This is a fixed spread at the per-second level, independent of utilization. For **KinkCurve** and other non-fixed curves (reserve factor model): ``` -currentCreditRate = 1.0 + debitRatePerSecond * (1.0 - protocolFeeRate) * totalDebitBalance / totalCreditBalance +creditRatePerSecond = debitRatePerSecond * (1.0 - protocolFeeRate) * totalDebitBalance / totalCreditBalance +currentCreditRate = 1.0 + creditRatePerSecond ``` The per-second credit excess is further scaled by the utilization ratio (`totalDebit / totalCredit`). This ensures the total interest paid by borrowers equals the total interest earned by lenders plus protocol fees, regardless of the utilization level. @@ -228,9 +230,11 @@ The update calculates the time elapsed since `lastUpdate` and compounds the inte Insurance and stability fees are both computed in a single `TokenState.collectProtocolFees()` method. It is called automatically before every balance or rate mutation to ensure fees settle at the rate that was in effect when they accrued: ``` -timeElapsed = currentTime - lastProtocolFeeCollectionTime -debitIncome = totalDebitBalance * (currentDebitRate ^ timeElapsed - 1.0) -creditIncome = totalCreditBalance * (currentCreditRate ^ timeElapsed - 1.0) +currentDebitRate = 1.0 + debitRatePerSecond +currentCreditRate = 1.0 + creditRatePerSecond +secondsElapsed = currentTime - lastProtocolFeeCollectionTime +debitIncome = totalDebitBalance * (currentDebitRate ^ secondsElapsed - 1.0) +creditIncome = totalCreditBalance * (currentCreditRate ^ secondsElapsed - 1.0) protocolFeeIncome = max(0, debitIncome - creditIncome) insuranceFeeAmount = protocolFeeIncome * insuranceRate / (insuranceRate + stabilityFeeRate) @@ -384,10 +388,11 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. 2. **Per-Second Rates**: - `debitRatePerSec = 0.10 / 31_557_600 ≈ 3.169e-9` - - `currentDebitRate = 1 + 3.169e-9` - - KinkCurve path: `currentCreditRate = 1 + 3.169e-9 × (1 - 0.051) × 0.8 = 1 + 2.406e-9` + - `creditRatePerSec = 3.169e-9 × (1 - 0.051) × 0.8 = 2.406e-9` 3. **After 1 Year**: + - perSecondDebitRate = 1.0 + debitRatePerSec + - perSecondCreditRate = 1.0 + creditRatePerSec - `debitIncome = 8,000 × (perSecondDebitRate ^ 31_557_600 − 1) ≈ 841.37 FLOW` - `creditIncome = 10,000 × (perSecondCreditRate ^ 31_557_600 − 1) ≈ 792.54 FLOW` - `protocolFeeIncome = 841.37 − 792.54 = 48.83 FLOW` From 0ff2f429eda65474600d700585197575b89b1092 Mon Sep 17 00:00:00 2001 From: Taras Maliarchuk Date: Fri, 27 Mar 2026 21:35:07 +0100 Subject: [PATCH 9/9] review fix --- cadence/contracts/FlowALPModels.cdc | 22 +++----- cadence/contracts/FlowALPv0.cdc | 4 +- .../insurance_collection_formula_test.cdc | 9 ++-- cadence/tests/insurance_collection_test.cdc | 5 +- cadence/tests/stability_collection_test.cdc | 2 +- docs/interest_rate_and_protocol_fees.md | 50 ++++++++++--------- 6 files changed, 46 insertions(+), 46 deletions(-) diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index 2bdff6b6..1647be14 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -970,7 +970,7 @@ access(all) contract FlowALPModels { /// Accumulates protocol fees (insurance + stability) for elapsed time since last collection. /// Called before any balance or rate change to capture fees at the current rates and balances. - access(EImplementation) fun collectProtocolFees() + access(EImplementation) fun accumulateProtocolFees() /// Per-position limit fraction of capacity (default 0.05 i.e., 5%) access(all) view fun getDepositLimitFraction(): UFix64 @@ -1307,7 +1307,7 @@ access(all) contract FlowALPModels { /// Sets the insurance rate. See TokenState.setInsuranceRate. access(EImplementation) fun setInsuranceRate(_ rate: UFix64) { - self.collectProtocolFees() + self.accumulateProtocolFees() self.insuranceRate = rate self.updateForUtilizationChange() } @@ -1351,7 +1351,7 @@ access(all) contract FlowALPModels { /// Sets the stability fee rate. See TokenState.setStabilityFeeRate. access(EImplementation) fun setStabilityFeeRate(_ rate: UFix64) { - self.collectProtocolFees() + self.accumulateProtocolFees() self.stabilityFeeRate = rate self.updateForUtilizationChange() } @@ -1363,7 +1363,7 @@ access(all) contract FlowALPModels { /// Sets the interest curve. Recalculates interest rates immediately. See TokenState.setInterestCurve. access(EImplementation) fun setInterestCurve(_ curve: {FlowALPInterestRates.InterestCurve}) { - self.collectProtocolFees() + self.accumulateProtocolFees() self.interestCurve = curve // Update rates immediately to reflect the new curve self.updateInterestRates() @@ -1501,14 +1501,14 @@ access(all) contract FlowALPModels { /// Increases total credit balance by the given amount and recalculates interest rates. access(EImplementation) fun increaseCreditBalance(by amount: UFix128) { - self.collectProtocolFees() + self.accumulateProtocolFees() self.totalCreditBalance = self.totalCreditBalance + amount self.updateForUtilizationChange() } /// Decreases total credit balance by the given amount (floored at 0) and recalculates interest rates. access(EImplementation) fun decreaseCreditBalance(by amount: UFix128) { - self.collectProtocolFees() + self.accumulateProtocolFees() if amount >= self.totalCreditBalance { self.totalCreditBalance = 0.0 } else { @@ -1519,14 +1519,14 @@ access(all) contract FlowALPModels { /// Increases total debit balance by the given amount and recalculates interest rates. access(EImplementation) fun increaseDebitBalance(by amount: UFix128) { - self.collectProtocolFees() + self.accumulateProtocolFees() self.totalDebitBalance = self.totalDebitBalance + amount self.updateForUtilizationChange() } /// Decreases total debit balance by the given amount (floored at 0) and recalculates interest rates. access(EImplementation) fun decreaseDebitBalance(by amount: UFix128) { - self.collectProtocolFees() + self.accumulateProtocolFees() if amount >= self.totalDebitBalance { self.totalDebitBalance = 0.0 } else { @@ -1541,9 +1541,6 @@ access(all) contract FlowALPModels { /// Note: This function only accrues fee income and does not withdraw it from the reserve. /// This is intentional—if the protocol becomes insolvent, fees should remain in the reserve /// and continue to accumulate until the protocol recovers, rather than being withdrawn. - /// Note: This function only accrues fee income and does not withdraw it from the reserve. - /// This is intentional—if the protocol becomes insolvent, fees should remain in the reserve - /// and continue to accumulate until the protocol recovers, rather than being withdrawn. access(EImplementation) fun accumulateProtocolFees() { let currentTime = getCurrentBlock().timestamp @@ -1554,9 +1551,6 @@ access(all) contract FlowALPModels { } let timeElapsed = currentTime - self.lastProtocolFeeCollectionTime - if timeElapsed <= 0.0 { - return - } let debitIncome = self.totalDebitBalance * (FlowALPMath.powUFix128(self.currentDebitRate, timeElapsed) - 1.0) let creditIncome = self.totalCreditBalance * (FlowALPMath.powUFix128(self.currentCreditRate, timeElapsed) - 1.0) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 034ff127..42bc212e 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1935,7 +1935,7 @@ access(all) contract FlowALPv0 { maxDeviationBps: UInt16 ): @MOET.Vault? { - tokenState.collectProtocolFees() + tokenState.accumulateProtocolFees() let insuranceAmount = tokenState.getCollectInsuranceAmount() if insuranceAmount == 0.0 { @@ -1977,7 +1977,7 @@ access(all) contract FlowALPv0 { reserveVault: auth(FungibleToken.Withdraw) &{FungibleToken.Vault} ): @{FungibleToken.Vault}? { - tokenState.collectProtocolFees() + tokenState.accumulateProtocolFees() let stabilityAmount = tokenState.getCollectStabilityAmount() if stabilityAmount == 0.0 { diff --git a/cadence/tests/insurance_collection_formula_test.cdc b/cadence/tests/insurance_collection_formula_test.cdc index 61313a6d..e1d5888e 100644 --- a/cadence/tests/insurance_collection_formula_test.cdc +++ b/cadence/tests/insurance_collection_formula_test.cdc @@ -91,12 +91,13 @@ fun test_collectInsurance_success_fullAmount() { let collectedInsuranceAmount = finalInsuranceBalance - initialInsuranceBalance - // collectProtocolFees withdraws both insurance AND stability in one call. - // With insuranceRate=0.1 and stabilityFeeRate=0.05 (default), both are withdrawn. + // 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 - // Total withdrawn = insurance (→ fund via swap with 1:1 ratio) + stability (kept as MOET) - Test.assertEqual(amountWithdrawnFromReserves, collectedInsuranceAmount + stabilityFundBalance) + // 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() diff --git a/cadence/tests/insurance_collection_test.cdc b/cadence/tests/insurance_collection_test.cdc index cd8d4506..bf6345f1 100644 --- a/cadence/tests/insurance_collection_test.cdc +++ b/cadence/tests/insurance_collection_test.cdc @@ -165,7 +165,7 @@ fun test_collectInsurance_insufficientReserves() { Test.assertEqual(0.0, finalInsuranceBalance) Test.assertEqual(reserveBalanceBefore, reserveBalanceAfter) - // In the accumulator model, collectProtocolFees() is always called and updates the timestamp + // In the accumulator model, accumulateProtocolFees() is always called and updates the timestamp // even when reserves are insufficient. Fees accumulate in the accumulator until reserves recover. Test.assert(lastCollectionTimeAfter! > lastCollectionTimeBefore!, message: "Timestamp should be updated even on failed collection") @@ -275,8 +275,9 @@ fun test_collectInsurance_success_fullAmount() { // collectInsurance only drains the insurance accumulator; stability remains in its accumulator // until collectStability is called separately. let stabilityFundBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) ?? 0.0 + Test.assertEqual(0.0, stabilityFundBalance) let amountWithdrawnFromReserves = reserveBalanceBefore - reserveBalanceAfter - Test.assertEqual(amountWithdrawnFromReserves, finalInsuranceBalance + stabilityFundBalance) + Test.assertEqual(amountWithdrawnFromReserves, finalInsuranceBalance) // verify last insurance collection time was updated to current block timestamp let currentTimestamp = getBlockTimestamp() diff --git a/cadence/tests/stability_collection_test.cdc b/cadence/tests/stability_collection_test.cdc index 100938fc..2d8bc50c 100644 --- a/cadence/tests/stability_collection_test.cdc +++ b/cadence/tests/stability_collection_test.cdc @@ -112,7 +112,7 @@ fun test_collectStability_insufficientReserves() { Test.assertEqual(nil, finalStabilityBalance) Test.assertEqual(reserveBalanceBefore, reserveBalanceAfter) - // In the accumulator model, collectProtocolFees() is always called and updates the timestamp + // In the accumulator model, accumulateProtocolFees() is always called and updates the timestamp // even when reserves are insufficient. Fees accumulate in the accumulator until reserves recover. Test.assert(lastCollectionTimeAfter! > lastCollectionTimeBefore!, message: "Timestamp should be updated even on failed collection") } diff --git a/docs/interest_rate_and_protocol_fees.md b/docs/interest_rate_and_protocol_fees.md index 6eeaef21..d3bfc5bf 100644 --- a/docs/interest_rate_and_protocol_fees.md +++ b/docs/interest_rate_and_protocol_fees.md @@ -225,9 +225,9 @@ The update calculates the time elapsed since `lastUpdate` and compounds the inte ## Protocol Fee Accumulation -### `collectProtocolFees()` Accumulator +### `accumulateProtocolFees()` Accumulator -Insurance and stability fees are both computed in a single `TokenState.collectProtocolFees()` method. It is called automatically before every balance or rate mutation to ensure fees settle at the rate that was in effect when they accrued: +Insurance and stability fees are both computed in a single `TokenState.accumulateProtocolFees()` method. It is called automatically before every balance or rate mutation to ensure fees settle at the rate that was in effect when they accrued: ``` currentDebitRate = 1.0 + debitRatePerSecond @@ -251,7 +251,7 @@ The two accumulators are read and reset by `_withdrawInsurance` and `_withdrawSt ### When Fees Are Settled -`collectProtocolFees()` is triggered on every mutation that could change the fee calculation: +`accumulateProtocolFees()` is triggered on every mutation that could change the fee calculation: - `increaseCreditBalance` / `decreaseCreditBalance` - `increaseDebitBalance` / `decreaseDebitBalance` - `setInterestCurve` @@ -279,7 +279,7 @@ Insurance is collected through `collectInsurance()` function on `Pool` in `FlowA insuranceAmount = tokenState.accumulatedInsuranceFeeIncome (reset to 0 after reading) ``` - The accumulation itself happens in `collectProtocolFees()` (see above). + The accumulation itself happens in `accumulateProtocolFees()` (see above). 2. **Withdraws from Reserves**: - Withdraws the calculated insurance amount from the token's reserve vault @@ -341,7 +341,7 @@ Stability fees are collected through `_withdrawStability()` in `FlowALPv0`, whic stabilityAmount = tokenState.accumulatedStabilityFeeIncome (reset to 0 after reading) ``` - The accumulation itself happens in `collectProtocolFees()` (see above). + The accumulation itself happens in `accumulateProtocolFees()` (see above). 2. **Withdraws from Reserves**: - Withdraws the calculated stability amount from the token's reserve vault @@ -380,30 +380,34 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. 1. **Initial State**: - Total credit balance (lender deposits): 10,000 FLOW - - Total debit balance (borrower debt): 8,000 FLOW → utilization U = 0.8 - - Debit nominal yearly rate: 10% + - Total debit balance (borrower debt): 1,000 FLOW → utilization U = 0.1 + - KinkCurve parameters (Aave v3 "Volatile One" profile): `optimalUtilization = 0.45`, `baseRate = 0.0`, `slope1 = 0.04`, `slope2 = 3.0` - Insurance rate: 0.1% (`0.001`) - Stability fee rate: 5% (`0.05`) - `protocolFeeRate = 0.001 + 0.05 = 0.051` -2. **Per-Second Rates**: - - `debitRatePerSec = 0.10 / 31_557_600 ≈ 3.169e-9` - - `creditRatePerSec = 3.169e-9 × (1 - 0.051) × 0.8 = 2.406e-9` +2. **KinkCurve Rate Computation**: + - Since `u = 0.10 ≤ u* = 0.45`: + - `debitRate = baseRate + slope1 × (u / u*) = 0.0 + 0.04 × (0.10 / 0.45) ≈ 0.00889` (≈ 0.889% nominal yearly rate) -3. **After 1 Year**: +3. **Per-Second Rates**: + - `debitRatePerSec = 0.00889 / 31_557_600 ≈ 2.817e-10` + - `creditRatePerSec = 2.817e-10 × (1 - 0.051) × 0.1 ≈ 2.673e-11` + +4. **After 1 Year**: - perSecondDebitRate = 1.0 + debitRatePerSec - perSecondCreditRate = 1.0 + creditRatePerSec - - `debitIncome = 8,000 × (perSecondDebitRate ^ 31_557_600 − 1) ≈ 841.37 FLOW` - - `creditIncome = 10,000 × (perSecondCreditRate ^ 31_557_600 − 1) ≈ 792.54 FLOW` - - `protocolFeeIncome = 841.37 − 792.54 = 48.83 FLOW` - - `insuranceFee = 48.83 × 0.001 / 0.051 ≈ 0.957 FLOW` → converted to MOET - - `stabilityFee = 48.83 × 0.050 / 0.051 ≈ 47.87 FLOW` → kept as FLOW - - Net lender return = creditIncome = 792.54 FLOW - - Effective lender yield over the year: 792.54 / 10,000 ≈ 7.93% - -4. **Fund Accumulation**: - - Insurance fund: +0.957 FLOW worth of MOET (permanent, for bad debt coverage) - - Stability fund (FLOW): +47.87 FLOW (available for MOET stability operations) + - `debitIncome = 1,000 × (perSecondDebitRate ^ 31_557_600 − 1) ≈ 8.9294 FLOW` + - `creditIncome = 10,000 × (perSecondCreditRate ^ 31_557_600 − 1) ≈ 8.4389 FLOW` + - `protocolFeeIncome = 8.9294 − 8.4389 = 0.4905 FLOW` + - `insuranceFee = 0.4905 × 0.001 / 0.051 ≈ 0.0096 FLOW` → converted to MOET + - `stabilityFee = protocolFeeIncome - insuranceFee ≈ 0.4809 FLOW` → kept as FLOW + - Net lender return = creditIncome = 8.4389 FLOW + - Effective lender yield over the year: 8.4389 / 10,000 ≈ 0.084% + +5. **Fund Accumulation**: + - Insurance fund: +0.0096 FLOW worth of MOET (permanent, for bad debt coverage) + - Stability fund (FLOW): +0.4809 FLOW (available for MOET stability operations) ## Key Design Decisions @@ -421,7 +425,7 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. 7. **Spread-Based Fee Formula**: Fees are taken from `debitIncome - creditIncome` (the spread), not from gross debit income. This ensures that at zero utilization or zero protocol fee rate no fees are collected, and that the fee split between insurance and stability is always exact regardless of curve type. -8. **Single `collectProtocolFees()` Accumulator**: A single shared accumulator and timestamp for both fees ensures they always use the same elapsed-time window and that a rate change for one fee type does not inadvertently double-count or skip the other. +8. **Single `accumulateProtocolFees()` Accumulator**: A single shared accumulator and timestamp for both fees ensures they always use the same elapsed-time window and that a rate change for one fee type does not inadvertently double-count or skip the other. 9. **Token-Specific Swappers**: Each token can have its own insurance swapper, allowing flexibility in how different tokens are converted to MOET.