diff --git a/.github/workflows/cadence_tests.yml b/.github/workflows/cadence_tests.yml index dd67efb5..d0fe6a17 100644 --- a/.github/workflows/cadence_tests.yml +++ b/.github/workflows/cadence_tests.yml @@ -28,6 +28,10 @@ jobs: key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- + - uses: actions/cache@v4 + with: + path: ./imports + key: flow-deps-${{ hashFiles('flow.json') }} - name: Install Flow CLI env: FLOW_CLI_VERSION: v2.7.2 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 fbbd1586..6f0a596f 100644 --- a/cadence/lib/FlowALPMath.cdc +++ b/cadence/lib/FlowALPMath.cdc @@ -100,13 +100,11 @@ access(all) contract FlowALPMath { } /// 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 is the nominal annual interest rate (e.g. 0.05 for 5%), and - /// the result is the per-second multiplier (e.g. 1.000000001585). - /// - /// NOTE: This uses linear (nominal) decomposition — the per-second rate is simply `nominalRate / secondsPerYear`. - /// Because interest is then applied as `rate^timeElapsed` (exponential compounding), the effective APY will - /// exceed the stated nominal rate. Use `effectiveYearlyRate` to compute the true effective annual yield. - access(all) view fun perSecondInterestRate(nominalYearlyRate: UFix128): UFix128 { + /// 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 = nominalYearlyRate / 31_557_600.0 // 365.25 * 24.0 * 60.0 * 60.0 assert( perSecondScaledValue < UFix128.max, 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/fork_multiple_positions_per_user_test.cdc b/cadence/tests/fork_multiple_positions_per_user_test.cdc new file mode 100644 index 00000000..971873e4 --- /dev/null +++ b/cadence/tests/fork_multiple_positions_per_user_test.cdc @@ -0,0 +1,987 @@ +#test_fork(network: "mainnet-fork", height: 142528994) + +import Test +import BlockchainHelpers + +import "FlowToken" +import "FungibleToken" +import "MOET" +import "FlowALPEvents" + +import "test_helpers.cdc" + +access(all) let MAINNET_PROTOCOL_ACCOUNT = Test.getAccount(MAINNET_PROTOCOL_ACCOUNT_ADDRESS) +access(all) let MAINNET_USDF_HOLDER = Test.getAccount(MAINNET_USDF_HOLDER_ADDRESS) +access(all) let MAINNET_WETH_HOLDER = Test.getAccount(MAINNET_WETH_HOLDER_ADDRESS) +access(all) let MAINNET_WBTC_HOLDER = Test.getAccount(MAINNET_WBTC_HOLDER_ADDRESS) +access(all) let MAINNET_FLOW_HOLDER = Test.getAccount(MAINNET_FLOW_HOLDER_ADDRESS) +access(all) let MAINNET_USDC_HOLDER = Test.getAccount(MAINNET_USDC_HOLDER_ADDRESS) + +access(all) var snapshot: UInt64 = 0 + +// ─── Protocol constants (set once in setup(), referenced in formula comments) ── + +// Initial oracle prices +access(all) let PRICE_FLOW = 1.0 +access(all) let PRICE_USDC = 1.0 +access(all) let PRICE_USDF = 1.0 +access(all) let PRICE_WETH = 3500.0 +access(all) let PRICE_WBTC = 50000.0 +access(all) let PRICE_MOET = 1.0 + +// Collateral factors +access(all) let CF_FLOW = 0.80 +access(all) let CF_USDC = 0.85 +access(all) let CF_USDF = 0.85 +access(all) let CF_WETH = 0.75 +access(all) let CF_WBTC = 0.75 + +// Minimum token balance per position +access(all) let MIN_BAL_WETH = 0.01 +access(all) let MIN_BAL_WBTC = 0.00001 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) fun setup() { + deployContracts() + + createAndStorePool(signer: MAINNET_PROTOCOL_ACCOUNT, defaultTokenIdentifier: MAINNET_MOET_TOKEN_ID, beFailed: false) + + // Set oracle prices + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_FLOW_TOKEN_ID, price: PRICE_FLOW) + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDC_TOKEN_ID, price: PRICE_USDC) + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDF_TOKEN_ID, price: PRICE_USDF) + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_WETH_TOKEN_ID, price: PRICE_WETH) + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_WBTC_TOKEN_ID, price: PRICE_WBTC) + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_MOET_TOKEN_ID, price: PRICE_MOET) + + // Add multiple token types as supported collateral (FLOW, USDC, USDF, WETH, WBTC) + addSupportedTokenZeroRateCurve( + signer: MAINNET_PROTOCOL_ACCOUNT, + tokenTypeIdentifier: MAINNET_FLOW_TOKEN_ID, + collateralFactor: CF_FLOW, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + addSupportedTokenZeroRateCurve( + signer: MAINNET_PROTOCOL_ACCOUNT, + tokenTypeIdentifier: MAINNET_USDC_TOKEN_ID, + collateralFactor: CF_USDC, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + addSupportedTokenZeroRateCurve( + signer: MAINNET_PROTOCOL_ACCOUNT, + tokenTypeIdentifier: MAINNET_USDF_TOKEN_ID, + collateralFactor: CF_USDF, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + addSupportedTokenZeroRateCurve( + signer: MAINNET_PROTOCOL_ACCOUNT, + tokenTypeIdentifier: MAINNET_WETH_TOKEN_ID, + collateralFactor: CF_WETH, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // Set minimum deposit for WETH to MIN_BAL_WETH (since holder only has 0.07032) + setMinimumTokenBalancePerPosition(signer: MAINNET_PROTOCOL_ACCOUNT, tokenTypeIdentifier: MAINNET_WETH_TOKEN_ID, minimum: MIN_BAL_WETH) + + addSupportedTokenZeroRateCurve( + signer: MAINNET_PROTOCOL_ACCOUNT, + tokenTypeIdentifier: MAINNET_WBTC_TOKEN_ID, + collateralFactor: CF_WBTC, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + // Set minimum deposit for WBTC to MIN_BAL_WBTC (since holder only has 0.0005) + setMinimumTokenBalancePerPosition(signer: MAINNET_PROTOCOL_ACCOUNT, tokenTypeIdentifier: MAINNET_WBTC_TOKEN_ID, minimum: MIN_BAL_WBTC) + + snapshot = getCurrentBlockHeight() +} + +// ============================================================================= +// Multiple Positions Per User +// +// Validates that a single user can hold 5 independent positions with distinct +// collateral types, and that operations on one position have no effect on any +// other (isolation guarantee). +// +// Pool liquidity: 800 MOET LP deposit +// +// Positions (all borrow MOET as debt): +// pos 1: 500 FLOW @ 1.00 MOET (CF=0.80), borrow 100 → health = 500*1.0*0.80/100 = 4.000 +// pos 2: 1500 USDF @ 1.00 MOET (CF=0.85), borrow 150 → health = 1500*1.0*0.85/150 = 8.500 +// pos 3: 10 USDC @ 1.00 MOET (CF=0.85), borrow 5 → health = 10*1.0*0.85/5 = 1.700 +// pos 4: 0.05 WETH @ 3500 MOET (CF=0.75), borrow 50 → health = 0.05*3500*0.75/50 = 2.625 +// pos 5: 0.0004 WBTC @ 50000 MOET (CF=0.75), borrow 8 → health = 0.0004*50000*0.75/8 = 1.875 +// +// Isolation test: borrow 100 more MOET from pos 2 (USDF) +// new debt = 150 + 100 = 250 → health = 1500*1.0*0.85/250 = 5.100 (lower) +// pos 1, 3, 4, 5: unchanged +// ============================================================================= +access(all) fun testMultiplePositionsPerUser() { + safeReset() + + log("Testing Multiple Positions with Real Mainnet Tokens\n") + + let lpUser = Test.createAccount() + let user = Test.createAccount() + + // Mint MOET to LP to create liquidity for borrowing + log("Setting up liquidity provider with MOET\n") + let liquidityAmount = 800.0 + setupMoetVault(lpUser, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: lpUser.address, amount: liquidityAmount, beFailed: false) + + // LP deposits MOET to create liquidity for borrowing + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: lpUser, amount: liquidityAmount, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) + + //////////// Position creation /////////////////// + log("Create 5 Positions with Different Collateral Types\n") + + // Define positions with different collateral types + // Token holder balances and prices: + // - flowHolder: 1921 FLOW x 1 = 1921 MOET + // - usdfHolder: 25000 USDF x 1 = 25000 MOET + // - usdcHolder: 97 USDC x 1 = 97 MOET + // - wethHolder: 0.07032 WETH x 3500 = 246.12 MOET + // - wbtcHolder: 0.0005 WBTC x 50000 = 25 MOET + // + // health = col * PRICE * CF / debt + let flowCol = 500.0; let flowDebt = 100.0 // health = 500.0 * PRICE_FLOW * CF_FLOW / 100.0 = 4.000 + let usdfCol = 1500.0; let usdfDebt = 150.0 // health = 1500.0 * PRICE_USDF * CF_USDF / 150.0 = 8.500 + let usdcCol = 10.0; let usdcDebt = 5.0 // health = 10.0 * PRICE_USDC * CF_USDC / 5.0 = 1.700 + let wethCol = 0.05; let wethDebt = 50.0 // health = 0.05 * PRICE_WETH * CF_WETH / 50.0 = 2.625 + let wbtcCol = 0.0004; let wbtcDebt = 8.0 // health = 0.0004 * PRICE_WBTC * CF_WBTC / 8.0 = 1.875 + + let positions = [ + {"type": MAINNET_FLOW_TOKEN_ID, "amount": flowCol, "storagePath": FLOW_VAULT_STORAGE_PATH, "name": "FLOW", "holder": MAINNET_FLOW_HOLDER}, + {"type": MAINNET_USDF_TOKEN_ID, "amount": usdfCol, "storagePath": MAINNET_USDF_STORAGE_PATH, "name": "USDF", "holder": MAINNET_USDF_HOLDER}, + {"type": MAINNET_USDC_TOKEN_ID, "amount": usdcCol, "storagePath": MAINNET_USDC_STORAGE_PATH, "name": "USDC", "holder": MAINNET_USDC_HOLDER}, + {"type": MAINNET_WETH_TOKEN_ID, "amount": wethCol, "storagePath": MAINNET_WETH_STORAGE_PATH, "name": "WETH", "holder": MAINNET_WETH_HOLDER}, + {"type": MAINNET_WBTC_TOKEN_ID, "amount": wbtcCol, "storagePath": MAINNET_WBTC_STORAGE_PATH, "name": "WBTC", "holder": MAINNET_WBTC_HOLDER} + ] + + let debts = [flowDebt, usdfDebt, usdcDebt, wethDebt, wbtcDebt] + + var userPids: [UInt64] = [] + + for i, position in positions { + let collateralType = position["type"]! as! String + let collateralName = position["name"]! as! String + let collateralAmount = position["amount"]! as! UFix64 + let storagePath = position["storagePath"]! as! StoragePath + let holder = position["holder"]! as! Test.TestAccount + + // Transfer tokens from holder to user + transferTokensWithSetup(tokenIdentifier: collateralType, from: holder, to: user, amount: collateralAmount) + + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: collateralAmount, vaultStoragePath: storagePath, pushToDrawDownSink: false) + userPids.append(getLastPositionId()) + + let price = getOraclePrice(tokenIdentifier: collateralType) + let value = collateralAmount * price + log(" Position \(userPids[i]): \(collateralAmount) \(collateralName) collateral (\(value) value)") + } + + //////////// Borrowing from each position /////////////////// + + log("Borrowing different amounts from each position\n") + + var healths: [UFix128] = [] + for i, debt in debts { + let pid = userPids[i] + borrowFromPosition(signer: user, positionId: pid, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: debt, beFailed: false) + + // Get health factor + let health = getPositionHealth(pid: pid, beFailed: false) + healths.append(health) + + log(" Position \(pid): Borrowed \(debt) - Health = \(health)") + } + + //////////// Test isolation: borrow more from position 2, verify others unchanged /////////////////// + + // userPids[1] is the second user position (USDF collateral) + let isolationTestPid = userPids[1] + let additionalDebt = 100.0 + + log("Testing isolation by borrowing more from Position \(isolationTestPid)\n") + + log("\n Action: Borrow 100 more MOET from Position \(isolationTestPid)\n") + borrowFromPosition(signer: user, positionId: isolationTestPid, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: additionalDebt, beFailed: false) + + // Get health of all positions after + var healthsAfterBorrow: [UFix128] = [] + for m in InclusiveRange(0, 4) { + let h = getPositionHealth(pid: userPids[m], beFailed: false) + healthsAfterBorrow.append(h) + } + + // Verify isolation: only position 2 (index 1) should change + Test.assert(healthsAfterBorrow[0] == healths[0], message: "Position 1 should be unchanged") + Test.assert(healthsAfterBorrow[1] < healths[1], message: "Position 2 should decrease") + Test.assert(healthsAfterBorrow[2] == healths[2], message: "Position 3 should be unchanged") + Test.assert(healthsAfterBorrow[3] == healths[3], message: "Position 4 should be unchanged") + Test.assert(healthsAfterBorrow[4] == healths[4], message: "Position 5 should be unchanged") +} + +// ============================================================================= +// Position Interactions Through Shared Liquidity Pool +// +// Validates cross-position effects mediated by a shared FLOW supply. Position A +// and B compete for the same limited liquidity; a repayment by one restores it +// for the other. A price crash on A's collateral leaves B's health unaffected. +// +// Pool liquidity: 400 MOET LP deposit +// +// Position A: 90 USDC @ 1.00 MOET (CF=0.85), borrow 60 MOET +// health = 90*1.0*0.85 / 60 = 76.5/60 = 1.275 +// pool remaining = 400 - 60 = 340 MOET +// +// Position B: 500 USDF @ 1.00 MOET (CF=0.85), borrow 340 MOET (drains pool) +// health = 500*1.0*0.85 / 340 = 425/340 = 1.250 +// pool remaining = 0 → Position B borrow of 1 MOET fails +// +// Position A repays 40 MOET: +// debt = 60 - 40 = 20 → health = 76.5/20 = 3.825 +// pool remaining = 40 MOET +// +// USDC price crash 1.00 MOET → 0.50 MOET (Position A's collateral only): +// Position A health = 90*0.50*0.85 / 20 = 38.25/20 = 1.913 (still healthy) +// Position B health: unchanged (USDF collateral unaffected) +// +// Position B borrows 30 MOET from restored pool: +// health = 500*1.0*0.85 / (340 + 30) = 425/370 = 1.149 +// ============================================================================= +access(all) fun testPositionInteractionsSharedLiquidity() { + safeReset() + + log("Testing Position Interactions Through Shared Liquidity Pools\n") + + // Create liquidity provider to deposit FLOW (the shared liquidity pool) + let lpUser = Test.createAccount() + let user = Test.createAccount() + + log("Setting up shared liquidity pool with limited capacity\n") + let liquidityAmount = 400.0 + setupMoetVault(lpUser, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: lpUser.address, amount: liquidityAmount, beFailed: false) + + // LP deposits MOET - this creates the shared liquidity pool + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: lpUser, amount: liquidityAmount, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) + log(" Liquidity Provider deposited: \(liquidityAmount) MOET\n") + + //////////// Create Position A with USDC collateral /////////////////// + + let userACollateral = 90.0 // 90 USDC + log("Creating Position A with \(userACollateral) USDC collateral\n") + transferTokensWithSetup(tokenIdentifier: MAINNET_USDC_TOKEN_ID, from: MAINNET_USDC_HOLDER, to: user, amount: userACollateral) + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: userACollateral, vaultStoragePath: MAINNET_USDC_STORAGE_PATH, pushToDrawDownSink: false) + let positionA_id = getLastPositionId() + + //////////// Create Position B with USDF collateral /////////////////// + + let userBCollateral = 500.0 // 500 USDF + log("Creating Position B with \(userBCollateral) USDF collateral\n") + transferTokensWithSetup(tokenIdentifier: MAINNET_USDF_TOKEN_ID, from: MAINNET_USDF_HOLDER, to: user, amount: userBCollateral) + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: userBCollateral, vaultStoragePath: MAINNET_USDF_STORAGE_PATH, pushToDrawDownSink: false) + let positionB_id = getLastPositionId() + + //////////// 1. Position A borrows heavily, affecting available liquidity /////////////////// + + log("Position A borrows heavily from shared pool\n") + // Formula: Effective Collateral = (collateralAmount * price) * collateralFactor = (90 × 1.0) × 0.85 = 76.50 + // Max Borrow = 76.50 / 1.1 (minHealth) = 69.55 MOET + // Health after borrow = 76.50 / 60 = 1.275 + let positionA_borrow1 = 60.0 // Borrow 60 MOET (within max 69.55) + borrowFromPosition(signer: user, positionId: positionA_id, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: positionA_borrow1, beFailed: false) + + let healthA_after1 = getPositionHealth(pid: positionA_id, beFailed: false) + log(" Position A borrowed \(positionA_borrow1) MOET - Health: \(healthA_after1)\n") + + // Check remaining liquidity in pool: liquidityAmount - positionA_borrow1 = 400.0 - 60.0 = 340.0 MOET + log(" Remaining liquidity in pool: 340.0 MOET\n") + + //////////// 2. Position B borrows successfully from shared pool /////////////////// + log("Position B borrows from shared pool\n") + + // Formula: Effective Collateral = (collateralAmount * price) * collateralFactor = (500 × 1.0) × 0.85 = 425.00 + // Max Borrow = 425.00 / 1.1 (minHealth) = 386.36 MOET + let positionB_borrow1 = 340.0 // Borrow 340 MOET (within max 386.36 borrow and 340 remaining liquidity) + log(" Attempting to borrow \(positionB_borrow1) MOET...") + borrowFromPosition(signer: user, positionId: positionB_id, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: positionB_borrow1, beFailed: false) + log(" Success - Position B borrowed \(positionB_borrow1) MOET") + let healthB_after1 = getPositionHealth(pid: positionB_id, beFailed: false) + log(" Position B Health: \(healthB_after1)\n") + log(" Remaining liquidity in pool: 0.0 MOET\n") + + //////////// 3. Position B tries to exceed max borrowing capacity - expects failure /////////////////// + log("Position B tries to borrow beyond its capacity - EXPECTS FAILURE\n") + + // Position B can't borrow more because remaining liquidity is 0 + let positionB_borrow2_attempt = 1.0 + log(" Attempting to borrow \(positionB_borrow2_attempt) MOET...") + borrowFromPosition(signer: user, positionId: positionB_id, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: positionB_borrow2_attempt, beFailed: true) + log(" Failed as expected - remaining liquidity is 0\n") + + let healthB_after2 = getPositionHealth(pid: positionB_id, beFailed: false) + + //////////// 4. Position A repayment increases available liquidity /////////////////// + log("Position A repays debt, freeing liquidity back to pool\n") + + // Position A repays substantial debt by depositing borrowed MOET back + let repayAmount = 40.0 + + // Deposit MOET back to position (repays debt using previously borrowed funds) + depositToPosition(signer: user, positionID: positionA_id, amount: repayAmount, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, pushToDrawDownSink: false) + + let healthA_after2 = getPositionHealth(pid: positionA_id, beFailed: false) + log(" Position A repaid \(repayAmount) MOET - Health: \(healthA_after2)\n") + log(" Remaining liquidity in pool after repayment: \(repayAmount) MOET\n") + + //////////// Verify cross-position effects /////////////////// + + Test.assert(healthA_after2 > healthA_after1, message: "Position A health should improve after repayment") + Test.assert(healthB_after2 == healthB_after1, message: "Position B health should be unchanged - second borrow attempt failed") + + + //////////// 5. Test Position A health change affects Position B's borrowing capacity /////////////////// + log("Testing how Position A's health deterioration affects Position B\n") + + let healthB_before_priceChange = getPositionHealth(pid: positionB_id, beFailed: false) + log(" Position B health: \(healthB_before_priceChange)") + + // Crash USDC price (Position A's collateral) −50% + let usdcCrashPrice = 0.5 // PRICE_USDC * 0.50 + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDC_TOKEN_ID, price: usdcCrashPrice) + + let healthA_after_crash = getPositionHealth(pid: positionA_id, beFailed: false) + log(" Position A health after price crash: \(healthA_after_crash)\n") + + // Position A's effective collateral is now: (90 * 0.5) * 0.85 = 38.25 + // Position A's debt is: 60 - 40 = 20 FLOW + // Position A's health is: 38.25 / 20 = 1.9125 + Test.assert(healthA_after_crash < healthA_after2, message: "Position A health should decrease after collateral price crash") + + // Position B's health should be UNCHANGED (different collateral type) + let healthB_after_priceChange = getPositionHealth(pid: positionB_id, beFailed: false) + log(" Position B health after Position A's price crash: \(healthB_after_priceChange)\n") + Test.assert(healthB_after_priceChange == healthB_before_priceChange, message: "Position B health unaffected by Position A's collateral price change") + + // Position B can still borrow from the shared pool (liquidity is independent of Position A's health) + // Position B has: 425 effective collateral, 340 borrowed, can borrow up to 46.36 more + let positionB_borrow3 = 30.0 // Well within remaining capacity (40 MOET available, 46.36 max allowed) + log(" Position B attempts to borrow \(positionB_borrow3) MOET after Position A's health deterioration...") + borrowFromPosition(signer: user, positionId: positionB_id, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: positionB_borrow3, beFailed: false) + log(" Success - Position B can still borrow despite Position A's poor health\n") + + let healthB_final = getPositionHealth(pid: positionB_id, beFailed: false) + log(" Position B final health: \(healthB_final)\n") + Test.assert(healthB_final < healthB_after_priceChange, message: "Position B health decreases from its own borrowing, not Position A's health") + +} + +// ============================================================================= +// Batch Liquidations — 2 Full + 2 Partial in One Transaction +// +// Validates that multiple unhealthy positions can be liquidated atomically in a +// single transaction via the batch DEX helper. Full liquidations bring positions +// above health 1.0; partial liquidations improve health without fully recovering. +// +// Pool liquidity: 600 MOET LP deposit +// +// Positions (all borrow MOET as debt): +// pid 0: 500 USDF @ 1.00 MOET (CF=0.85), borrow 200 → health = 500*1.0*0.85/200 = 2.125 +// pid 1: 0.06 WETH @ 3500 MOET (CF=0.75), borrow 90 → health = 0.06*3500*0.75/90 = 1.750 +// pid 2: 80 USDC @ 1.00 MOET (CF=0.85), borrow 40 → health = 80*1.0*0.85/40 = 1.700 +// pid 3: 0.0004 WBTC @ 50000 MOET (CF=0.75), borrow 10 → health = 0.0004*50000*0.75/10 = 1.500 +// pid 4: 200 FLOW @ 1.00 MOET (CF=0.80), borrow 80 → health = 200*1.0*0.80/80 = 2.000 +// +// Price crash: +// USDF: 1.00 → 0.30 (-70%) | WETH: 3500 → 1050 (-70%) +// USDC: 1.00 → 0.50 (-50%) | WBTC: 50000 → 25000 (-50%) | FLOW: unchanged +// +// Health after crash: +// pid 0 (USDF): 500*0.30*0.85/200 = 127.5/200 = 0.638 (unhealthy) +// pid 1 (WETH): 0.06*1050*0.75/90 = 47.25/90 = 0.525 (unhealthy) +// pid 2 (USDC): 80*0.50*0.85/40 = 34/40 = 0.850 (unhealthy) +// pid 3 (WBTC): 0.0004*25000*0.75/10 = 7.5/10 = 0.750 (unhealthy) +// pid 4 (FLOW): 200*1.00*0.80/80 = 160/80 = 2.000 (healthy, not liquidated) +// +// Batch liquidation (target health 1.05, post ≈1.03 for full, <1.0 for partial): +// pid 1 FULL: seize 0.035 WETH, repay 71 FLOW +// post = (0.06-0.035)*1050*0.75 / (90-71) = 19.6875/19 ≈ 1.036 +// DEX: 0.035 < 71/1050 = 0.0676 +// pid 0 FULL: seize 147 USDF, repay 113 FLOW +// post = (500-147)*0.30*0.85 / (200-113) = 90.015/87 ≈ 1.034 +// DEX: 147 < 113/0.30 = 376.7 +// pid 3 PARTIAL: seize 0.00011 WBTC, repay 4 FLOW +// post = (0.0004-0.00011)*25000*0.75 / (10-4) = 5.4375/6 ≈ 0.906 (still unhealthy) +// DEX: 0.00011 < 4/25000 = 0.00016 +// pid 2 PARTIAL: seize 17 USDC, repay 12 FLOW +// post = (80-17)*0.50*0.85 / (40-12) = 26.775/28 ≈ 0.956 (still unhealthy) +// DEX: 17 < 12/0.50 = 24.0 +// ============================================================================= +access(all) fun testBatchLiquidations() { + safeReset() + + log("Testing Batch Liquidations of Multiple Positions\n") + + let lpUser = Test.createAccount() + let user = Test.createAccount() + + // Collateral deposits and target debts (health = col * PRICE * CF / debt): + let usdfCol = 500.0; let usdfDebt = 200.0 // health = 500.0 * PRICE_USDF * CF_USDF / 200.0 = 2.125 + let wethCol = 0.06; let wethDebt = 90.0 // health = 0.06 * PRICE_WETH * CF_WETH / 90.0 = 1.750 + let usdcCol = 80.0; let usdcDebt = 40.0 // health = 80.0 * PRICE_USDC * CF_USDC / 40.0 = 1.700 + let wbtcCol = 0.0004; let wbtcDebt = 10.0 // health = 0.0004 * PRICE_WBTC * CF_WBTC / 10.0 = 1.500 + let flowCol = 200.0; let flowDebt = 80.0 // health = 200.0 * PRICE_FLOW * CF_FLOW / 80.0 = 2.000 + + // Crashed prices (−70% for USDF/WETH, −50% for USDC/WBTC; FLOW unchanged) + let usdfCrashPrice = 0.3 // PRICE_USDF * 0.30 + let wethCrashPrice = 1050.0 // PRICE_WETH * 0.30 + let usdcCrashPrice = 0.5 // PRICE_USDC * 0.50 + let wbtcCrashPrice = 25000.0 // PRICE_WBTC * 0.50 + // DEX priceRatio == crashed oracle price (required to pass deviation check) + + // Seize / repay per position (postHealth = (col*CF - seize*P*CF) / (debt - repay)) + let usdfSeize = 147.0; let usdfRepay = 113.0 // postHealth ≈ 1.034 (full) + let wethSeize = 0.035; let wethRepay = 71.0 // postHealth ≈ 1.036 (full) + let usdcSeize = 17.0; let usdcRepay = 12.0 // postHealth ≈ 0.956 (partial) + let wbtcSeize = 0.00011; let wbtcRepay = 4.0 // postHealth ≈ 0.906 (partial) + + // LP deposits lpLiquidity MOET to provide borrowing liquidity + // (total borrows = usdfDebt+wethDebt+usdcDebt+wbtcDebt+flowDebt = 420 MOET < lpLiquidity) + let lpLiquidity = 600.0 + setupMoetVault(lpUser, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: lpUser.address, amount: lpLiquidity, beFailed: false) + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: lpUser, amount: lpLiquidity, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) + + // 5 positions with distinct collateral types: + // + // pid | Collateral| Amount | Borrow | Crash price | Health after | Action + // ----|-----------|-------------|----------|-------------|--------------|-------- + // 1 | USDF | 500 USDF | 200 MOET | 0.30 (-70%)| 0.638 | FULL liquidation + // 2 | WETH | 0.06 WETH | 90 MOET | 1050 (-70%)| 0.525 | FULL liquidation + // 3 | USDC | 80 USDC | 40 MOET | 0.50 (-50%)| 0.850 | PARTIAL liquidation + // 4 | WBTC | 0.0004 WBTC | 10 MOET | 25000(-50%)| 0.750 | PARTIAL liquidation + // 5 | FLOW | 200 FLOW | 80 MOET | 1.00 (0%) | 2.000 | NOT liquidated + // + log("Creating 5 positions with different collateral types\n") + + let positions = [ + {"type": MAINNET_USDF_TOKEN_ID, "amount": usdfCol, "storagePath": MAINNET_USDF_STORAGE_PATH, "name": "USDF", "holder": MAINNET_USDF_HOLDER, "borrow": usdfDebt}, + {"type": MAINNET_WETH_TOKEN_ID, "amount": wethCol, "storagePath": MAINNET_WETH_STORAGE_PATH, "name": "WETH", "holder": MAINNET_WETH_HOLDER, "borrow": wethDebt}, + {"type": MAINNET_USDC_TOKEN_ID, "amount": usdcCol, "storagePath": MAINNET_USDC_STORAGE_PATH, "name": "USDC", "holder": MAINNET_USDC_HOLDER, "borrow": usdcDebt}, + {"type": MAINNET_WBTC_TOKEN_ID, "amount": wbtcCol, "storagePath": MAINNET_WBTC_STORAGE_PATH, "name": "WBTC", "holder": MAINNET_WBTC_HOLDER, "borrow": wbtcDebt}, + {"type": MAINNET_FLOW_TOKEN_ID, "amount": flowCol, "storagePath": FLOW_VAULT_STORAGE_PATH, "name": "FLOW", "holder": MAINNET_FLOW_HOLDER, "borrow": flowDebt} + ] + + var userPids: [UInt64] = [] + + for i, position in positions { + let collateralType = position["type"]! as! String + let collateralName = position["name"]! as! String + let collateralAmount = position["amount"]! as! UFix64 + let storagePath = position["storagePath"]! as! StoragePath + let holder = position["holder"]! as! Test.TestAccount + + transferTokensWithSetup(tokenIdentifier: collateralType, from: holder, to: user, amount: collateralAmount) + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: collateralAmount, vaultStoragePath: storagePath, pushToDrawDownSink: false) + userPids.append(getLastPositionId()) + } + + log("Borrowing MOET from each position\n") + var healths: [UFix128] = [] + for i, position in positions { + let pid = userPids[i] + let borrowAmount = position["borrow"]! as! UFix64 + let collateralName = position["name"]! as! String + + borrowFromPosition(signer: user, positionId: pid, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: borrowAmount, beFailed: false) + + let health = getPositionHealth(pid: pid, beFailed: false) + healths.append(health) + log(" Position \(pid) (\(collateralName)): Borrowed \(borrowAmount) MOET - Health: \(health)") + } + + // Crash collateral prices. FLOW stays at 1.0 so userPids[4] stays healthy. + log("\nCrashing collateral prices to trigger liquidations\n") + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDF_TOKEN_ID, price: usdfCrashPrice) // -70% + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_WETH_TOKEN_ID, price: wethCrashPrice) // -70% + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDC_TOKEN_ID, price: usdcCrashPrice) // -50% + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_WBTC_TOKEN_ID, price: wbtcCrashPrice) // -50% + + log("\nPosition health after price crash:\n") + for i in InclusiveRange(0, 4) { + let pid = userPids[i] + let health = getPositionHealth(pid: pid, beFailed: false) + let collateralName = positions[i]["name"]! as! String + healths[i] = health + log(" Position \(pid) (\(collateralName)): Health = \(health)") + } + + // Verify expected health states + Test.assert(healths[0] < 1.0, message: "USDF position should be unhealthy") + Test.assert(healths[1] < 1.0, message: "WETH position should be unhealthy") + Test.assert(healths[2] < 1.0, message: "USDC position should be unhealthy") + Test.assert(healths[3] < 1.0, message: "WBTC position should be unhealthy") + Test.assert(healths[4] > 1.0, message: "FLOW position should remain healthy") + + // Verify worst-health ordering: WETH < USDF < WBTC < USDC + Test.assert(healths[1] < healths[0], message: "WETH should be worse than USDF") + Test.assert(healths[0] < healths[3], message: "USDF should be worse than WBTC") + Test.assert(healths[3] < healths[2], message: "WBTC should be worse than USDC") + + // Setup protocol account MOET vault as the DEX output source. + // priceRatio = Pc_crashed / Pd = post-crash collateral price / MOET price. + // This must match the oracle prices exactly to pass the DEX/oracle deviation check. + setupMoetVault(MAINNET_PROTOCOL_ACCOUNT, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: MAINNET_PROTOCOL_ACCOUNT.address, amount: 300.0, beFailed: false) + + log("\nSetting up DEX swappers (priceRatio = post-crash Pc / Pd)\n") + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_USDF_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: usdfCrashPrice // usdfCrashPrice USDF / 1.00 MOET + ) + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_WETH_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: wethCrashPrice // wethCrashPrice WETH / 1.00 MOET + ) + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_USDC_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: usdcCrashPrice // usdcCrashPrice USDC / 1.00 MOET + ) + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_WBTC_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: wbtcCrashPrice // wbtcCrashPrice WBTC / 1.00 MOET + ) + + // Liquidator setup: mint MOET for debt repayment (total needed: 71+113+4+12 = 200 MOET) + // and 1 unit of each collateral token to initialize vault storage paths. + // + // Repay amounts derived from: repay = debt - (collat - seize) * CF * P_crashed / H_target + // let chose target health factor H_target ≈ 1.034 (randomly chosen ~1.03-1.04, close to 1.05 target) + // + // WETH=71: debt=90, (0.06-0.035)*0.75*1050 = 19.6875, H≈1.034 → 90 - 19.6875/1.034 ≈ 71 + // USDF=113: debt=200, (500-147)*0.85*0.3 = 90.015, H≈1.034 → 200 - 90.015/1.034 ≈ 113 + // WBTC=4: partial; (0.0004-0.00011)*0.75*25000 = 5.4375 → repay=4 → postHealth=5.4375/6≈0.906 + // USDC=12: partial; (80-17)*0.85*0.5 = 26.775 → repay=12 → postHealth=26.775/28≈0.956 + log("\nSetting up liquidator account\n") + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: liquidator.address, amount: 250.0, beFailed: false) + transferTokensWithSetup(tokenIdentifier: MAINNET_USDF_TOKEN_ID, from: MAINNET_USDF_HOLDER, to: liquidator, amount: 1.0) + transferTokensWithSetup(tokenIdentifier: MAINNET_WETH_TOKEN_ID, from: MAINNET_WETH_HOLDER, to: liquidator, amount: 0.001) + transferTokensWithSetup(tokenIdentifier: MAINNET_USDC_TOKEN_ID, from: MAINNET_USDC_HOLDER, to: liquidator, amount: 1.0) + transferTokensWithSetup(tokenIdentifier: MAINNET_WBTC_TOKEN_ID, from: MAINNET_WBTC_HOLDER, to: liquidator, amount: 0.00001) + + // seize/repay values satisfy three constraints: + // 1. seize < quote.inAmount (offer beats DEX price) + // 2. postHealth <= 1.05 (liquidationTargetHF default) + // 3. postHealth > pre-liq health (position improves) + // + // postHealth = (collateral*CF - seize*price*CF) / (debt - repay) + // DEX check: seize < repay / priceRatio where priceRatio = collateralPrice / debtPrice + // + // Full liquidations — bring health up to ~1.03-1.04 (as close to 1.05 target as possible): + // pid=WETH: repay 71 MOET, seize 0.035 WETH + // postHealth = (47.25 - 0.035*787.5) / (90 - 71) = 19.6875/19 ≈ 1.036 + // DEX check: 0.035 < 71/1050 = 0.0676 + // pid=USDF: repay 113 MOET, seize 147 USDF + // postHealth = (127.5 - 147*0.255) / (200 - 113) = 90.015/87 ≈ 1.034 + // DEX check: 147 < 113/0.3 = 376.7 + // + // Partial liquidations — improve health without reaching 1.05: + // pid=WBTC: repay 4 MOET, seize 0.00011 WBTC + // postHealth = (7.5 - 0.00011*18750) / (10 - 4) = 5.4375/6 ≈ 0.906 + // DEX check: 0.00011 < 4/25000 = 0.00016 + // pid=USDC: repay 12 MOET, seize 17 USDC + // postHealth = (34 - 17*0.425) / (40 - 12) = 26.775/28 ≈ 0.956 + // DEX check: 17 < 12/0.5 = 24 + + log("\nExecuting batch liquidation of 4 positions (2 full, 2 partial) in SINGLE transaction...\n") + let batchPids = [userPids[0], userPids[1], userPids[2], userPids[3] ] + let batchSeizeTypes = [MAINNET_USDF_TOKEN_ID, MAINNET_WETH_TOKEN_ID, MAINNET_USDC_TOKEN_ID, MAINNET_WBTC_TOKEN_ID] + let batchSeizeAmounts = [usdfSeize, wethSeize, usdcSeize, wbtcSeize] + let batchRepayAmounts = [usdfRepay, wethRepay, usdcRepay, wbtcRepay] + + batchManualLiquidation( + pids: batchPids, + debtVaultIdentifier: MAINNET_MOET_TOKEN_ID, + seizeVaultIdentifiers: batchSeizeTypes, + seizeAmounts: batchSeizeAmounts, + repayAmounts: batchRepayAmounts, + signer: liquidator + ) + + log("\nVerifying results after batch liquidation:\n") + + // Full liquidations (WETH, USDF): health must cross above 1.0 (healthy again) + let healthAfterWeth = getPositionHealth(pid: userPids[1], beFailed: false) + let healthAfterUsdf = getPositionHealth(pid: userPids[0], beFailed: false) + log(" WETH (FULL): \(healths[1]) -> \(healthAfterWeth)") + log(" USDF (FULL): \(healths[0]) -> \(healthAfterUsdf)") + Test.assert(healthAfterWeth > 1.0, message: "WETH position should be healthy after full liquidation") + Test.assert(healthAfterUsdf > 1.0, message: "USDF position should be healthy after full liquidation") + + // Partial liquidations (WBTC, USDC): health must improve but stays below 1.0 + let healthAfterWbtc = getPositionHealth(pid: userPids[3], beFailed: false) + let healthAfterUsdc = getPositionHealth(pid: userPids[2], beFailed: false) + log(" WBTC (PARTIAL): \(healths[3]) -> \(healthAfterWbtc)") + log(" USDC (PARTIAL): \(healths[2]) -> \(healthAfterUsdc)") + Test.assert(healthAfterWbtc > healths[3], message: "WBTC position health should improve after partial liquidation") + Test.assert(healthAfterUsdc > healths[2], message: "USDC position health should improve after partial liquidation") + + // FLOW position (userPids[4]): completely unaffected — health is price-independent for FLOW/FLOW + let healthAfterFlow = getPositionHealth(pid: userPids[4], beFailed: false) + log(" FLOW (NONE): \(healths[4]) -> \(healthAfterFlow)") + Test.assert(healthAfterFlow == healths[4], message: "FLOW position health should be unchanged") +} + +// ============================================================================= +// Mass Simultaneous Unhealthy Liquidations — 100-Position Stress Test +// +// System-wide stress test: 100 positions across three collateral types all crash +// 40% simultaneously, requiring a chunked batch DEX liquidation of every position. +// +// ============================================================================= +access(all) fun testMassUnhealthyLiquidations() { + safeReset() + + log("=== Stress Test: 100 Positions (USDF/USDC/WBTC) Simultaneously Unhealthy ===\n") + + let lpUser = Test.createAccount() + let user = Test.createAccount() + let liquidator = Test.createAccount() + + // ── Group index ranges ────────────────────────────────────────────────────── + // Group A — USDF: indices 0..49 (50 positions) + let usdfHighStart = 0; let usdfHighEnd = 24 // high-risk: 25 positions + let usdfModStart = 25; let usdfModEnd = 49 // moderate: 25 positions + // Group B — USDC: indices 50..94 (45 positions) + let usdcHighStart = 50; let usdcHighEnd = 72 // high-risk: 23 positions + let usdcModStart = 73; let usdcModEnd = 94 // moderate: 22 positions + // Group C — WBTC: indices 95..99 (5 positions) + let wbtcStart = 95; let wbtcEnd = 99 + + // Collateral per position (health = colPerPos * PRICE * CF / debt) + let usdfColPerPos = 10.0 // 50 × usdfColPerPos = 500 USDF transferred + let usdcColPerPos = 2.0 // 45 × usdcColPerPos = 90 USDC transferred + let wbtcColPerPos = 0.00009 // 5 × wbtcColPerPos = 0.00045 WBTC transferred + + // Borrow amounts per position + let usdfHighDebt = 7.0 // health = usdfColPerPos * PRICE_USDF * CF_USDF / usdfHighDebt = 1.214 + let usdfModDebt = 6.0 // health = usdfColPerPos * PRICE_USDF * CF_USDF / usdfModDebt = 1.417 + let usdcHighDebt = 1.4 // health = usdcColPerPos * PRICE_USDC * CF_USDC / usdcHighDebt = 1.214 + let usdcModDebt = 1.2 // health = usdcColPerPos * PRICE_USDC * CF_USDC / usdcModDebt = 1.417 + let wbtcDebt = 2.5 // health = wbtcColPerPos * PRICE_WBTC * CF_WBTC / wbtcDebt = 1.350 + + // Crashed prices (−40% across all three collateral types) + let usdfCrashPrice = 0.6 // PRICE_USDF * 0.60 + let usdcCrashPrice = 0.6 // PRICE_USDC * 0.60 + let wbtcCrashPrice = 30000.0 // PRICE_WBTC * 0.60 + + // Seize / repay per position (postHealth = (col*CF - seize*P*CF) / (debt - repay)) + let usdfHighSeize = 4.0; let usdfHighRepay = 4.0 // postHealth ≈ 1.02 + let usdcHighSeize = 0.8; let usdcHighRepay = 0.8 // postHealth ≈ 1.02 + let wbtcSeize = 0.00003; let wbtcRepay = 1.18 // postHealth ≈ 1.023 + let usdfModSeize = 4.0; let usdfModRepay = 3.0 // postHealth ≈ 1.02 + let usdcModSeize = 0.8; let usdcModRepay = 0.6 // postHealth ≈ 1.02 + + let batchChunkSize = 10 + + //////////// LP setup /////////////////// + + // LP deposits lpLiquidity MOET — covers the ~397 MOET of total borrows with headroom. + let lpLiquidity = 450.0 + log("LP depositing \(lpLiquidity) MOET to shared liquidity pool\n") + setupMoetVault(lpUser, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: lpUser.address, amount: lpLiquidity, beFailed: false) + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: lpUser, amount: lpLiquidity, vaultStoragePath: MOET.VaultStoragePath, pushToDrawDownSink: false) + + //////////// Transfer collateral to user /////////////////// + + // Group A: 50 positions × usdfColPerPos = 500 USDF + // Group B: 45 positions × usdcColPerPos = 90 USDC + // Group C: 5 positions × wbtcColPerPos = 0.00045 WBTC + log("Transferring collateral: 500 USDF + 90 USDC + 0.00045 WBTC\n") + transferTokensWithSetup(tokenIdentifier: MAINNET_USDF_TOKEN_ID, from: MAINNET_USDF_HOLDER, to: user, amount: 500.0) + transferTokensWithSetup(tokenIdentifier: MAINNET_USDC_TOKEN_ID, from: MAINNET_USDC_HOLDER, to: user, amount: 90.0) + transferTokensWithSetup(tokenIdentifier: MAINNET_WBTC_TOKEN_ID, from: MAINNET_WBTC_HOLDER, to: user, amount: 0.00045) + + //////////// Create 100 positions /////////////////// + + var allPids: [UInt64] = [] + + // Group A — 50 USDF positions + log("Creating 50 USDF positions (\(usdfColPerPos) USDF each)...\n") + for i in InclusiveRange(usdfHighStart, usdfModEnd) { + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: usdfColPerPos, vaultStoragePath: MAINNET_USDF_STORAGE_PATH, pushToDrawDownSink: false) + allPids.append(getLastPositionId()) + } + + // Group B — 45 USDC positions + log("Creating 45 USDC positions (\(usdcColPerPos) USDC each)...\n") + for i in InclusiveRange(usdcHighStart, usdcModEnd) { + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: usdcColPerPos, vaultStoragePath: MAINNET_USDC_STORAGE_PATH, pushToDrawDownSink: false) + allPids.append(getLastPositionId()) + } + + // Group C — 5 WBTC positions + log("Creating 5 WBTC positions (\(wbtcColPerPos) WBTC each)...\n") + for i in InclusiveRange(wbtcStart, wbtcEnd) { + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: wbtcColPerPos, vaultStoragePath: MAINNET_WBTC_STORAGE_PATH, pushToDrawDownSink: false) + allPids.append(getLastPositionId()) + } + + Test.assert(allPids.length == 100, message: "Expected 100 positions, got \(allPids.length)") + + //////////// Borrow FLOW from each position /////////////////// + + // Group A — USDF positions: + // high-risk [usdfHighStart..usdfHighEnd]: borrow usdfHighDebt → health = 1.214 + // moderate [usdfModStart..usdfModEnd]: borrow usdfModDebt → health = 1.417 + log("Borrowing MOET from 50 USDF positions...\n") + for i in InclusiveRange(usdfHighStart, usdfHighEnd) { + borrowFromPosition(signer: user, positionId: allPids[i], tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: usdfHighDebt, beFailed: false) + } + for i in InclusiveRange(usdfModStart, usdfModEnd) { + borrowFromPosition(signer: user, positionId: allPids[i], tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: usdfModDebt, beFailed: false) + } + + // Group B — USDC positions: + // high-risk [usdcHighStart..usdcHighEnd]: borrow usdcHighDebt → health = 1.214 + // moderate [usdcModStart..usdcModEnd]: borrow usdcModDebt → health = 1.417 + log("Borrowing MOET from 45 USDC positions...\n") + for i in InclusiveRange(usdcHighStart, usdcHighEnd) { + borrowFromPosition(signer: user, positionId: allPids[i], tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: usdcHighDebt, beFailed: false) + } + for i in InclusiveRange(usdcModStart, usdcModEnd) { + borrowFromPosition(signer: user, positionId: allPids[i], tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: usdcModDebt, beFailed: false) + } + + // Group C — WBTC positions: + // uniform [wbtcStart..wbtcEnd]: borrow wbtcDebt → health = 1.350 + log("Borrowing MOET from 5 WBTC positions...\n") + for i in InclusiveRange(wbtcStart, wbtcEnd) { + borrowFromPosition(signer: user, positionId: allPids[i], tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: wbtcDebt, beFailed: false) + } + + // Confirm all 100 positions are healthy before the crash + for i in InclusiveRange(0, 99) { + let health = getPositionHealth(pid: allPids[i], beFailed: false) + Test.assert(health > 1.0, message: "Position \(allPids[i]) must be healthy before crash (got \(health))") + } + + //////////// Simulate 40% price crash across all three collateral types /////////////////// + + // USDF/USDC: PRICE_USDF → usdfCrashPrice (-40%) | WBTC: PRICE_WBTC → wbtcCrashPrice (-40%) + // + // Health after crash: + // USDF high: (usdfColPerPos×usdfCrashPrice×CF_USDF)/usdfHighDebt = 0.729 + // USDF mod: (usdfColPerPos×usdfCrashPrice×CF_USDF)/usdfModDebt = 0.850 + // USDC high: (usdcColPerPos×usdcCrashPrice×CF_USDC)/usdcHighDebt = 0.729 + // USDC mod: (usdcColPerPos×usdcCrashPrice×CF_USDC)/usdcModDebt = 0.850 + // WBTC: (wbtcColPerPos×wbtcCrashPrice×CF_WBTC)/wbtcDebt = 0.810 + log("All three collateral types crash 40% simultaneously\n") + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDF_TOKEN_ID, price: usdfCrashPrice) + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDC_TOKEN_ID, price: usdcCrashPrice) + setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_WBTC_TOKEN_ID, price: wbtcCrashPrice) + + // Capture post-crash health by token type and verify all positions are unhealthy + var usdfHealths: [UFix128] = [] + var usdcHealths: [UFix128] = [] + var wbtcHealths: [UFix128] = [] + + for i in InclusiveRange(0, 49) { + let h = getPositionHealth(pid: allPids[i], beFailed: false) + usdfHealths.append(h) + Test.assert(h < 1.0, message: "USDF pos \(allPids[i]) must be unhealthy (got \(h))") + } + for i in InclusiveRange(50, 94) { + let h = getPositionHealth(pid: allPids[i], beFailed: false) + usdcHealths.append(h) + Test.assert(h < 1.0, message: "USDC pos \(allPids[i]) must be unhealthy (got \(h))") + } + for i in InclusiveRange(95, 99) { + let h = getPositionHealth(pid: allPids[i], beFailed: false) + wbtcHealths.append(h) + Test.assert(h < 1.0, message: "WBTC pos \(allPids[i]) must be unhealthy (got \(h))") + } + + // Verify risk ordering: high-risk (more debt) → worse health than moderate + // usdfHealths[0]=high-risk, usdfHealths[25]=first moderate; usdcHealths[0]=high-risk, usdcHealths[23]=first moderate + Test.assert(usdfHealths[0] < usdfHealths[25], message: "USDF high-risk must be worse than moderate") + Test.assert(usdcHealths[0] < usdcHealths[23], message: "USDC high-risk must be worse than moderate") + + log(" USDF high: \(usdfHealths[0]) (≈0.729) mod: \(usdfHealths[25]) (≈0.850)\n") + log(" USDC high: \(usdcHealths[0]) (≈0.729) mod: \(usdcHealths[23]) (≈0.850)\n") + log(" WBTC: \(wbtcHealths[0]) (≈0.810)\n") + log(" All 100 positions confirmed unhealthy — proceeding to batch liquidation\n") + + //////////// DEX setup /////////////////// + + // Three DEX pairs (all source MOET from MAINNET_PROTOCOL_ACCOUNT's vault): + // USDF→MOET at priceRatio=usdfCrashPrice + // USDC→MOET at priceRatio=usdcCrashPrice + // WBTC→MOET at priceRatio=wbtcCrashPrice + // + // Total DEX MOET: 25×usdfHighRepay + 25×usdfModRepay + 23×usdcHighRepay + 22×usdcModRepay + 5×wbtcRepay + // = 100 + 75 + 18.4 + 13.2 + 5.90 = 212.50; mint 230 for headroom + log("Configuring DEX pairs: USDF→MOET, USDC→MOET, WBTC→MOET\n") + setupMoetVault(MAINNET_PROTOCOL_ACCOUNT, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: MAINNET_PROTOCOL_ACCOUNT.address, amount: 230.0, beFailed: false) + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_USDF_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: usdfCrashPrice + ) + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_USDC_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: usdcCrashPrice + ) + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_WBTC_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: wbtcCrashPrice + ) + + //////////// Build batch parameters (ordered worst health first) /////////////////// + // + // Seize/repay parameters (ordered worst health first): + // USDF high [usdfHighStart..usdfHighEnd]: seize usdfHighSeize, repay usdfHighRepay post=1.02 + // USDC high [usdcHighStart..usdcHighEnd]: seize usdcHighSeize, repay usdcHighRepay post=1.02 + // WBTC [wbtcStart..wbtcEnd]: seize wbtcSeize, repay wbtcRepay post=1.023 + // USDF mod [usdfModStart..usdfModEnd]: seize usdfModSeize, repay usdfModRepay post=1.02 + // USDC mod [usdcModStart..usdcModEnd]: seize usdcModSeize, repay usdcModRepay post=1.02 + var batchPids: [UInt64] = [] + var batchSeize: [String] = [] + var batchAmounts: [UFix64] = [] + var batchRepay: [UFix64] = [] + + // USDF high-risk [usdfHighStart..usdfHighEnd] + for i in InclusiveRange(usdfHighStart, usdfHighEnd) { + batchPids.append(allPids[i]) + batchSeize.append(MAINNET_USDF_TOKEN_ID) + batchAmounts.append(usdfHighSeize) + batchRepay.append(usdfHighRepay) + } + // USDC high-risk [usdcHighStart..usdcHighEnd] + for i in InclusiveRange(usdcHighStart, usdcHighEnd) { + batchPids.append(allPids[i]) + batchSeize.append(MAINNET_USDC_TOKEN_ID) + batchAmounts.append(usdcHighSeize) + batchRepay.append(usdcHighRepay) + } + // WBTC uniform [wbtcStart..wbtcEnd] + for i in InclusiveRange(wbtcStart, wbtcEnd) { + batchPids.append(allPids[i]) + batchSeize.append(MAINNET_WBTC_TOKEN_ID) + batchAmounts.append(wbtcSeize) + batchRepay.append(wbtcRepay) + } + // USDF moderate [usdfModStart..usdfModEnd] + for i in InclusiveRange(usdfModStart, usdfModEnd) { + batchPids.append(allPids[i]) + batchSeize.append(MAINNET_USDF_TOKEN_ID) + batchAmounts.append(usdfModSeize) + batchRepay.append(usdfModRepay) + } + // USDC moderate [usdcModStart..usdcModEnd] + for i in InclusiveRange(usdcModStart, usdcModEnd) { + batchPids.append(allPids[i]) + batchSeize.append(MAINNET_USDC_TOKEN_ID) + batchAmounts.append(usdcModSeize) + batchRepay.append(usdcModRepay) + } + + Test.assert(batchPids.length == 100, message: "Expected 100 batch entries, got \(batchPids.length)") + + //////////// Batch liquidation — 100 positions in chunks of 10 /////////////////// + + // Setup liquidator vaults for seized collateral tokens (required to receive seized amounts). + transferTokensWithSetup(tokenIdentifier: MAINNET_USDF_TOKEN_ID, from: MAINNET_USDF_HOLDER, to: liquidator, amount: 1.0) + transferTokensWithSetup(tokenIdentifier: MAINNET_USDC_TOKEN_ID, from: MAINNET_USDC_HOLDER, to: liquidator, amount: 1.0) + transferTokensWithSetup(tokenIdentifier: MAINNET_WBTC_TOKEN_ID, from: MAINNET_WBTC_HOLDER, to: liquidator, amount: 0.00001) + + // Split into chunks of 10 to stay within the computation limit (single tx of 100 exceeds it). + // DEX sources MOET from MAINNET_PROTOCOL_ACCOUNT's vault; liquidator receives seized collateral. + log("Liquidating all 100 positions via DEX in chunks of 10...\n") + batchLiquidateViaMockDex( + pids: batchPids, + debtVaultIdentifier: MAINNET_MOET_TOKEN_ID, + seizeVaultIdentifiers: batchSeize, + seizeAmounts: batchAmounts, + repayAmounts: batchRepay, + chunkSize: batchChunkSize, + signer: liquidator + ) + + //////////// Verification /////////////////// + + // All 100 positions must have improved and be healthy again + log("Verifying all 100 positions recovered...\n") + + // USDF [0..49] + for i in InclusiveRange(0, 49) { + let h = getPositionHealth(pid: allPids[i], beFailed: false) + Test.assert(h > usdfHealths[i], message: "USDF pos \(allPids[i]) health must improve: \(usdfHealths[i]) → \(h)") + Test.assert(h > 1.0, message: "USDF pos \(allPids[i]) must be healthy again (got \(h))") + } + // USDC [50..94] + for i in InclusiveRange(0, 44) { + let pidIdx = i + 50 + let h = getPositionHealth(pid: allPids[pidIdx], beFailed: false) + Test.assert(h > usdcHealths[i], message: "USDC pos \(allPids[pidIdx]) health must improve: \(usdcHealths[i]) → \(h)") + Test.assert(h > 1.0, message: "USDC pos \(allPids[pidIdx]) must be healthy again (got \(h))") + } + // WBTC [95..99] + for i in InclusiveRange(0, 4) { + let pidIdx = i + 95 + let h = getPositionHealth(pid: allPids[pidIdx], beFailed: false) + Test.assert(h > wbtcHealths[i], message: "WBTC pos \(allPids[pidIdx]) health must improve: \(wbtcHealths[i]) → \(h)") + Test.assert(h > 1.0, message: "WBTC pos \(allPids[pidIdx]) must be healthy again (got \(h))") + } + + // Protocol solvency: FLOW reserve must remain positive after mass liquidation + let reserveBalance = getReserveBalance(vaultIdentifier: MAINNET_MOET_TOKEN_ID) + log("Protocol MOET reserve after mass liquidation: \(reserveBalance)\n") + Test.assert(reserveBalance > 0.0, message: "Protocol must remain solvent (positive MOET reserve) after mass liquidation") +} 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 38d407a3..0b81949c 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/scripts/get_oracle_price.cdc b/cadence/tests/scripts/get_oracle_price.cdc new file mode 100644 index 00000000..acc74bac --- /dev/null +++ b/cadence/tests/scripts/get_oracle_price.cdc @@ -0,0 +1,9 @@ +import "MockOracle" + +access(all) fun main(tokenIdentifier: String): UFix64? { + let tokenType = CompositeType(tokenIdentifier) + ?? panic("Invalid token identifier: ".concat(tokenIdentifier)) + + let oracle = MockOracle.PriceOracle() + return oracle.price(ofToken: tokenType) +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 19d43bc0..1a1c2c0f 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -1,6 +1,7 @@ import Test import "FlowALPv0" import "FlowALPModels" +import "FlowALPEvents" import "MOET" /* --- Global test constants --- */ @@ -25,7 +26,7 @@ access(all) let MAX_HEALTH = 1.5 access(all) let INT_MIN_HEALTH: UFix128 = 1.1 access(all) let INT_TARGET_HEALTH: UFix128 = 1.3 access(all) let INT_MAX_HEALTH: UFix128 = 1.5 -access(all) let CEILING_HEALTH: UFix128 = UFix128.max // infinite health when debt ~ 0.0 +access(all) let CEILING_HEALTH = UFix128.max // infinite health when debt ~ 0.0 // Time constants access(all) let DAY: Fix64 = 86_400.0 @@ -38,11 +39,12 @@ access(all) let ONE_YEAR: Fix64 = 31_557_600.0 // 365.25 * 86400 access(all) let MAINNET_WETH_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_2f6f07cdcf3588944bf4c42ac74ff24bf56e7590.Vault" access(all) let MAINNET_USDF_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_2aabea2058b5ac2d339b163c6ab6f2b6d53aabed.Vault" access(all) let MAINNET_WBTC_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579.Vault" - +access(all) let MAINNET_USDC_TOKEN_ID = "A.f1ab99c82dee3526.USDCFlow.Vault" access(all) let MAINNET_MOET_TOKEN_ID = "A.6b00ff876c299c61.MOET.Vault" access(all) let MAINNET_FLOW_TOKEN_ID = "A.1654653399040a61.FlowToken.Vault" // Storage paths +access(all) let MAINNET_USDC_STORAGE_PATH = /storage/usdcFlowVault access(all) let MAINNET_USDF_STORAGE_PATH = /storage/EVMVMBridgedToken_2aabea2058b5ac2d339b163c6ab6f2b6d53aabedVault access(all) let MAINNET_WETH_STORAGE_PATH = /storage/EVMVMBridgedToken_2f6f07cdcf3588944bf4c42ac74ff24bf56e7590Vault access(all) let MAINNET_WBTC_STORAGE_PATH = /storage/EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579Vault @@ -52,6 +54,8 @@ access(all) let MAINNET_PROTOCOL_ACCOUNT_ADDRESS: Address = 0x6b00ff876c299c61 access(all) let MAINNET_USDF_HOLDER_ADDRESS: Address = 0xf18b50870aed46ad access(all) let MAINNET_WETH_HOLDER_ADDRESS: Address = 0xf62e3381a164f993 access(all) let MAINNET_WBTC_HOLDER_ADDRESS: Address = 0x47f544294e3b7656 +access(all) let MAINNET_FLOW_HOLDER_ADDRESS: Address = 0xe467b9dd11fa00df +access(all) let MAINNET_USDC_HOLDER_ADDRESS: Address = 0xec6119051f7adc31 /* --- Test execution helpers --- */ @@ -419,6 +423,23 @@ fun setMockOraclePrice(signer: Test.TestAccount, forTokenIdentifier: String, pri Test.expect(setRes, Test.beSucceeded()) } +access(all) +fun getOraclePrice(tokenIdentifier: String): UFix64 { + let result = Test.executeScript( + Test.readFile("./scripts/get_oracle_price.cdc"), + [tokenIdentifier] + ) + + if result.error != nil { + panic("Failed to get oracle price: ".concat(result.error!.message)) + } + + let price = result.returnValue! as! UFix64? + ?? panic("No price set for token: ".concat(tokenIdentifier)) + + return price +} + /// Sets a swapper for the given pair with the given price ratio. /// This overwrites any previously stored swapper for this pair, if any exists. /// This is intended to be used in tests both to set an initial DEX price for a supported token, @@ -856,6 +877,65 @@ fun transferFungibleTokens( Test.expect(res, Test.beSucceeded()) } +/// Sets up the recipient's vault (if not already present) and transfers tokens in one call. +/// Combines setupGenericVault + transferFungibleTokens for the common case of funding a fresh account. +access(all) +fun transferTokensWithSetup(tokenIdentifier: String, from: Test.TestAccount, to: Test.TestAccount, amount: UFix64) { + let res = setupGenericVault(to, vaultIdentifier: tokenIdentifier) + Test.expect(res, Test.beSucceeded()) + transferFungibleTokens(tokenIdentifier: tokenIdentifier, from: from, to: to, amount: amount) +} + +/// Batch-liquidate positions using the liquidator's own tokens as repayment (no DEX). +/// The liquidator must hold sufficient debt tokens upfront. +access(all) fun batchManualLiquidation( + pids: [UInt64], + debtVaultIdentifier: String, + seizeVaultIdentifiers: [String], + seizeAmounts: [UFix64], + repayAmounts: [UFix64], + signer: Test.TestAccount +) { + let res = _executeTransaction( + "./transactions/flow-alp/pool-management/batch_manual_liquidation.cdc", + [pids, debtVaultIdentifier, seizeVaultIdentifiers, seizeAmounts, repayAmounts], + signer + ) + Test.expect(res, Test.beSucceeded()) +} + +/// Batch-liquidate positions using MockDexSwapper as the repayment source in chunks of +/// chunkSize to stay within the computation limit. +access(all) fun batchLiquidateViaMockDex( + pids: [UInt64], + debtVaultIdentifier: String, + seizeVaultIdentifiers: [String], + seizeAmounts: [UFix64], + repayAmounts: [UFix64], + chunkSize: Int, + signer: Test.TestAccount +) { + let total = pids.length + let numChunks = (total + chunkSize - 1) / chunkSize + for i in InclusiveRange(0, numChunks - 1) { + let startIdx = i * chunkSize + var endIdx = startIdx + chunkSize + if endIdx > total { + endIdx = total + } + let res = _executeTransaction( + "./transactions/flow-alp/pool-management/batch_liquidate_via_mock_dex.cdc", + [pids.slice(from: startIdx, upTo: endIdx), + debtVaultIdentifier, + seizeVaultIdentifiers.slice(from: startIdx, upTo: endIdx), + seizeAmounts.slice(from: startIdx, upTo: endIdx), + repayAmounts.slice(from: startIdx, upTo: endIdx)], + signer + ) + Test.expect(res, Test.beSucceeded()) + } +} + access(all) fun expectEvents(eventType: Type, expectedCount: Int) { let events = Test.eventsOfType(eventType) @@ -933,3 +1013,9 @@ fun getCreditBalanceForType(details: FlowALPModels.PositionDetails, vaultType: T } return 0.0 } + +access(all) fun getLastPositionId(): UInt64 { + var openEvents = Test.eventsOfType(Type()) + let pid = (openEvents[openEvents.length - 1] as! FlowALPEvents.Opened).pid + return pid +} diff --git a/cadence/tests/transactions/flow-alp/pool-management/batch_liquidate_via_mock_dex.cdc b/cadence/tests/transactions/flow-alp/pool-management/batch_liquidate_via_mock_dex.cdc new file mode 100644 index 00000000..34c61486 --- /dev/null +++ b/cadence/tests/transactions/flow-alp/pool-management/batch_liquidate_via_mock_dex.cdc @@ -0,0 +1,102 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" +import "MetadataViews" + +import "FlowALPv0" +import "MockDexSwapper" + +/// TEST-ONLY: Batch liquidate multiple positions using the stored MockDexSwapper as the debt +/// repayment source. The swapper's vaultSource (configured via setMockDexPriceForPair) withdraws +/// the required debt tokens, so the transaction signer needs no debt tokens upfront. +/// +/// Positions are liquidated in the order provided (caller is responsible for ordering by priority). +/// +/// pids: Array of position IDs to liquidate +/// debtVaultIdentifier: e.g., Type<@FlowToken.Vault>().identifier +/// seizeVaultIdentifiers: Array of collateral vault identifiers to seize (one per position) +/// seizeAmounts: Array of collateral amounts to seize from each position +/// repayAmounts: Array of debt amounts to repay for each position (sourced from the DEX) +transaction( + pids: [UInt64], + repayVaultIdentifier: String, + seizeVaultIdentifiers: [String], + seizeAmounts: [UFix64], + repayAmounts: [UFix64] +) { + let pool: &FlowALPv0.Pool + let debtType: Type + let signerAccount: auth(BorrowValue) &Account + + prepare(signer: auth(BorrowValue) &Account) { + let protocolAddress = Type<@FlowALPv0.Pool>().address! + self.pool = getAccount(protocolAddress).capabilities.borrow<&FlowALPv0.Pool>(FlowALPv0.PoolPublicPath) + ?? panic("Could not borrow Pool at \(FlowALPv0.PoolPublicPath)") + + self.debtType = CompositeType(repayVaultIdentifier) + ?? panic("Invalid debtVaultIdentifier: \(repayVaultIdentifier)") + + self.signerAccount = signer + } + + execute { + let numPositions = pids.length + assert(seizeVaultIdentifiers.length == numPositions, message: "seizeVaultIdentifiers length mismatch") + assert(seizeAmounts.length == numPositions, message: "seizeAmounts length mismatch") + assert(repayAmounts.length == numPositions, message: "repayAmounts length mismatch") + + var totalRepaid = 0.0 + + for idx in InclusiveRange(0, numPositions - 1) { + let pid = pids[idx] + let seizeVaultIdentifier = seizeVaultIdentifiers[idx] + let seizeAmount = seizeAmounts[idx] + let repayAmount = repayAmounts[idx] + + let seizeType = CompositeType(seizeVaultIdentifier) + ?? panic("Invalid seizeVaultIdentifier: \(seizeVaultIdentifier)") + + // Retrieve the stored MockDexSwapper for this collateral → debt pair. + // The swapper's vaultSource (protocolAccount's vault) provides the debt tokens. + let swapper = MockDexSwapper.getSwapper(inType: seizeType, outType: self.debtType) + ?? panic("No MockDexSwapper configured for \(seizeVaultIdentifier) -> \(repayVaultIdentifier)") + + // Build an exact quote for the repayAmount we need from the swapper's vaultSource + let swapQuote = MockDexSwapper.BasicQuote( + inType: seizeType, + outType: self.debtType, + inAmount: 0.0, + outAmount: repayAmount + ) + + // Create an empty collateral vault as a dummy swap input — MockDexSwapper burns it + // and withdraws repayAmount debt tokens from its configured vaultSource instead. + let seizeVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: seizeVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not resolve FTVaultData for \(seizeVaultIdentifier)") + let emptyCollateralVault <- seizeVaultData.createEmptyVault() + + // Swap: burns emptyCollateralVault, withdraws repayAmount from vaultSource + let repayVault <- swapper.swap(quote: swapQuote, inVault: <-emptyCollateralVault) + + // Execute the liquidation: pool seizes collateral, caller provides repayment + let seizedVault <- self.pool.manualLiquidation( + pid: pid, + debtType: self.debtType, + seizeType: seizeType, + seizeAmount: seizeAmount, + repayment: <-repayVault + ) + + totalRepaid = totalRepaid + repayAmount + + // Deposit seized collateral back to liquidator + let liquidatorVault = self.signerAccount.storage.borrow<&{FungibleToken.Vault}>(from: seizeVaultData.storagePath) + ?? panic("No vault at \(seizeVaultData.storagePath) to deposit seized collateral") + liquidatorVault.deposit(from: <-seizedVault) + } + + log("Batch DEX liquidation completed: \(numPositions) positions, total repaid: \(totalRepaid)") + } +} diff --git a/cadence/tests/transactions/flow-alp/pool-management/batch_manual_liquidation.cdc b/cadence/tests/transactions/flow-alp/pool-management/batch_manual_liquidation.cdc new file mode 100644 index 00000000..e57882bc --- /dev/null +++ b/cadence/tests/transactions/flow-alp/pool-management/batch_manual_liquidation.cdc @@ -0,0 +1,90 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" +import "MetadataViews" + +import "FlowALPv0" + +/// Batch liquidate multiple positions in a single transaction +/// +/// pids: Array of position IDs to liquidate +/// repaymentVaultIdentifier: e.g., Type<@FlowToken.Vault>().identifier +/// seizeVaultIdentifiers: Array of collateral vault identifiers to seize +/// seizeAmounts: Array of max seize amounts for each position +/// repayAmounts: Array of repay amounts for each position +transaction( + pids: [UInt64], + repaymentVaultIdentifier: String, + seizeVaultIdentifiers: [String], + seizeAmounts: [UFix64], + repayAmounts: [UFix64] +) { + let pool: &FlowALPv0.Pool + let repaymentType: Type + let repaymentVaultRef: auth(FungibleToken.Withdraw) &{FungibleToken.Vault} + let signerAccount: auth(BorrowValue) &Account + + prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + self.signerAccount = signer + + let protocolAddress = Type<@FlowALPv0.Pool>().address! + self.pool = getAccount(protocolAddress).capabilities.borrow<&FlowALPv0.Pool>(FlowALPv0.PoolPublicPath) + ?? panic("Could not borrow Pool at \(FlowALPv0.PoolPublicPath)") + + self.repaymentType = CompositeType(repaymentVaultIdentifier) ?? panic("Invalid repaymentVaultIdentifier: \(repaymentVaultIdentifier)") + + let repaymentVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: repaymentVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not construct valid FT type and view from identifier \(repaymentVaultIdentifier)") + + self.repaymentVaultRef = signer.storage.borrow(from: repaymentVaultData.storagePath) + ?? panic("no repayment vault in storage at path \(repaymentVaultData.storagePath)") + } + + execute { + let numPositions = pids.length + assert(seizeVaultIdentifiers.length == numPositions, message: "seizeVaultIdentifiers length mismatch") + assert(seizeAmounts.length == numPositions, message: "seizeAmounts length mismatch") + assert(repayAmounts.length == numPositions, message: "repayAmounts length mismatch") + + var totalRepaid = 0.0 + + for i in InclusiveRange(0, numPositions - 1) { + let pid = pids[i] + let seizeVaultIdentifier = seizeVaultIdentifiers[i] + let seizeAmount = seizeAmounts[i] + let repayAmount = repayAmounts[i] + + let seizeType = CompositeType(seizeVaultIdentifier) + ?? panic("Invalid seizeVaultIdentifier: \(seizeVaultIdentifier)") + + assert(self.repaymentVaultRef.balance >= repayAmount, + message: "Insufficient repayment token balance for position \(pid)") + + let repay <- self.repaymentVaultRef.withdraw(amount: repayAmount) + + let seizedVault <- self.pool.manualLiquidation( + pid: pid, + debtType: self.repaymentType, + seizeType: seizeType, + seizeAmount: seizeAmount, + repayment: <-repay + ) + + totalRepaid = totalRepaid + repayAmount + + // Deposit seized collateral back to liquidator + let seizeVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: seizeVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not resolve FTVaultData for \(seizeVaultIdentifier)") + let liquidatorVault = self.signerAccount.storage.borrow<&{FungibleToken.Vault}>(from: seizeVaultData.storagePath) + ?? panic("No vault at \(seizeVaultData.storagePath) to deposit seized collateral") + liquidatorVault.deposit(from: <-seizedVault) + } + + log("Batch liquidation completed: \(numPositions) positions, total repaid: \(totalRepaid)") + } +} diff --git a/cadence/tests/update_interest_rate_test.cdc b/cadence/tests/update_interest_rate_test.cdc index c050203b..fc01ed10 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.