Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions cadence/contracts/FlowALPInterestRates.cdc
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import "FlowALPMath"

access(all) contract FlowALPInterestRates {

/// InterestCurve
Expand Down Expand Up @@ -51,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%)
Expand Down Expand Up @@ -104,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 {
Expand All @@ -119,7 +128,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)
Expand Down
52 changes: 36 additions & 16 deletions cadence/tests/interest_curve_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
94 changes: 94 additions & 0 deletions cadence/tests/kink_curve_utilization_regression_test.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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=\(tokenState.getTotalCreditBalance()) and debitBalance=\(tokenState.getTotalDebitBalance()), producing \(actualYearlyRate) instead"
)
}
Loading