diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 6e626356..589c572d 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1490,8 +1490,14 @@ access(all) contract FlowALPv0 { message:"Cannot set non-zero insurance rate without an insurance swapper configured for \(tokenType.identifier)", ) } + // 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) + tsRef.setInsuranceRate(insuranceRate) + // Recalculate currentCreditRate for a given token to reflect the new insurance rate + tsRef.updateInterestRates() + FlowALPEvents.emitInsuranceRateUpdated( poolUUID: self.uuid, tokenType: tokenType.identifier, @@ -1594,8 +1600,15 @@ access(all) contract FlowALPv0 { } let tsRef = self.state.borrowTokenState(tokenType) ?? 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) + tsRef.setStabilityFeeRate(stabilityFeeRate) + // Recalculate currentCreditRate for a given token to reflect the new stability rate + tsRef.updateInterestRates() + FlowALPEvents.emitStabilityFeeRateUpdated( poolUUID: self.uuid, tokenType: tokenType.identifier, diff --git a/cadence/tests/insurance_collection_test.cdc b/cadence/tests/insurance_collection_test.cdc index 87aa8d96..f90d79df 100644 --- a/cadence/tests/insurance_collection_test.cdc +++ b/cadence/tests/insurance_collection_test.cdc @@ -3,6 +3,7 @@ import BlockchainHelpers import "MOET" import "FlowToken" +import "FlowALPEvents" import "test_helpers.cdc" access(all) var snapshot: UInt64 = 0 @@ -10,31 +11,29 @@ access(all) var snapshot: UInt64 = 0 access(all) fun setup() { deployContracts() - createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + // take snapshot first, then advance time so reset() target is always lower than current height + snapshot = getCurrentBlockHeight() + // move time by 1 second so Test.reset() works properly before each test + Test.moveTime(by: 1.0) +} - // Add FlowToken as a supported collateral type (needed for borrowing scenarios) +access(all) +fun beforeEach() { + Test.reset(to: snapshot) + + // Recreate pool and supported tokens fresh for each test + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) addSupportedTokenZeroRateCurve( signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, collateralFactor: 0.8, - borrowFactor: 1.0, + borrowFactor: 0.9, depositRate: 1_000_000.0, depositCapacityCap: 1_000_000.0 ) - - // take snapshot first, then advance time so reset() target is always lower than current height - snapshot = getCurrentBlockHeight() - // move time by 1 second so Test.reset() works properly before each test - Test.moveTime(by: 1.0) } -access(all) -fun beforeEach() { - Test.reset(to: snapshot) -} - - // ----------------------------------------------------------------------------- // Test: collectInsurance when no insurance rate is configured should complete without errors // The collectInsurance function should return nil internally and not fail @@ -450,4 +449,132 @@ fun test_collectInsurance_dexOracleSlippageProtection() { // verify insurance was collected let finalBalance = getInsuranceFundBalance() Test.assert(finalBalance > 0.0, message: "Insurance fund should have received MOET after successful collection") +} + +// ----------------------------------------------------------------------------- +/// Verifies that insurance collection remains correct when the insurance +/// rate changes between collection periods. Rate changes must trigger fee collections, +/// so that all fees due under the previous rate are collected before the new rate comes into effect. +// ----------------------------------------------------------------------------- +access(all) +fun test_collectInsurance_midPeriodRateChange() { + // configure the protocol FLOW wallet and the insurance swapper + setupMoetVault(PROTOCOL_ACCOUNT, beFailed: false) + mintMoet(signer: PROTOCOL_ACCOUNT, to: PROTOCOL_ACCOUNT.address, amount: 10000.0, beFailed: false) + 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 insurance rate + var rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.1) + Test.expect(rateResult, Test.beSucceeded()) + + // provide FLOW liquidity so the borrower can actually borrow + let lp = Test.createAccount() + let resMint = mintFlow(to: lp, amount: 10000.0) + Test.expect(resMint, Test.beSucceeded()) + createPosition(admin: PROTOCOL_ACCOUNT, signer: lp, amount: 10000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + // borrower deposits 1000 MOET as collateral + let borrower = Test.createAccount() + setupMoetVault(borrower, beFailed: false) + mintMoet(signer: PROTOCOL_ACCOUNT, to: borrower.address, amount: 1000.0, beFailed: false) + createPosition(admin: PROTOCOL_ACCOUNT, signer: borrower, amount: 1000.0, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) + + let openEvents = Test.eventsOfType(Type()) + let pid = (openEvents[openEvents.length - 1] as! FlowALPEvents.Opened).pid + + // collateralValue = 1000 MOET * price(MOET=1.0) * CF(1) = 1000$ + // targetDebtValue = collateralValue / targetHealth(1.3) = 1000/1.3 = 769.2307692$ + // Max FLOW borrow = targetDebtValue * BF(0.9) / price(FLOW=1.0) ≈ 692.3076923 FLOW + + // borrow 500 FLOW + borrowFromPosition( + signer: borrower, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + amount: 500.0, + beFailed: false + ) + + let reservesBefore_phase1 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + + // 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) + // + // 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 + + // change the insurance rate to 20% for phase 2 + rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.2) + Test.expect(rateResult, Test.beSucceeded()) + + let insuranceAfterPhase1 = getInsuranceFundBalance() + let reservesAfterPhase1 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + let collected_phase1 = reservesBefore_phase1 - reservesAfterPhase1 + + // 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.00001 + var diff = expectedCollectedInsuranceAmountAfterPhase1 > insuranceAfterPhase1 + ? expectedCollectedInsuranceAmountAfterPhase1 - insuranceAfterPhase1 + : insuranceAfterPhase1 - expectedCollectedInsuranceAmountAfterPhase1 + Test.assert(diff < tolerance, message: "Insurance collected should be around \(expectedCollectedInsuranceAmountAfterPhase1) but current \(insuranceAfterPhase1)") + Test.assertEqual(collected_phase1, insuranceAfterPhase1) + + let reservesBefore_phase2 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + + 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 + // + // 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 + + // change the insurance rate to 25% + rateResult = setInsuranceRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, insuranceRate: 0.25) + Test.expect(rateResult, Test.beSucceeded()) + + let insuranceAfterPhase2 = getInsuranceFundBalance() + let reservesAfterPhase2 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + + // 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 expectedCollectedInsuranceAmount= expectedCollectedInsuranceAmountAfterPhase1 + expectedCollectedInsuranceAmountAfterPhase2 // 5.25854589 + 10.51709179 + diff = expectedCollectedInsuranceAmount > insuranceAfterPhase2 + ? expectedCollectedInsuranceAmount - insuranceAfterPhase2 + : insuranceAfterPhase2 - expectedCollectedInsuranceAmount + + Test.assert(diff < tolerance, message: "Insurance collected should be around \(expectedCollectedInsuranceAmount) but current \(insuranceAfterPhase2)") + + // acumulative insurance fund must equal sum of both collections + let collected_phase2 = reservesBefore_phase2 - reservesAfterPhase2 + Test.assertEqual(insuranceAfterPhase2, insuranceAfterPhase1 + collected_phase2) + Test.assert(collected_phase2 > collected_phase1, message: "Phase 2 collection should exceed phase 1 due to higher rate") } \ No newline at end of file diff --git a/cadence/tests/stability_collection_test.cdc b/cadence/tests/stability_collection_test.cdc index 7d0eb36e..7550ade8 100644 --- a/cadence/tests/stability_collection_test.cdc +++ b/cadence/tests/stability_collection_test.cdc @@ -3,6 +3,7 @@ import BlockchainHelpers import "MOET" import "FlowToken" +import "FlowALPEvents" import "test_helpers.cdc" access(all) var snapshot: UInt64 = 0 @@ -10,21 +11,6 @@ access(all) var snapshot: UInt64 = 0 access(all) fun setup() { deployContracts() - - // Add FlowToken as a supported collateral type (needed for borrowing scenarios) - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: 1.0) - setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) - createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) - - addSupportedTokenZeroRateCurve( - signer: PROTOCOL_ACCOUNT, - tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 1_000_000.0, - depositCapacityCap: 1_000_000.0 - ) - // take snapshot first, then advance time so reset() target is always lower than current height snapshot = getCurrentBlockHeight() // move time by 1 second so Test.reset() works properly before each test @@ -34,6 +20,18 @@ fun setup() { access(all) fun beforeEach() { Test.reset(to: snapshot) + + // Recreate pool and supported tokens fresh for each test + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 0.9, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) } // ----------------------------------------------------------------------------- @@ -73,7 +71,15 @@ fun test_collectStability_zeroDebitBalance_returnsNil() { // ----------------------------------------------------------------------------- access(all) fun test_collectStability_partialReserves_collectsAvailable() { - // setup LP to provide MOET liquidity for borrowing (small amount to create limited reserves) + // set 90% annual debit rate for MOET + setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.9) + + // set a high stability fee rate so calculated amount would exceed reserves + // Note: stabilityFeeRate must be < 1.0, using 0.9 which combined with default insuranceRate (0.0) = 0.9 < 1.0 + let rateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, stabilityFeeRate: 0.9) + Test.expect(rateResult, Test.beSucceeded()) + + // setup LP to provide MOET liquidity (small amount to create limited reserves) let lp = Test.createAccount() setupMoetVault(lp, beFailed: false) mintMoet(signer: PROTOCOL_ACCOUNT, to: lp.address, amount: 1000.0, beFailed: false) @@ -87,22 +93,11 @@ fun test_collectStability_partialReserves_collectsAvailable() { transferFlowTokens(to: borrower, amount: 10000.0) // borrower deposits 10000 FLOW and auto-borrows MOET - // With 0.8 CF and 1.3 target health: 10000 FLOW allows borrowing ~6153 MOET - // But pool only has 1000 MOET, so borrower gets ~1000 MOET (limited by liquidity) - // This leaves reserves very low (close to 0) + // With CF(0.8) and targetHealth(1.3): 10000 FLOW * 0.8 / 1.3 ≈ 6153 MOET borrowable + // but pool only has 1000 MOET, so borrower gets ~1000 MOET (limited by liquidity) + // this leaves reserves very close to 0 createPosition(admin: PROTOCOL_ACCOUNT, signer: borrower, amount: 10000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) - setupMoetVault(PROTOCOL_ACCOUNT, beFailed: false) - mintMoet(signer: PROTOCOL_ACCOUNT, to: PROTOCOL_ACCOUNT.address, amount: 10000.0, beFailed: false) - - // set 90% annual debit rate - setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, yearlyRate: 0.9) - - // set a high stability fee rate so calculated amount would exceed reserves - // Note: stabilityFeeRate must be < 1.0, using 0.9 which combined with default insuranceRate (0.0) = 0.9 < 1.0 - let rateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, stabilityFeeRate: 0.9) - Test.expect(rateResult, Test.beSucceeded()) - let initialStabilityBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) Test.assertEqual(nil, initialStabilityBalance) @@ -115,7 +110,7 @@ fun test_collectStability_partialReserves_collectsAvailable() { let finalStabilityBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) let reserveBalanceAfter = getReserveBalance(vaultIdentifier: MOET_TOKEN_IDENTIFIER) - // stability fund balance should equal amount withdrawn from reserves + // reserves should be fully drained Test.assertEqual(0.0, reserveBalanceAfter) // verify collection was limited by reserves @@ -172,6 +167,17 @@ 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) + + let moetRateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER, stabilityFeeRate: 0.1) + Test.expect(moetRateResult, Test.beSucceeded()) + + let flowRateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, stabilityFeeRate: 0.05) + Test.expect(flowRateResult, Test.beSucceeded()) + // Note: FlowToken is already added in setup() // setup MOET LP to provide MOET liquidity for borrowing @@ -208,19 +214,7 @@ 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 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% - Test.expect(moetRateResult, Test.beSucceeded()) - - let flowRateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, stabilityFeeRate: 0.05) // 5% - Test.expect(flowRateResult, Test.beSucceeded()) - - // verify initial state + // verify initial state — both funds must be nil since rates were set before any borrowing let initialMoetStabilityBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) Test.assertEqual(nil, initialMoetStabilityBalance) let initialFlowStabilityBalance = getStabilityFundBalance(tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER) @@ -316,4 +310,117 @@ fun test_collectStability_zeroRate_returnsNil() { // verify stability fund balance is still nil let finalBalance = getStabilityFundBalance(tokenTypeIdentifier: MOET_TOKEN_IDENTIFIER) Test.assertEqual(nil, finalBalance) +} + +// ----------------------------------------------------------------------------- +/// Verifies that stability fee collection remains correct when the stability +/// fee rate changes between collection periods. Rate changes must trigger fee collections, +/// so that all fees due under the previous rate are collected before the new rate comes into effect. +// ----------------------------------------------------------------------------- +access(all) +fun test_collectStability_midPeriodRateChange() { + // set interest curve + setInterestCurveFixed(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, yearlyRate: 0.1) + + // provide FLOW liquidity so the borrower can actually borrow + let lp = Test.createAccount() + let resMint = mintFlow(to: lp, amount: 10000.0) + Test.expect(resMint, Test.beSucceeded()) + createPosition(admin: PROTOCOL_ACCOUNT, signer: lp, amount: 10000.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + // borrower deposits 1000 MOET as collateral + let borrower = Test.createAccount() + setupMoetVault(borrower, beFailed: false) + mintMoet(signer: PROTOCOL_ACCOUNT, to: borrower.address, amount: 1000.0, beFailed: false) + createPosition(admin: PROTOCOL_ACCOUNT, signer: borrower, amount: 1000.0, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) + + let openEvents = Test.eventsOfType(Type()) + let pid = (openEvents[openEvents.length - 1] as! FlowALPEvents.Opened).pid + + // collateralValue = 1000 MOET * price(MOET=1.0) * CF(1) = 1000$ + // targetDebtValue = collateralValue / targetHealth(1.3) = 1000/1.3 = 769.2307692$ + // Max FLOW borrow = targetDebtValue * BF(0.9) / price(FLOW=1.0) ≈ 692.3076923 FLOW + + // borrow 500 FLOW + borrowFromPosition( + signer: borrower, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + amount: 500.0, + beFailed: false + ) + + let reservesBefore_phase1 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + + // 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) + // + // 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 + + // change the stability fee rate to 20% for phase 2 + var rateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, stabilityFeeRate: 0.2) + Test.expect(rateResult, Test.beSucceeded()) + + let stabilityAfterPhase1 = getStabilityFundBalance(tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER)! + let reservesAfterPhase1 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + let collected_phase1 = reservesBefore_phase1 - reservesAfterPhase1 + + // NOTE: + // We intentionally do not use `equalWithinVariance` with `defaultUFixVariance` here. + // The default variance is designed for deterministic math, but stability collection + // depends on block timestamps, which can differ slightly between test runs. + // A larger, time-aware tolerance is required. + let tolerance = 0.00001 + var diff = expectedStabilityAmountAfterPhase1 > stabilityAfterPhase1 + ? expectedStabilityAmountAfterPhase1 - stabilityAfterPhase1 + : stabilityAfterPhase1 - expectedStabilityAmountAfterPhase1 + Test.assert(diff < tolerance, message: "Stability collected should be around \(expectedStabilityAmountAfterPhase1) but current \(stabilityAfterPhase1)") + + // stability fund balance must equal what was withdrawn from reserves + // (no swap needed — stability is collected in the same token as the reserve) + Test.assertEqual(collected_phase1, stabilityAfterPhase1) + + let reservesBefore_phase2 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + + // 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) + // + // 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 + + // change the stability rate to 25% + rateResult = setStabilityFeeRate(signer: PROTOCOL_ACCOUNT, tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, stabilityFeeRate: 0.25) + Test.expect(rateResult, Test.beSucceeded()) + + let stabilityAfterPhase2 = getStabilityFundBalance(tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER)! + let reservesAfterPhase2 = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + + let expectedStabilityTotal = expectedStabilityAmountAfterPhase1 + expectedStabilityAmountAfterPhase2 // 2.62927294 + 10.51709179 + diff = expectedStabilityTotal > stabilityAfterPhase2 + ? expectedStabilityTotal - stabilityAfterPhase2 + : stabilityAfterPhase2 - expectedStabilityTotal + Test.assert(diff < tolerance, message: "Stability collected should be around \(expectedStabilityTotal) but current \(stabilityAfterPhase2)") + + // acumulative stability fund must equal sum of both collections + let collected_phase2 = reservesBefore_phase2 - reservesAfterPhase2 + Test.assertEqual(stabilityAfterPhase2, stabilityAfterPhase1 + collected_phase2) + Test.assert(collected_phase2 > collected_phase1, message: "Phase 2 collection should exceed phase 1 due to higher rate") } \ No newline at end of file