diff --git a/cadence/contracts/FlowALPEvents.cdc b/cadence/contracts/FlowALPEvents.cdc index e46fd39c..0b029958 100644 --- a/cadence/contracts/FlowALPEvents.cdc +++ b/cadence/contracts/FlowALPEvents.cdc @@ -141,11 +141,11 @@ access(all) contract FlowALPEvents { ) /// Emitted when the insurance rate for a token is updated by governance. - /// The insurance rate is an annual fraction of debit interest diverted to the insurance fund. + /// The insurance rate is a fee of accrued debit interest diverted to the insurance fund. /// /// @param poolUUID the UUID of the pool containing the token /// @param tokenType the type identifier string of the token whose rate changed - /// @param insuranceRate the new annual insurance rate (e.g. 0.001 for 0.1%) + /// @param insuranceRate the new insurance fee (e.g. 0.001 for 0.1%) access(all) event InsuranceRateUpdated( poolUUID: UInt64, tokenType: String, @@ -167,11 +167,11 @@ access(all) contract FlowALPEvents { ) /// Emitted when the stability fee rate for a token is updated by governance. - /// The stability fee rate is an annual fraction of debit interest diverted to the stability fund. + /// The stability fee rate is a fee of accrued debit interest diverted to the stability fund. /// /// @param poolUUID the UUID of the pool containing the token /// @param tokenType the type identifier string of the token whose rate changed - /// @param stabilityFeeRate the new annual stability fee rate (e.g. 0.05 for 5%) + /// @param stabilityFeeRate the new stability fee (e.g. 0.05 for 5%) access(all) event StabilityFeeRateUpdated( poolUUID: UInt64, tokenType: String, diff --git a/cadence/contracts/FlowALPInterestRates.cdc b/cadence/contracts/FlowALPInterestRates.cdc index 74819bd6..65577254 100644 --- a/cadence/contracts/FlowALPInterestRates.cdc +++ b/cadence/contracts/FlowALPInterestRates.cdc @@ -4,7 +4,7 @@ access(all) contract FlowALPInterestRates { /// /// A simple interface to calculate interest rate for a token type. access(all) struct interface InterestCurve { - /// Returns the annual interest rate for the given credit and debit balance, for some token T. + /// Returns the annual nominal interest rate for the given credit and debit balance, for some token T. /// @param creditBalance The credit (deposit) balance of token T /// @param debitBalance The debit (withdrawal) balance of token T access(all) fun interestRate(creditBalance: UFix128, debitBalance: UFix128): UFix128 { @@ -19,10 +19,10 @@ access(all) contract FlowALPInterestRates { /// FixedCurve /// - /// A fixed-rate interest curve implementation that returns a constant yearly interest rate + /// A fixed-rate interest curve implementation that returns a constant nominal yearly interest rate /// regardless of utilization. This is suitable for stable assets like MOET where predictable /// rates are desired. - /// @param yearlyRate The fixed yearly interest rate as a UFix128 (e.g., 0.05 for 5% APY) + /// @param yearlyRate The fixed yearly nominal rate as a UFix128 (e.g., 0.05 for a 5% nominal yearly rate) access(all) struct FixedCurve: InterestCurve { access(all) let yearlyRate: UFix128 @@ -64,7 +64,7 @@ access(all) contract FlowALPInterestRates { /// 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 baseRate The minimum yearly nominal rate (e.g., 0.01 for a 1% nominal yearly rate) /// @param slope1 The total rate increase from 0% to optimal utilization (e.g., 0.04 for 4%) /// @param slope2 The total rate increase from optimal to 100% utilization (e.g., 0.60 for 60%) access(all) struct KinkCurve: InterestCurve { diff --git a/cadence/lib/FlowALPMath.cdc b/cadence/lib/FlowALPMath.cdc index 1a753d89..1c8fde8d 100644 --- a/cadence/lib/FlowALPMath.cdc +++ b/cadence/lib/FlowALPMath.cdc @@ -99,9 +99,11 @@ access(all) contract FlowALPMath { return diffBps <= maxDeviationBps } - /// Converts a yearly interest rate to a per-second multiplication factor (stored in a UFix128 as a fixed point - /// number with 18 decimal places). The input to this function will be just the relative annual interest rate - /// (e.g. 0.05 for 5% interest), and the result will be the per-second multiplier (e.g. 1.000000000001). + /// Converts a nominal yearly interest rate to a per-second multiplication factor (stored in a UFix128 as a fixed + /// point number with 18 decimal places). The input to this function is the relative nominal annual rate + /// (e.g. 0.05 for a 5% nominal yearly rate), and the result is the per-second multiplier + /// (e.g. 1.000000000001). For positive rates, the effective one-year growth will be slightly higher than the + /// nominal rate because interest compounds over time. access(all) view fun perSecondInterestRate(yearlyRate: UFix128): UFix128 { let perSecondScaledValue = yearlyRate / 31_557_600.0 // 365.25 * 24.0 * 60.0 * 60.0 assert( @@ -111,6 +113,19 @@ access(all) contract FlowALPMath { return perSecondScaledValue + 1.0 } + /// Returns the effective annual yield (EAY) for a given nominal yearly rate, assuming discrete per-second compounding. + /// + /// Formula: EAY = (1 + nominalRate / secondsPerYear) ^ secondsPerYear - 1 + /// + /// For example, a nominal rate of 100% (1.0) produces an effective rate of about 171.8281776413% + /// under discrete per-second compounding: (1 + 1 / 31_557_600) ^ 31_557_600 - 1. + /// This is extremely close to the continuous-compounding limit of e - 1. + access(all) view fun effectiveYearlyRate(nominalYearlyRate: UFix128): UFix128 { + let perSecondRate = FlowALPMath.perSecondInterestRate(yearlyRate: nominalYearlyRate) + let compounded = FlowALPMath.powUFix128(perSecondRate, 31_557_600.0) + return compounded - 1.0 + } + /// Returns the compounded interest index reflecting the passage of time /// The result is: newIndex = oldIndex * perSecondRate ^ seconds access(all) view fun compoundInterestIndex( diff --git a/cadence/tests/TEST_COVERAGE.md b/cadence/tests/TEST_COVERAGE.md index 6c81ec3c..2a8b7239 100644 --- a/cadence/tests/TEST_COVERAGE.md +++ b/cadence/tests/TEST_COVERAGE.md @@ -204,7 +204,7 @@ The `test_helpers.cdc` file provides: 3. **FLOW Debit Interest** - KinkCurve-based interest rates - Variable rates based on utilization - - Interest compounds continuously + - Interest compounds via discrete per-second updates 4. **FLOW Credit Interest** - LP earnings with insurance spread diff --git a/cadence/tests/effective_interest_rate_test.cdc b/cadence/tests/effective_interest_rate_test.cdc new file mode 100644 index 00000000..a47b7011 --- /dev/null +++ b/cadence/tests/effective_interest_rate_test.cdc @@ -0,0 +1,46 @@ +import Test +import BlockchainHelpers + +import "FlowALPMath" +import "test_helpers.cdc" + +access(all) +fun setup() { + let err = Test.deployContract( + name: "FlowALPMath", + path: "../lib/FlowALPMath.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) +} + +access(all) struct TestCase { + access(all) let nominal: UFix128 + access(all) let expected: UFix128 + + init(nominal: UFix128, expected: UFix128) { + self.nominal = nominal + self.expected = expected + } +} + +access(all) +fun test_effectiveYearlyRate() { + let delta: UFix128 = 0.0001 + let testCases = [ + TestCase(nominal: 0.01, expected: 0.01005016708), // ≈ e^0.01 - 1 + TestCase(nominal: 0.02, expected: 0.02020134003), // ≈ e^0.02 - 1 + TestCase(nominal: 0.05, expected: 0.05127109638), // ≈ e^0.05 - 1 + TestCase(nominal: 0.50, expected: 0.6487212707), // ≈ e^0.5 - 1 + TestCase(nominal: 1.0, expected: 1.7182818285), // ≈ e^1 - 1 + TestCase(nominal: 4.0, expected: 53.5981500331) // ≈ e^4 - 1 + ] + for testCase in testCases { + let effective = FlowALPMath.effectiveYearlyRate(nominalYearlyRate: testCase.nominal) + let diff = effective > testCase.expected ? effective - testCase.expected : testCase.expected - effective + Test.assert( + diff <= delta, + message: "effectiveYearlyRate(\(testCase.nominal.toString())) expected ~\(testCase.expected.toString()), got \(effective.toString()), diff \(diff.toString()) exceeds delta \(delta.toString())" + ) + } +} diff --git a/cadence/tests/interest_accrual_integration_test.cdc b/cadence/tests/interest_accrual_integration_test.cdc index eaef69b3..263f3241 100644 --- a/cadence/tests/interest_accrual_integration_test.cdc +++ b/cadence/tests/interest_accrual_integration_test.cdc @@ -28,7 +28,7 @@ import "test_helpers.cdc" // - Focuses on protocol solvency and insurance mechanics // // Interest Rate Configuration: -// - MOET: FixedCurve at 4% APY (rate independent of utilization) +// - MOET: FixedCurve at a 4% nominal yearly rate (rate independent of utilization) // - Flow: KinkCurve with Aave v3 Volatile One parameters // (45% optimal utilization, 0% base, 4% slope1, 300% slope2) // ============================================================================= @@ -40,18 +40,19 @@ access(all) var snapshot: UInt64 = 0 // Interest Rate Parameters // ============================================================================= -// MOET: FixedCurve (Spread Model) +// MOET: FixedCurve (Protocol-Fee Spread Model) // ----------------------------------------------------------------------------- -// In the spread model, the curve defines the DEBIT rate (what borrowers pay). -// The CREDIT rate is derived as: creditRate = debitRate - insuranceRate +// In the fixed-curve path, the curve defines the DEBIT rate (what borrowers pay). +// The CREDIT rate is derived from the debit rate after protocol fees. // This ensures lenders always earn less than borrowers pay, with the -// difference going to the insurance pool for protocol solvency. +// difference allocated by the configured protocol fee settings. // -// Example at 4% debit rate with 0.1% insurance: -// - Borrowers pay: 4.0% APY -// - Lenders earn: 3.9% APY -// - Insurance: 0.1% APY (collected by protocol) -access(all) let moetFixedRate: UFix128 = 0.04 // 4% APY debit rate +// Example at a 4% nominal yearly debit rate: +// - Borrowers pay: 4.0% nominal yearly debit rate +// - Lenders earn: a lower nominal yearly credit rate after protocol fees +// - Protocol Fees are comprised of two parts - +// - Insurance/Stability: configurable fees of accrued debit interest +access(all) let moetFixedRate: UFix128 = 0.04 // 4% nominal yearly debit rate // FlowToken: KinkCurve (Aave v3 Volatile One Parameters) // ----------------------------------------------------------------------------- @@ -64,10 +65,10 @@ access(all) let moetFixedRate: UFix128 = 0.04 // 4% APY debit rate // - If utilization > optimal: rate = baseRate + slope1 + ((util-optimal)/(1-optimal)) × slope2 // // At 40% utilization (below 45% optimal): -// - Rate = 0% + (40%/45%) × 4% ≈ 3.56% APY +// - Rate = 0% + (40%/45%) × 4% ≈ 3.56% nominal yearly rate // // At 80% utilization (above 45% optimal): -// - Rate = 0% + 4% + ((80%-45%)/(100%-45%)) × 300% ≈ 195% APY +// - Rate = 0% + 4% + ((80%-45%)/(100%-45%)) × 300% ≈ 195% nominal yearly rate access(all) let flowOptimalUtilization: UFix128 = 0.45 // 45% kink point access(all) let flowBaseRate: UFix128 = 0.0 // 0% base rate access(all) let flowSlope1: UFix128 = 0.04 // 4% slope below kink @@ -160,7 +161,7 @@ fun test_moet_debit_accrues_interest() { // ------------------------------------------------------------------------- // STEP 4: Configure MOET Interest Rate // ------------------------------------------------------------------------- - // Set MOET to use a FixedCurve at 4% APY. + // Set MOET to use a FixedCurve at a 4% nominal yearly rate. // This rate is independent of utilization - borrowers always pay 4%. // Note: Interest curve must be set AFTER LP deposit to ensure credit exists. setInterestCurveFixed( @@ -168,7 +169,7 @@ fun test_moet_debit_accrues_interest() { tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: moetFixedRate ) - log("Set MOET interest rate to 4% APY (after LP deposit)") + log("Set MOET interest rate to 4% nominal yearly rate (after LP deposit)") let res = setInsuranceSwapper( signer: PROTOCOL_ACCOUNT, @@ -305,7 +306,7 @@ fun test_moet_debit_accrues_interest() { // Expected Growth Calculation // ------------------------------------------------------------------------- // Per-second compounding: (1 + r / 31_557_600) ^ seconds - 1 - // At 4% APY for 30 days (2,592,000 seconds): + // At a 4% nominal yearly rate for 30 days (2,592,000 seconds): // Growth = (1 + 0.04 / 31_557_600) ^ 2_592_000 - 1 ≈ 0.328% // // We use a wide tolerance range because: @@ -337,10 +338,10 @@ fun test_moet_debit_accrues_interest() { // - Time advances 30 days // - Verify: LP credit increased, growth rate is in expected range // -// Key Insight (FixedCurve Spread Model): -// - debitRate = 4.0% (what borrowers pay, defined by curve) -// - insuranceRate = 0.1% (protocol reserve) -// - creditRate = debitRate - insuranceRate = 3.9% (what lenders earn) +// Key Insight (FixedCurve Protocol-Fee Spread): +// - debitRate is defined by the curve +// - creditRate is the debit rate after protocol fees +// - creditRate remains below debitRate // ============================================================================= access(all) fun test_moet_credit_accrues_interest_with_insurance() { @@ -394,7 +395,7 @@ fun test_moet_credit_accrues_interest_with_insurance() { // ------------------------------------------------------------------------- // STEP 4: Configure MOET Interest Rate // ------------------------------------------------------------------------- - // Set 4% APY debit rate. Credit rate will be ~3.9% after insurance deduction. + // Set a 4% nominal yearly debit rate. Credit rate will be lower after protocol fees. setInterestCurveFixed( signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, @@ -485,9 +486,9 @@ fun test_moet_credit_accrues_interest_with_insurance() { // ------------------------------------------------------------------------- // Expected Credit Growth Calculation // ------------------------------------------------------------------------- - // Debit rate: 4% APY (what borrowers pay) - // Insurance: 0.1% APY (protocol reserve) - // Credit rate: 4% - 0.1% = 3.9% APY (what LPs earn) + // Debit rate: 4% nominal yearly rate (what borrowers pay) + // Protocol fees: configured insurance plus stability fee fractions + // Credit rate: lower than the debit rate after protocol fees // // 30-day credit growth ≈ 3.9% × (30/365) ≈ 0.32% // @@ -523,7 +524,7 @@ fun test_moet_credit_accrues_interest_with_insurance() { // Key Insight (KinkCurve): // At 40% utilization (below 45% optimal kink): // - Rate = baseRate + (utilization/optimal) × slope1 -// - Rate = 0% + (40%/45%) × 4% ≈ 3.56% APY +// - Rate = 0% + (40%/45%) × 4% ≈ 3.56% nominal yearly rate // ============================================================================= access(all) fun test_flow_debit_accrues_interest() { @@ -685,7 +686,7 @@ fun test_flow_debit_accrues_interest() { // ------------------------------------------------------------------------- // Utilization = 4,000 / 10,000 = 40% (below 45% optimal) // Rate = baseRate + (util/optimal) × slope1 - // = 0% + (40%/45%) × 4% ≈ 3.56% APY + // = 0% + (40%/45%) × 4% ≈ 3.56% nominal yearly rate // // 30-day growth ≈ 3.56% × (30/365) ≈ 0.29% let minExpectedDebtGrowth: UFix64 = 0.002 // 0.2% @@ -891,15 +892,15 @@ fun test_flow_credit_accrues_interest_with_insurance() { // - LP deposits 10,000 MOET // - Borrower deposits 10,000 FLOW and borrows MOET // - Insurance rate set to 1% (higher than default 0.1% for visibility) -// - Debit rate set to 10% APY +// - Debit rate set to a 10% nominal yearly rate // - Time advances 1 YEAR // - Verify: Insurance spread ≈ 1% (debit rate - credit rate) // -// Key Insight (FixedCurve Spread Model): -// - debitRate = 10% (what borrowers pay) -// - insuranceRate = 1% (protocol reserve) -// - creditRate = debitRate - insuranceRate = 9% (what LPs earn) -// - Spread = debitRate - creditRate = 1% +// Key Insight (FixedCurve Protocol-Fee Spread): +// - debitRate is set by the fixed curve +// - insurance/stability remain configured fee parameters +// - creditRate is reduced relative to debitRate by those protocol fees +// - the realized spread shows up as a lower lender growth rate than borrower growth rate // ============================================================================= access(all) fun test_insurance_deduction_verification() { @@ -952,7 +953,7 @@ fun test_insurance_deduction_verification() { // // Insurance Rate: 1% (vs default 0.1%) // Debit Rate: 10% (vs default 4%) - // Expected Credit Rate: 10% - 1% = 9% + // Expected Credit Rate: lower than 10% after protocol fees let res = setInsuranceSwapper( signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, @@ -1012,8 +1013,8 @@ fun test_insurance_deduction_verification() { // ========================================================================= // Using 1 year (31,557,600 seconds for 365.25 days) makes the percentage calculations // straightforward. With per-second discrete compounding: - // - 10% APY → (1 + 0.10 / 31_557_600) ^ 31_557_600 - 1 ≈ 10.52% effective rate - // - 9% APY → (1 + 0.09 / 31_557_600) ^ 31_557_600 - 1 ≈ 9.42% effective rate + // - 10% nominal yearly rate → (1 + 0.10 / 31_557_600) ^ 31_557_600 - 1 ≈ 10.52% effective rate + // - 9% nominal yearly rate → (1 + 0.09 / 31_557_600) ^ 31_557_600 - 1 ≈ 9.42% effective rate // - Spread should be approximately 1% Test.moveTime(by: ONE_YEAR) Test.commitBlock() @@ -1049,9 +1050,10 @@ fun test_insurance_deduction_verification() { // ========================================================================= // ASSERTION: Verify Insurance Spread // ========================================================================= - // For FixedCurve (spread model): - // - debitRate = creditRate + insuranceRate - // - insuranceSpread = debitRate - creditRate ≈ insuranceRate + // For FixedCurve: + // - debitRate is the curve-defined nominal yearly rate + // - creditRate is the debit rate after protocol fees + // - insuranceSpread = actualDebtRate - actualCreditRate // // With 10% debit and 1% insurance, spread should be ~1% // (Slight variation due to per-second compounding effects) @@ -1158,12 +1160,12 @@ fun test_combined_all_interest_scenarios() { // ------------------------------------------------------------------------- // STEP 5: Configure Interest Curves for Both Tokens // ------------------------------------------------------------------------- - // MOET: FixedCurve at 4% APY (spread model) + // MOET: FixedCurve at a 4% nominal yearly rate (fixed-curve spread model) // Flow: KinkCurve with Aave v3 Volatile One parameters setInterestCurveFixed( signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, - yearlyRate: moetFixedRate // 4% APY + yearlyRate: moetFixedRate // 4% nominal yearly rate ) setInterestCurveKink( signer: PROTOCOL_ACCOUNT, @@ -1324,14 +1326,14 @@ fun test_combined_all_interest_scenarios() { // Assertion Group 2: Health Factor Changes // ------------------------------------------------------------------------- // Borrower1 (Flow collateral, MOET debt): - // - MOET debit rate: 4% APY + // - MOET debit rate: 4% nominal yearly rate // - Flow credit rate: lower than Flow debit rate due to insurance spread // - Net effect: Debt grows faster than collateral → Health DECREASES Test.assert(b1HealthAfter < b1HealthBefore, message: "Borrower1 health should decrease") // Borrower2 (MOET collateral, Flow debt): - // - MOET credit rate: ~3.9% APY (4% debit - 0.1% insurance) - // - Flow debit rate: ~2.5% APY (at 28.6% utilization) + // - MOET credit rate: lower than the MOET debit rate after protocol fees + // - Flow debit rate: ~2.5% nominal yearly rate (at 28.6% utilization) // - Collateral (3,000 MOET) earning more absolute interest than debt (2,000 Flow) // - Net effect: Health INCREASES Test.assert(b2HealthAfter > b2HealthBefore, message: "Borrower2 health should increase (collateral interest > debt interest)") diff --git a/cadence/tests/interest_curve_advanced_test.cdc b/cadence/tests/interest_curve_advanced_test.cdc index c1005703..aac2f935 100644 --- a/cadence/tests/interest_curve_advanced_test.cdc +++ b/cadence/tests/interest_curve_advanced_test.cdc @@ -39,9 +39,9 @@ fun setup() { // 4. Rate change ratios are mathematically correct // // Scenario using a single pool that evolves over time: -// - Phase 1: 10 days at 5% APY -// - Phase 2: 10 days at 15% APY (3x rate) -// - Phase 3: 10 days at 10% APY (2x original rate) +// - Phase 1: 10 days at a 5% nominal yearly rate +// - Phase 2: 10 days at a 15% nominal yearly rate (3x rate) +// - Phase 3: 10 days at a 10% nominal yearly rate (2x original rate) // ============================================================================= access(all) fun test_curve_change_mid_accrual_and_rate_segmentation() { @@ -105,7 +105,7 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { // ------------------------------------------------------------------------- // STEP 4: Set Initial Interest Rate (Phase 1 Configuration) // ------------------------------------------------------------------------- - // Configure MOET with a fixed 5% APY interest rate. + // Configure MOET with a fixed 5% nominal yearly interest rate. // This is the baseline rate we'll compare other phases against. // Using FixedCurve means rate doesn't depend on utilization. let rate1: UFix128 = 0.05 @@ -114,7 +114,7 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: rate1 ) - log("Set MOET interest rate to 5% APY (Phase 1)") + log("Set MOET interest rate to 5% nominal yearly rate (Phase 1)") // set insurance swapper let res = setInsuranceSwapper( @@ -169,7 +169,7 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { log("Initial debt: \(debtT0.toString())") // 6153.84615384 MOET // ========================================================================= - // PHASE 1: 10 Days at 5% APY + // PHASE 1: 10 Days at a 5% Nominal Yearly Rate // ========================================================================= // Advance blockchain time by 10 days and observe interest accrual. // Formula: perSecondRate = 1 + 0.05/31_557_600, factor = perSecondRate^864000 @@ -196,9 +196,9 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { Test.assert(diff1 <= tolerance, message: "Phase 1 growth should be ~8.42992491. Actual: \(growth1)") // ------------------------------------------------------------------------- - // STEP 7: Change Interest Rate to 15% APY (Phase 2 Configuration) + // STEP 7: Change Interest Rate to a 15% Nominal Yearly Rate (Phase 2 Configuration) // ------------------------------------------------------------------------- - // Triple the interest rate to 15% APY. This tests that: + // Triple the nominal yearly rate to 15%. This tests that: // 1. Interest accrued at old rate (5%) is finalized before curve change // 2. New rate (15%) is applied correctly for subsequent accrual // 3. The ratio of growth reflects the ratio of rates (15%/5% = 3x) @@ -209,10 +209,10 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { yearlyRate: rate2 ) - log("Changed MOET interest rate to 15% APY (Phase 2)") + log("Changed MOET interest rate to 15% nominal yearly rate (Phase 2)") // ========================================================================= - // PHASE 2: 10 Days at 15% APY + // PHASE 2: 10 Days at a 15% Nominal Yearly Rate // ========================================================================= // Advance another 10 days at the higher rate. // Expected: growth2 should be approximately 3x growth1 (since 15%/5% = 3). @@ -234,7 +234,7 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { Test.assert(diff2 <= tolerance, message: "Phase 2 growth should be ~25.35912505. Actual: \(growth2)") // ------------------------------------------------------------------------- - // STEP 8: Change Interest Rate to 10% APY (Phase 3 Configuration) + // STEP 8: Change Interest Rate to a 10% Nominal Yearly Rate (Phase 3 Configuration) // ------------------------------------------------------------------------- // Set rate to 10% (2x the original 5%, 0.67x Phase 2's 15%). // This validates that multiple consecutive rate changes work correctly. @@ -244,12 +244,12 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: rate3 ) - log("Changed MOET interest rate to 10% APY (Phase 3)") + log("Changed MOET interest rate to 10% nominal yearly rate (Phase 3)") // ========================================================================= - // PHASE 3: 10 Days at 10% APY + // PHASE 3: 10 Days at a 10% Nominal Yearly Rate // ========================================================================= - // Final 10-day period at 10% APY. + // Final 10-day period at a 10% nominal yearly rate. // Expected: growth3 should be approximately 2x growth1 (since 10%/5% = 2). Test.moveTime(by: TEN_DAYS) Test.commitBlock() @@ -338,7 +338,7 @@ fun test_curve_change_mid_accrual_and_rate_segmentation() { // Formula: FinalBalance = InitialBalance × (1 + r/n)^(n×t) for per-second compounding // The protocol uses discrete per-second compounding with exponentiation by squaring. // -// Expected: 10% APY should yield ~10.52% effective rate ((1 + 0.10/31_557_600)^31_557_600 ≈ 1.10517) +// Expected: a 10% nominal yearly rate should yield ~10.52% effective one-year growth ((1 + 0.10/31_557_600)^31_557_600 ≈ 1.10517) // ============================================================================= access(all) fun test_exact_compounding_verification_one_year() { @@ -354,16 +354,16 @@ fun test_exact_compounding_verification_one_year() { // ------------------------------------------------------------------------- // STEP 1: Configure a Known Interest Rate for Mathematical Verification // ------------------------------------------------------------------------- - // Set MOET to exactly 10% APY. This round number makes it easy to verify + // Set MOET to exactly a 10% nominal yearly rate. This round number makes it easy to verify // that the compounding formula is working correctly. - // 10% APY with per-second compounding yields: (1 + 0.10/31_557_600)^31_557_600 - 1 ≈ 10.517% effective rate + // A 10% nominal yearly rate with per-second compounding yields: (1 + 0.10/31_557_600)^31_557_600 - 1 ≈ 10.517% effective rate let yearlyRate: UFix128 = 0.10 setInterestCurveFixed( signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: yearlyRate ) - log("Set MOET interest rate to 10% APY for compounding verification") + log("Set MOET interest rate to 10% nominal yearly rate for compounding verification") // ------------------------------------------------------------------------- // STEP 2: Record Starting Debt Before Time Advancement @@ -408,7 +408,7 @@ fun test_exact_compounding_verification_one_year() { // MATHEMATICAL BACKGROUND: Per-Second Compounding // ========================================================================= // Formula: factor = (1 + r/31_557_600)^31_557_600 - // At 10% APY: factor = (1 + 0.10/31_557_600)^31_557_600 ≈ 1.10517092 + // At a 10% nominal yearly rate: factor = (1 + 0.10/31_557_600)^31_557_600 ≈ 1.10517092 // This is discrete per-second compounding with exponentiation by squaring. // Note: Using 31557600 seconds/year (365.25 days) // ========================================================================= @@ -422,7 +422,7 @@ fun test_exact_compounding_verification_one_year() { // Expected growth = debtBefore * (factor - 1) = 6204.59926707 * 0.105170918 ≈ 652.54340074 MOET // Note: Tests run sequentially with accumulated interest, so exact values depend on debtBefore - // Verify growth rate is approximately 10.52% (the effective rate from 10% APY compounded per-second) + // Verify growth rate is approximately 10.52% (the effective yield from a 10% nominal yearly rate compounded per second) let expectedGrowthRate: UFix64 = 0.10517091 let expectedGrowth: UFix64 = 652.54340074 let tolerance: UFix64 = 0.001 @@ -589,15 +589,15 @@ fun test_credit_rate_changes_with_curve() { // ------------------------------------------------------------------------- // STEP 1: Set a Known Interest Rate for Credit Verification // ------------------------------------------------------------------------- - // Configure MOET with 8% APY. This will be the debit rate. + // Configure MOET with an 8% nominal yearly rate. This will be the debit rate. // The LP should earn slightly less (approximately 7.9% after insurance). - let testRate: UFix128 = 0.08 // 8% APY + let testRate: UFix128 = 0.08 // 8% nominal yearly rate setInterestCurveFixed( signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: testRate ) - log("Set MOET interest rate to 8% APY") + log("Set MOET interest rate to 8% nominal yearly rate") // ------------------------------------------------------------------------- // STEP 2: Record LP's Credit Balance Before Time Advancement @@ -648,7 +648,7 @@ fun test_credit_rate_changes_with_curve() { // Verify credit growth rate equals expected value // Formula: creditRate = debitRate * (1 - protocolFeeRate) // where protocolFeeRate = insuranceRate + stabilityFeeRate = 0.001 + 0.05 = 0.051 - // creditRate = 0.08 * (1 - 0.051) = 0.08 * 0.949 = 0.07592 APY (7.592%) + // creditRate = 0.08 * (1 - 0.051) = 0.08 * 0.949 = 0.07592 nominal yearly credit rate (7.592%) // perSecondRate = 1 + (0.07592/31557600), factor = perSecondRate^2592000 // Expected 30-day growth rate = factor - 1 ≈ 0.00625521141 // Expected credit growth = creditBefore * 0.00625950922 ≈ 346.58 MOET diff --git a/cadence/tests/interest_curve_test.cdc b/cadence/tests/interest_curve_test.cdc index adbfa692..afa9c78f 100644 --- a/cadence/tests/interest_curve_test.cdc +++ b/cadence/tests/interest_curve_test.cdc @@ -18,7 +18,7 @@ fun setup() { access(all) fun test_FixedCurve_returns_constant_rate() { - // Create a fixed rate curve with 5% APY + // Create a fixed rate curve with a 5% nominal yearly rate let fixedRate: UFix128 = 0.05 let curve = FlowALPInterestRates.FixedCurve(yearlyRate: fixedRate) @@ -32,7 +32,7 @@ fun test_FixedCurve_returns_constant_rate() { access(all) fun test_FixedCurve_accepts_zero_rate() { - // Zero rate should be valid (0% APY) + // Zero rate should be valid (0% nominal yearly rate) let curve = FlowALPInterestRates.FixedCurve(yearlyRate: 0.0) let rate = curve.interestRate(creditBalance: 100.0, debitBalance: 50.0) Test.assertEqual(0.0 as UFix128, rate) diff --git a/cadence/tests/kink_curve_utilization_regression_test.cdc b/cadence/tests/kink_curve_utilization_regression_test.cdc index b7c5924f..17f4011b 100644 --- a/cadence/tests/kink_curve_utilization_regression_test.cdc +++ b/cadence/tests/kink_curve_utilization_regression_test.cdc @@ -89,6 +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=\(tokenState.getTotalCreditBalance()) and debitBalance=\(tokenState.getTotalDebitBalance()), producing \(actualYearlyRate) instead" + "Regression: 100 supplied / 90 borrowed should price at 90% utilization (0.35 nominal yearly rate), but current accounting passed creditBalance=\(tokenState.getTotalCreditBalance()) and debitBalance=\(tokenState.getTotalDebitBalance()), producing \(actualYearlyRate) instead" ) } diff --git a/cadence/tests/update_interest_rate_test.cdc b/cadence/tests/update_interest_rate_test.cdc index 4dab8f76..bab33343 100644 --- a/cadence/tests/update_interest_rate_test.cdc +++ b/cadence/tests/update_interest_rate_test.cdc @@ -27,7 +27,7 @@ fun setup() { } // ============================================================================= -// FixedCurve Tests (Spread Model: creditRate = debitRate - insuranceRate) +// FixedCurve Tests (Spread Model: creditRate = debitRate * (1 - protocolFeeRate)) // ============================================================================= access(all) diff --git a/cadence/transactions/flow-alp/pool-governance/add_supported_token_fixed_rate_curve.cdc b/cadence/transactions/flow-alp/pool-governance/add_supported_token_fixed_rate_curve.cdc index c8cddf63..259c3051 100644 --- a/cadence/transactions/flow-alp/pool-governance/add_supported_token_fixed_rate_curve.cdc +++ b/cadence/transactions/flow-alp/pool-governance/add_supported_token_fixed_rate_curve.cdc @@ -3,7 +3,7 @@ import "FlowALPModels" import "FlowALPInterestRates" /// Adds a token type as supported to the stored pool with a fixed-rate interest curve. -/// This uses FixedCurve for a constant yearly interest rate regardless of utilization. +/// This uses FixedCurve for a constant nominal yearly interest rate regardless of utilization. /// transaction( tokenTypeIdentifier: String, diff --git a/cadence/transactions/flow-alp/pool-governance/add_supported_token_zero_rate_curve.cdc b/cadence/transactions/flow-alp/pool-governance/add_supported_token_zero_rate_curve.cdc index af16a460..eced40c2 100644 --- a/cadence/transactions/flow-alp/pool-governance/add_supported_token_zero_rate_curve.cdc +++ b/cadence/transactions/flow-alp/pool-governance/add_supported_token_zero_rate_curve.cdc @@ -2,7 +2,7 @@ import "FlowALPv0" import "FlowALPModels" import "FlowALPInterestRates" -/// Adds a token type as supported to the stored pool with a zero-rate interest curve (0% APY). +/// Adds a token type as supported to the stored pool with a zero-rate interest curve (0% nominal yearly rate). /// This uses FixedCurve with yearlyRate: 0.0, suitable for testing or /// scenarios where no interest should accrue. /// diff --git a/cadence/transactions/flow-alp/pool-governance/set_interest_curve_fixed.cdc b/cadence/transactions/flow-alp/pool-governance/set_interest_curve_fixed.cdc index 88059754..5aad08fc 100644 --- a/cadence/transactions/flow-alp/pool-governance/set_interest_curve_fixed.cdc +++ b/cadence/transactions/flow-alp/pool-governance/set_interest_curve_fixed.cdc @@ -3,7 +3,7 @@ import "FlowALPModels" import "FlowALPInterestRates" /// Updates the interest curve for an existing supported token to a FixedCurve. -/// This sets a constant yearly interest rate regardless of utilization. +/// This sets a constant nominal yearly interest rate regardless of utilization. /// transaction( tokenTypeIdentifier: String, diff --git a/docs/interest_rate_and_protocol_fees.md b/docs/interest_rate_and_protocol_fees.md index 137dcbca..8d351a45 100644 --- a/docs/interest_rate_and_protocol_fees.md +++ b/docs/interest_rate_and_protocol_fees.md @@ -20,7 +20,7 @@ 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 continuously collected 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 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. #### Stability Fund @@ -34,7 +34,7 @@ Both fees are deducted from the interest income that would otherwise go to lende ### 1. Debit Rate Calculation -For each token, the protocol stores one interest curve. The debit rate (borrow APY) is computed from that curve and the current pool utilization: +For each token, the protocol stores one interest curve. The debit rate (borrow nominal yearly rate) is computed from that curve and the current pool utilization: ```text if totalDebitBalance == 0: @@ -64,21 +64,21 @@ Utilization in this model is: - `100%` when debit and credit balances are equal - capped at `100%` in defensive edge cases where debt exceeds supply or supply is zero while debt remains positive -### FixedCurve (constant APY) +### FixedCurve (constant nominal yearly rate) -For `FixedCurve`, debit APY is constant regardless of utilization: +For `FixedCurve`, the debit nominal yearly rate is constant regardless of utilization: ``` debitRate = yearlyRate ``` Example: -- `yearlyRate = 0.05` (5% APY) -- debit APY stays at 5% whether utilization is 10% or 95% +- `yearlyRate = 0.05` (5% nominal yearly rate) +- the debit nominal yearly rate stays at 5% whether utilization is 10% or 95% -### KinkCurve (utilization-based APY) +### KinkCurve (utilization-based nominal yearly rate) -For `KinkCurve`, debit APY follows a two-segment curve: +For `KinkCurve`, the debit nominal yearly rate follows a two-segment curve: - below `optimalUtilization` ("before the kink"), rates rise gently - above `optimalUtilization` ("after the kink"), rates rise steeply @@ -117,15 +117,15 @@ Reference values discussed for volatile assets: - `slope2 = 300%` (`3.0`) Interpretation: -- at or below 45% utilization, borrowers see relatively low/gradual APY increases -- above 45%, APY increases very aggressively to push utilization back down -- theoretical max debit APY at 100% utilization is `304%` (`0% + 4% + 300%`) +- at or below 45% utilization, borrowers see relatively low/gradual nominal-rate increases +- above 45%, the nominal yearly rate increases very aggressively to push utilization back down +- theoretical max debit nominal yearly rate at 100% utilization is `304%` (`0% + 4% + 300%`) This is the mechanism that helps protect withdrawal liquidity under stress. ### 2. Credit Rate Calculation -The credit rate (deposit APY) is derived from debit-side income after protocol fees. +The credit rate (deposit nominal yearly rate) is derived from debit-side income after protocol fees. Shared definitions: @@ -140,7 +140,7 @@ For **FixedCurve** (used for stable assets like MOET): creditRate = debitRate * (1 - protocolFeeRate) ``` -This gives a simple spread model between borrow APY and lend APY. +This gives a simple spread model between borrow and lend nominal yearly rates. For **KinkCurve** and other non-fixed curves: ``` @@ -156,7 +156,7 @@ This computes lender yield from actual debit-side income, after reserve deductio ### 3. Per-Second Rate Conversion -Both credit and debit rates are converted from annual rates to per-second compounding rates: +Both credit and debit rates are converted from nominal annual rates to per-second compounding rates: ``` perSecondRate = (yearlyRate / secondsInYear) + 1.0 @@ -164,7 +164,9 @@ perSecondRate = (yearlyRate / secondsInYear) + 1.0 Where `secondsInYear = 31_557_600` (365.25 days × 24 hours × 60 minutes × 60 seconds). -This conversion allows for continuous compounding of interest over time. +This conversion allows for discrete per-second compounding of interest over time. + +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. ### 4. Querying Curve Parameters On-Chain @@ -186,7 +188,7 @@ The protocol uses **interest indices** to track how interest accrues over time. ### Compounding Interest -Interest compounds continuously using the formula: +Interest compounds via discrete per-second updates using the formula: ``` newIndex = oldIndex * (perSecondRate ^ elapsedSeconds) @@ -219,7 +221,7 @@ Interest indices are updated whenever: 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. +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. ## Insurance Collection Mechanism @@ -229,7 +231,7 @@ The insurance mechanism collects a percentage of interest income over time, swap ### Insurance Rate -Each token has a configurable `insuranceRate` (default: 0.0) that represents the annual percentage of interest income that should be collected as insurance. +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. ### Collection Process @@ -253,7 +255,7 @@ Insurance is collected through the `collectInsurance()` function on `TokenState` 4. **Deposits to Insurance Fund**: - The collected MOET is deposited into the protocol's insurance fund - - This fund grows continuously and is never withdrawable + - This fund grows as insurance is collected and is never withdrawable ### Integration with Rate Updates @@ -313,7 +315,7 @@ The stability fee mechanism collects a percentage of interest income over time a ### Stability Fee Rate -Each token has a configurable `stabilityFeeRate` (default: 0.05 or 5%) that represents the percentage of interest income that should be collected as stability fees. +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. ### Collection Process @@ -364,7 +366,7 @@ 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 - - Debit rate: 10% APY + - Debit rate: 10% nominal yearly rate - Insurance rate: 0.1% (of interest income) - Stability fee rate: 5% (of interest income) @@ -374,7 +376,7 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. - 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 APY: 798.46 / 10,000 = 7.98% + - 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) @@ -382,7 +384,7 @@ This emits a `StabilityFundWithdrawn` event for transparency and accountability. ## Key Design Decisions -1. **Continuous Compounding**: Interest compounds continuously using per-second rates, providing fair and accurate interest accrual. +1. **Discrete Per-Second Compounding**: Interest compounds via per-second updates, providing fair and accurate interest accrual. 2. **Scaled vs True Balances**: Storing scaled balances (principal) separately from interest indices allows efficient storage while maintaining precision.