From 01230a450e6112a06b233a9fec5798539372dfd7 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Mon, 9 Mar 2026 19:02:47 -0400 Subject: [PATCH 1/3] refactor: remove FlowALPMath dependency from interest rates --- cadence/contracts/FlowALPInterestRates.cdc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cadence/contracts/FlowALPInterestRates.cdc b/cadence/contracts/FlowALPInterestRates.cdc index 9e1a1ab8..b885d453 100644 --- a/cadence/contracts/FlowALPInterestRates.cdc +++ b/cadence/contracts/FlowALPInterestRates.cdc @@ -1,5 +1,3 @@ -import "FlowALPMath" - access(all) contract FlowALPInterestRates { /// InterestCurve @@ -119,7 +117,7 @@ access(all) contract FlowALPInterestRates { // If utilization is above the optimal point, use slope2 for excess // excessUtilization = (utilization - optimalUtilization) / (1 - optimalUtilization) let excessUtilization = utilization - self.optimalUtilization - let maxExcess = FlowALPMath.one - self.optimalUtilization + let maxExcess = (1.0 as UFix128) - self.optimalUtilization let excessFactor = excessUtilization / maxExcess // rate = baseRate + slope1 + (slope2 × excessFactor) From da887346f4b7f669624be5529c9c92edd6d18bde Mon Sep 17 00:00:00 2001 From: liobrasil Date: Mon, 9 Mar 2026 19:04:18 -0400 Subject: [PATCH 2/3] fix: align kink curve utilization with pool accounting --- cadence/contracts/FlowALPInterestRates.cdc | 21 +++- cadence/tests/interest_curve_test.cdc | 52 ++++++--- ...kink_curve_utilization_regression_test.cdc | 100 ++++++++++++++++++ 3 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 cadence/tests/kink_curve_utilization_regression_test.cdc diff --git a/cadence/contracts/FlowALPInterestRates.cdc b/cadence/contracts/FlowALPInterestRates.cdc index b885d453..e509e204 100644 --- a/cadence/contracts/FlowALPInterestRates.cdc +++ b/cadence/contracts/FlowALPInterestRates.cdc @@ -49,13 +49,18 @@ access(all) contract FlowALPInterestRates { /// optimal point while heavily penalizing over-utilization to protect protocol liquidity. /// /// Formula: - /// - utilization = debitBalance / (creditBalance + debitBalance) + /// - utilization = min(debitBalance / creditBalance, 1.0) /// - Before kink (utilization <= optimalUtilization): /// rate = baseRate + (slope1 × utilization / optimalUtilization) /// - After kink (utilization > optimalUtilization): /// rate = baseRate + slope1 + (slope2 × excessUtilization) /// where excessUtilization = (utilization - optimalUtilization) / (1 - optimalUtilization) /// + /// `creditBalance` is expected to be the total credit balance, i.e. the + /// total supplied balance for the token, not the remaining idle liquidity + /// in the pool. + /// This matches the live TokenState accounting used by FlowALP. + /// /// @param optimalUtilization The target utilization ratio (e.g., 0.80 for 80%) /// @param baseRate The minimum yearly interest rate (e.g., 0.01 for 1% APY) /// @param slope1 The total rate increase from 0% to optimal utilization (e.g., 0.04 for 4%) @@ -102,10 +107,16 @@ access(all) contract FlowALPInterestRates { return self.baseRate } - // Calculate utilization ratio: debitBalance / (creditBalance + debitBalance) - // Note: totalBalance > 0 is guaranteed since debitBalance > 0 and creditBalance >= 0 - let totalBalance = creditBalance + debitBalance - let utilization = debitBalance / totalBalance + // Calculate utilization ratio from debt over total supplied. + // If the supplied side is zero or debt grows past supply, saturate at + // 100% utilization instead of dividing by zero or exceeding the kink curve. + var utilization: UFix128 = 1.0 + if creditBalance > 0.0 { + utilization = debitBalance / creditBalance + if utilization > 1.0 { + utilization = 1.0 + } + } // If utilization is below or at the optimal point, use slope1 if utilization <= self.optimalUtilization { diff --git a/cadence/tests/interest_curve_test.cdc b/cadence/tests/interest_curve_test.cdc index c16bdbe3..adbfa692 100644 --- a/cadence/tests/interest_curve_test.cdc +++ b/cadence/tests/interest_curve_test.cdc @@ -41,6 +41,11 @@ fun test_FixedCurve_accepts_zero_rate() { // ============================================================================ // KinkCurve Tests // ============================================================================ +// +// For direct KinkCurve calls, `creditBalance` uses the same semantics as the +// live pool accounting: total supplied, i.e. total creditor claims for the +// token. +// It does NOT mean remaining idle liquidity in the pool. access(all) fun test_KinkCurve_at_zero_utilization() { @@ -75,11 +80,11 @@ fun test_KinkCurve_before_kink() { slope2: 0.60 ) - // At 40% utilization (credit: 60, debit: 40, total: 100) + // At 40% utilization (credit: 100 supplied, debit: 40 borrowed) // utilization = 40 / 100 = 0.40 // utilizationFactor = 0.40 / 0.80 = 0.5 // rate = 0.01 + (0.04 × 0.5) = 0.01 + 0.02 = 0.03 - let rate = curve.interestRate(creditBalance: 60.0, debitBalance: 40.0) + let rate = curve.interestRate(creditBalance: 100.0, debitBalance: 40.0) Test.assertEqual(0.03 as UFix128, rate) } @@ -93,10 +98,10 @@ fun test_KinkCurve_at_kink() { slope2: 0.60 ) - // At 80% utilization (credit: 20, debit: 80, total: 100) + // At 80% utilization (credit: 100 supplied, debit: 80 borrowed) // utilization = 80 / 100 = 0.80 (exactly at kink) // rate = 0.01 + 0.04 = 0.05 - let rate = curve.interestRate(creditBalance: 20.0, debitBalance: 80.0) + let rate = curve.interestRate(creditBalance: 100.0, debitBalance: 80.0) Test.assertEqual(0.05 as UFix128, rate) } @@ -110,13 +115,13 @@ fun test_KinkCurve_after_kink() { slope2: 0.60 ) - // At 90% utilization (credit: 10, debit: 90, total: 100) + // At 90% utilization (credit: 100 supplied, debit: 90 borrowed) // utilization = 90 / 100 = 0.90 // excessUtilization = 0.90 - 0.80 = 0.10 // maxExcess = 1.0 - 0.80 = 0.20 // excessFactor = 0.10 / 0.20 = 0.5 // rate = 0.01 + 0.04 + (0.60 × 0.5) = 0.01 + 0.04 + 0.30 = 0.35 - let rate = curve.interestRate(creditBalance: 10.0, debitBalance: 90.0) + let rate = curve.interestRate(creditBalance: 100.0, debitBalance: 90.0) Test.assertEqual(0.35 as UFix128, rate) } @@ -130,12 +135,27 @@ fun test_KinkCurve_at_full_utilization() { slope2: 0.60 ) - // At 100% utilization (credit: 0, debit: 100, total: 100) + // At 100% utilization (credit: 100 supplied, debit: 100 borrowed) // utilization = 100 / 100 = 1.0 // excessUtilization = 1.0 - 0.80 = 0.20 // maxExcess = 1.0 - 0.80 = 0.20 // excessFactor = 0.20 / 0.20 = 1.0 // rate = 0.01 + 0.04 + (0.60 × 1.0) = 0.65 + let rate = curve.interestRate(creditBalance: 100.0, debitBalance: 100.0) + Test.assertEqual(0.65 as UFix128, rate) +} + +access(all) +fun test_KinkCurve_zero_credit_balance_saturates_at_full_utilization() { + let curve = FlowALPInterestRates.KinkCurve( + optimalUtilization: 0.80, + baseRate: 0.01, + slope1: 0.04, + slope2: 0.60 + ) + + // Defensive edge case: if positive debt is ever observed with zero credit, + // the curve should saturate at 100% utilization instead of dividing by zero. let rate = curve.interestRate(creditBalance: 0.0, debitBalance: 100.0) Test.assertEqual(0.65 as UFix128, rate) } @@ -197,11 +217,11 @@ fun test_TokenState_with_KinkCurve() { ) // Set up balances for 60% utilization (below kink) - // credit: 40, debit: 60, total: 100 + // credit: 100 supplied, debit: 60 borrowed // utilization = 0.60 // rate = 0.02 + (0.05 × 0.60 / 0.80) = 0.02 + 0.0375 = 0.0575 // Note: Balance changes automatically trigger updateInterestRates() via updateForUtilizationChange() - tokenState.increaseCreditBalance(by: 40.0) + tokenState.increaseCreditBalance(by: 100.0) tokenState.increaseDebitBalance(by: 60.0) // Verify the debit rate @@ -234,25 +254,25 @@ fun test_KinkCurve_rates_update_automatically_on_balance_change() { Test.assertEqual(rateAtZeroUtilization, tokenState.getCurrentDebitRate()) // Step 2: Add debt to create 50% utilization - // credit: 100, debit: 100 → total: 200, utilization = 100/200 = 50% + // credit: 100 supplied, debit: 50 borrowed → utilization = 50/100 = 50% // rate = 0.02 + (0.05 × 0.50 / 0.80) = 0.02 + 0.03125 = 0.05125 - tokenState.increaseDebitBalance(by: 100.0) + tokenState.increaseDebitBalance(by: 50.0) let rateAt50Utilization = FlowALPMath.perSecondInterestRate(yearlyRate: 0.05125) Test.assertEqual(rateAt50Utilization, tokenState.getCurrentDebitRate()) // Step 3: Increase utilization to 90% (above kink) - // credit: 100, debit: 900 → total: 1000, utilization = 900/1000 = 90% + // credit: 100 supplied, debit: 90 borrowed → utilization = 90/100 = 90% // excessUtil = (0.90 - 0.80) / (1 - 0.80) = 0.50 // rate = 0.02 + 0.05 + (0.50 × 0.50) = 0.32 - tokenState.increaseDebitBalance(by: 800.0) + tokenState.increaseDebitBalance(by: 40.0) let rateAt90Util = FlowALPMath.perSecondInterestRate(yearlyRate: 0.32) Test.assertEqual(rateAt90Util, tokenState.getCurrentDebitRate()) // Step 4: Decrease debt to lower utilization back to 0% // credit: 100, debit: 0 → utilization = 0% → rate = baseRate = 2% - tokenState.decreaseDebitBalance(by: 900.0) + tokenState.decreaseDebitBalance(by: 90.0) let rateBackToZero = FlowALPMath.perSecondInterestRate(yearlyRate: 0.02) Test.assertEqual(rateBackToZero, tokenState.getCurrentDebitRate()) @@ -272,7 +292,7 @@ fun test_KinkCurve_with_very_small_balances() { ) // Test with very small balances (fractional tokens) - let rate = curve.interestRate(creditBalance: 0.01, debitBalance: 0.01) + let rate = curve.interestRate(creditBalance: 0.02, debitBalance: 0.01) // At 50% utilization, rate should be: 0.01 + (0.04 × 0.50 / 0.80) = 0.01 + 0.025 = 0.035 Test.assertEqual(0.035 as UFix128, rate) } @@ -287,7 +307,7 @@ fun test_KinkCurve_with_large_balances() { ) // Test with large balances (millions of tokens) - let rate = curve.interestRate(creditBalance: 5_000_000.0, debitBalance: 5_000_000.0) + let rate = curve.interestRate(creditBalance: 10_000_000.0, debitBalance: 5_000_000.0) // At 50% utilization, rate should be: 0.01 + (0.04 × 0.50 / 0.80) = 0.035 Test.assertEqual(0.035 as UFix128, rate) } diff --git a/cadence/tests/kink_curve_utilization_regression_test.cdc b/cadence/tests/kink_curve_utilization_regression_test.cdc new file mode 100644 index 00000000..6c421606 --- /dev/null +++ b/cadence/tests/kink_curve_utilization_regression_test.cdc @@ -0,0 +1,100 @@ +import Test +import "FlowToken" +import "FlowALPv0" +import "FlowALPModels" +import "FlowALPInterestRates" +import "test_helpers.cdc" + +// This file guards against the historical KinkCurve utilization bug and keeps +// the original semantic mismatch documented in one place. +// +// Historical bug: +// - The old KinkCurve formula computed utilization as: +// debitBalance / (creditBalance + debitBalance) +// - That only works if `creditBalance` means REMAINING AVAILABLE LIQUIDITY. +// - But FlowALP's live accounting uses `totalCreditBalance` to mean total +// creditor claims, i.e. total supplied. +// +// Example of the mismatch: +// - 100 supplied +// - 90 borrowed +// - 10 idle liquidity left in the pool +// +// If `creditBalance` means remaining liquidity: +// - utilization = 90 / (10 + 90) = 90% +// +// If `creditBalance` means total supplied: +// - utilization = 90 / (100 + 90) = 47.4% +// +// The bug survived because direct curve tests were easy to write using +// hand-picked "remaining liquidity" inputs, while the live pool always passed +// total supplied, i.e. creditor claims, into the same parameter. +// +// After the fix, both direct KinkCurve calls and TokenState accounting must use +// the same meaning: +// creditBalance = total creditor claims, i.e. total supplied +// +// These tests ensure a pool with 100 supplied and 90 borrowed is priced as 90% +// utilization in both paths. + +access(all) +fun setup() { + deployContracts() +} + +access(all) +fun test_regression_KinkCurve_direct_curve_uses_total_supplied_semantics() { + let curve = FlowALPInterestRates.KinkCurve( + optimalUtilization: 0.80, + baseRate: 0.01, + slope1: 0.04, + slope2: 0.60 + ) + + // Direct curve calls must use total supplied semantics, matching the pool. + let rate = curve.interestRate(creditBalance: 100.0, debitBalance: 90.0) + + Test.assertEqual(0.35 as UFix128, rate) +} + +access(all) +fun test_regression_TokenState_90_borrow_of_100_supply_should_price_at_90_percent_utilization() { + let curve = FlowALPInterestRates.KinkCurve( + optimalUtilization: 0.80, + baseRate: 0.01, + slope1: 0.04, + slope2: 0.60 + ) + + var tokenState = FlowALPModels.TokenStateImplv1( + tokenType: Type<@FlowToken.Vault>(), + interestCurve: curve, + depositRate: 1.0, + depositCapacityCap: 1_000.0 + ) + + // Realistic pool state: 100 total supplied, 90 total borrowed. + // TokenState stores those values directly as total credit and total debt, + // so this path verifies the live accounting matches the direct curve path. + tokenState.increaseCreditBalance(by: 100.0) + tokenState.increaseDebitBalance(by: 90.0) + + let actualYearlyRate = curve.interestRate( + creditBalance: tokenState.getTotalCreditBalance(), + debitBalance: tokenState.getTotalDebitBalance() + ) + + // The live pool path should match the direct curve path above. If it does + // not, `creditBalance` semantics have drifted again. + Test.assert( + actualYearlyRate == 0.35, + message: + "Regression: 100 supplied / 90 borrowed should price at 90% utilization (0.35 APY), but current accounting passed creditBalance=" + .concat(tokenState.getTotalCreditBalance().toString()) + .concat(" and debitBalance=") + .concat(tokenState.getTotalDebitBalance().toString()) + .concat(", producing ") + .concat(actualYearlyRate.toString()) + .concat(" instead") + ) +} From 9db65e91901ad9b37e249cea4942e0ccf6f84bdb Mon Sep 17 00:00:00 2001 From: liobrasil Date: Tue, 10 Mar 2026 12:17:53 -0400 Subject: [PATCH 3/3] style: use interpolation in regression assertion --- cadence/tests/kink_curve_utilization_regression_test.cdc | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/cadence/tests/kink_curve_utilization_regression_test.cdc b/cadence/tests/kink_curve_utilization_regression_test.cdc index 6c421606..b7c5924f 100644 --- a/cadence/tests/kink_curve_utilization_regression_test.cdc +++ b/cadence/tests/kink_curve_utilization_regression_test.cdc @@ -89,12 +89,6 @@ fun test_regression_TokenState_90_borrow_of_100_supply_should_price_at_90_percen Test.assert( actualYearlyRate == 0.35, message: - "Regression: 100 supplied / 90 borrowed should price at 90% utilization (0.35 APY), but current accounting passed creditBalance=" - .concat(tokenState.getTotalCreditBalance().toString()) - .concat(" and debitBalance=") - .concat(tokenState.getTotalDebitBalance().toString()) - .concat(", producing ") - .concat(actualYearlyRate.toString()) - .concat(" instead") + "Regression: 100 supplied / 90 borrowed should price at 90% utilization (0.35 APY), but current accounting passed creditBalance=\(tokenState.getTotalCreditBalance()) and debitBalance=\(tokenState.getTotalDebitBalance()), producing \(actualYearlyRate) instead" ) }