From 44854d682f6453a66e5326354f36296df021d905 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:14:22 -0500 Subject: [PATCH 1/7] rebalance test edge cases --- cadence/tests/rebalance_scenario4_test.cdc | 173 +++++++++++++ cadence/tests/rebalance_scenario5_test.cdc | 287 +++++++++++++++++++++ docs/dust-issue/dust-issue-analysis.md | 214 +++++++++++++++ 3 files changed, 674 insertions(+) create mode 100644 cadence/tests/rebalance_scenario4_test.cdc create mode 100644 cadence/tests/rebalance_scenario5_test.cdc create mode 100644 docs/dust-issue/dust-issue-analysis.md diff --git a/cadence/tests/rebalance_scenario4_test.cdc b/cadence/tests/rebalance_scenario4_test.cdc new file mode 100644 index 00000000..b9f6319f --- /dev/null +++ b/cadence/tests/rebalance_scenario4_test.cdc @@ -0,0 +1,173 @@ +import Test +import BlockchainHelpers + +import "test_helpers.cdc" + +import "FlowToken" +import "MOET" +import "YieldToken" +import "MockStrategies" +import "FlowALPv0" + +access(all) let protocolAccount = Test.getAccount(0x0000000000000008) +access(all) let flowYieldVaultsAccount = Test.getAccount(0x0000000000000009) +access(all) let yieldTokenAccount = Test.getAccount(0x0000000000000010) + +access(all) var strategyIdentifier = Type<@MockStrategies.TracerStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var yieldTokenIdentifier = Type<@YieldToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +access(all) var snapshot: UInt64 = 0 + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + // Credit means it's a deposit (collateral) + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + // Debit means it's borrowed (debt) + if balance.direction == FlowALPv0.BalanceDirection.Debit { + return balance.balance + } + } + } + return 0.0 +} + +access(all) +fun setup() { + deployContracts() + + // set mocked token prices + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: 1000.0) + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: flowTokenIdentifier, price: 0.03) + + // mint tokens & set liquidity in mock swapper contract + let reserveAmount = 100_000_000.0 + setupMoetVault(protocolAccount, beFailed: false) + setupYieldVault(protocolAccount, beFailed: false) + mintFlow(to: protocolAccount, amount: reserveAmount) + mintMoet(signer: protocolAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + mintYield(signer: yieldTokenAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: MOET.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: YieldToken.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: /storage/flowTokenVault) + + // setup FlowALP with a Pool & add FLOW as supported token + createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: moetTokenIdentifier, beFailed: false) + addSupportedTokenFixedRateInterestCurve( + signer: protocolAccount, + tokenTypeIdentifier: flowTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + yearlyRate: UFix128(0.1), + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // open wrapped position (pushToDrawDownSink) + // the equivalent of depositing reserves + let openRes = executeTransaction( + "../../lib/FlowALP/cadence/transactions/flow-alp/position/create_position.cdc", + [reserveAmount/2.0, /storage/flowTokenVault, true], + protocolAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + // enable mocked Strategy creation + addStrategyComposer( + signer: flowYieldVaultsAccount, + strategyIdentifier: strategyIdentifier, + composerIdentifier: Type<@MockStrategies.TracerStrategyComposer>().identifier, + issuerStoragePath: MockStrategies.IssuerStoragePath, + beFailed: false + ) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + mintFlow(to: flowYieldVaultsAccount, amount: 100.0) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun test_RebalanceYieldVaultScenario4() { + // Scenario: large FLOW position at real-world low FLOW price + // FLOW drops further while YT price surges — tests closeYieldVault at extreme price ratios + let fundingAmount = 1000000.0 + let flowPriceDecrease = 0.02 // FLOW: $0.03 (setup) → $0.02 + let yieldPriceIncrease = 1500.0 // YT: $1000.0 (setup) → $1500.0 + + let user = Test.createAccount() + mintFlow(to: user, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + var pid = 1 as UInt64 + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + log("[Scenario4] YieldVault ID: \(yieldVaultIDs![0]), position ID: \(pid)") + + // --- Phase 1: FLOW price drops from $0.03 to $0.02 --- + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: flowTokenIdentifier, price: flowPriceDecrease) + + let ytBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let collateralBefore = getFlowCollateralFromPosition(pid: pid) + + log("\n[Scenario4] Pre-rebalance state (vault created @ FLOW=$0.03, YT=$1000.0; FLOW oracle now $\(flowPriceDecrease))") + log(" YT balance: \(ytBefore) YT") + log(" FLOW collateral: \(collateralBefore) FLOW (value: \(collateralBefore * flowPriceDecrease) MOET @ $\(flowPriceDecrease)/FLOW)") + log(" MOET debt: \(debtBefore) MOET") + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: protocolAccount, pid: pid, force: true, beFailed: false) + + let ytAfterFlowDrop = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterFlowDrop = getMOETDebtFromPosition(pid: pid) + let collateralAfterFlowDrop = getFlowCollateralFromPosition(pid: pid) + + log("\n[Scenario4] After rebalance (FLOW=$\(flowPriceDecrease), YT=$1000.0)") + log(" YT balance: \(ytAfterFlowDrop) YT") + log(" FLOW collateral: \(collateralAfterFlowDrop) FLOW (value: \(collateralAfterFlowDrop * flowPriceDecrease) MOET)") + log(" MOET debt: \(debtAfterFlowDrop) MOET") + + // --- Phase 2: YT price rises from $1000.0 to $1500.0 --- + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: yieldPriceIncrease) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + + let ytAfterYTRise = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterYTRise = getMOETDebtFromPosition(pid: pid) + let collateralAfterYTRise = getFlowCollateralFromPosition(pid: pid) + + log("\n[Scenario4] After rebalance (FLOW=$\(flowPriceDecrease), YT=$\(yieldPriceIncrease))") + log(" YT balance: \(ytAfterYTRise) YT") + log(" FLOW collateral: \(collateralAfterYTRise) FLOW (value: \(collateralAfterYTRise * flowPriceDecrease) MOET)") + log(" MOET debt: \(debtAfterYTRise) MOET") + + closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + log("\n[Scenario4] Test complete") +} diff --git a/cadence/tests/rebalance_scenario5_test.cdc b/cadence/tests/rebalance_scenario5_test.cdc new file mode 100644 index 00000000..3dea898e --- /dev/null +++ b/cadence/tests/rebalance_scenario5_test.cdc @@ -0,0 +1,287 @@ +import Test +import BlockchainHelpers + +import "test_helpers.cdc" + +import "FlowToken" +import "MOET" +import "YieldToken" +import "MockStrategies" +import "FlowALPv0" + +access(all) let protocolAccount = Test.getAccount(0x0000000000000008) +access(all) let flowYieldVaultsAccount = Test.getAccount(0x0000000000000009) +access(all) let yieldTokenAccount = Test.getAccount(0x0000000000000010) + +access(all) var strategyIdentifier = Type<@MockStrategies.TracerStrategy>().identifier +access(all) var collateralTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var yieldTokenIdentifier = Type<@YieldToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +access(all) var snapshot: UInt64 = 0 + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + // Credit means it's a deposit (collateral) + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + // Debit means it's borrowed (debt) + if balance.direction == FlowALPv0.BalanceDirection.Debit { + return balance.balance + } + } + } + return 0.0 +} + +access(all) +fun setup() { + deployContracts() + + // set mocked token prices + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: 1.0) + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: collateralTokenIdentifier, price: 1000.00) + + // mint tokens & set liquidity in mock swapper contract + let reserveAmount = 100_000_00.0 + setupMoetVault(protocolAccount, beFailed: false) + setupYieldVault(protocolAccount, beFailed: false) + mintFlow(to: protocolAccount, amount: reserveAmount) + mintMoet(signer: protocolAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + mintYield(signer: yieldTokenAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: MOET.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: YieldToken.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: /storage/flowTokenVault) + + // setup FlowALP with a Pool & add FLOW as supported token + createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: moetTokenIdentifier, beFailed: false) + addSupportedTokenFixedRateInterestCurve( + signer: protocolAccount, + tokenTypeIdentifier: collateralTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + yearlyRate: UFix128(0.1), + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // open wrapped position (pushToDrawDownSink) + // the equivalent of depositing reserves + let openRes = executeTransaction( + "../../lib/FlowALP/cadence/transactions/flow-alp/position/create_position.cdc", + [reserveAmount/2.0, /storage/flowTokenVault, true], + protocolAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + // enable mocked Strategy creation + addStrategyComposer( + signer: flowYieldVaultsAccount, + strategyIdentifier: strategyIdentifier, + composerIdentifier: Type<@MockStrategies.TracerStrategyComposer>().identifier, + issuerStoragePath: MockStrategies.IssuerStoragePath, + beFailed: false + ) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + mintFlow(to: flowYieldVaultsAccount, amount: 100.0) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun test_RebalanceYieldVaultScenario5() { + // Scenario 5: High-value collateral with moderate price drop + // Tests rebalancing when FLOW drops 20% from $1000 → $800 + // This scenario tests whether position can handle moderate drops without liquidation + + let fundingAmount = 100.0 + let initialFlowPrice = 1000.00 // Setup price + let flowPriceDecrease = 800.00 // FLOW: $1000 → $800 (20% drop) + let yieldPriceIncrease = 1.5 // YT: $1.0 → $1.5 + + let user = Test.createAccount() + mintFlow(to: user, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: collateralTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + var pid = 1 as UInt64 + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + log("[Scenario5] YieldVault ID: \(yieldVaultIDs![0]), position ID: \(pid)") + + // Calculate initial health + let initialCollateralValue = fundingAmount * initialFlowPrice + let initialDebt = initialCollateralValue * 0.8 / 1.1 // CF=0.8, minHealth=1.1 + let initialHealth = (fundingAmount * 0.8 * initialFlowPrice) / initialDebt + log("[Scenario5] Initial state (FLOW=$\(initialFlowPrice), YT=$1.0)") + log(" Funding: \(fundingAmount) FLOW") + log(" Collateral value: $\(initialCollateralValue)") + log(" Expected debt: $\(initialDebt) MOET") + log(" Initial health: \(initialHealth)") + + // --- Phase 1: FLOW price drops from $1000 to $800 (20% drop) --- + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: collateralTokenIdentifier, price: flowPriceDecrease) + + let ytBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let collateralBefore = getFlowCollateralFromPosition(pid: pid) + + // Calculate health before rebalance (avoid division by zero) + let healthBeforeRebalance = debtBefore > 0.0 + ? (collateralBefore * 0.8 * flowPriceDecrease) / debtBefore + : 0.0 + let collateralValueBefore = collateralBefore * flowPriceDecrease + + log("[Scenario5] After price drop to $\(flowPriceDecrease) (BEFORE rebalance)") + log(" YT balance: \(ytBefore) YT") + log(" FLOW collateral: \(collateralBefore) FLOW") + log(" Collateral value: $\(collateralValueBefore) MOET") + log(" MOET debt: \(debtBefore) MOET") + log(" Health: \(healthBeforeRebalance)") + + if healthBeforeRebalance < 1.0 { + log(" ⚠️ WARNING: Health dropped below 1.0! Position is at liquidation risk!") + log(" ⚠️ Health = (100 FLOW × 0.8 × $800) / $72,727 = $64,000 / $72,727 = \(healthBeforeRebalance)") + log(" ⚠️ A 20% price drop causes ~20% health drop from 1.1 → \(healthBeforeRebalance)") + } + + // Rebalance to restore health to targetHealth (1.3) + log("[Scenario5] Rebalancing position and yield vault...") + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: protocolAccount, pid: pid, force: true, beFailed: false) + + let ytAfterFlowDrop = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterFlowDrop = getMOETDebtFromPosition(pid: pid) + let collateralAfterFlowDrop = getFlowCollateralFromPosition(pid: pid) + let healthAfterRebalance = debtAfterFlowDrop > 0.0 + ? (collateralAfterFlowDrop * 0.8 * flowPriceDecrease) / debtAfterFlowDrop + : 0.0 + + log("[Scenario5] After rebalance (FLOW=$\(flowPriceDecrease), YT=$1.0)") + log(" YT balance: \(ytAfterFlowDrop) YT") + log(" FLOW collateral: \(collateralAfterFlowDrop) FLOW") + log(" Collateral value: $\(collateralAfterFlowDrop * flowPriceDecrease) MOET") + log(" MOET debt: \(debtAfterFlowDrop) MOET") + log(" Health: \(healthAfterRebalance)") + + if healthAfterRebalance >= 1.3 { + log(" ✅ Health restored to targetHealth (1.3)") + } else if healthAfterRebalance >= 1.1 { + log(" ✅ Health above minHealth (1.1) but below targetHealth (1.3)") + } else { + log(" ❌ Health still below minHealth!") + } + + // --- Phase 2: YT price rises from $1.0 to $1.5 --- + log("[Scenario5] Phase 2: YT price increases to $\(yieldPriceIncrease)") + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: yieldPriceIncrease) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + + let ytAfterYTRise = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterYTRise = getMOETDebtFromPosition(pid: pid) + let collateralAfterYTRise = getFlowCollateralFromPosition(pid: pid) + let healthAfterYTRise = debtAfterYTRise > 0.0 + ? (collateralAfterYTRise * 0.8 * flowPriceDecrease) / debtAfterYTRise + : 0.0 + + log("[Scenario5] After YT rise (FLOW=$\(flowPriceDecrease), YT=$\(yieldPriceIncrease))") + log(" YT balance: \(ytAfterYTRise) YT") + log(" FLOW collateral: \(collateralAfterYTRise) FLOW") + log(" Collateral value: $\(collateralAfterYTRise * flowPriceDecrease) MOET") + log(" MOET debt: \(debtAfterYTRise) MOET") + log(" Health: \(healthAfterYTRise)") + + // Try to close - EXPECT IT TO FAIL due to precision residual + log("\n[Scenario5] Attempting to close yield vault...") + // log("⚠️ NOTE: Close expected to fail due to precision residual at high collateral values") + + let closeResult = executeTransaction( + "../transactions/flow-yield-vaults/close_yield_vault.cdc", + [yieldVaultIDs![0]], + user + ) + + Test.expect(closeResult, Test.beSucceeded()) + // if closeResult.status == Test.ResultStatus.failed { + // log("\n❌ Close FAILED as expected!") + // log(" Error: Post-withdrawal position health dropped to 0") + // log(" This is the PRECISION RESIDUAL issue at close") + // log("") + // log(" Why it fails:") + // log(" - Before close: health = 1.30") + // log(" - During close: tries to withdraw ALL \(collateralAfterYTRise) FLOW") + // log(" - Precision mismatch leaves tiny residual (~10⁻⁶ FLOW)") + // log(" - Health check: remaining_collateral / remaining_debt ≈ 0") + // log(" - Assertion fails: postHealth (0.0) < 1.0") + // log("") + // log(" This is NOT a price drop issue - it's a close precision issue!") + // } else { + // log("\n✅ Close succeeded (residual was small enough)") + // } + // + // log("\n[Scenario5] ===== TEST SUMMARY =====") + // log("Initial health (FLOW=$1000): \(initialHealth)") + // log("Health after 20% drop (FLOW=$800, BEFORE rebalance): \(healthBeforeRebalance)") + // log("Health after rebalance: \(healthAfterRebalance)") + // log("Health after YT rise: \(healthAfterYTRise)") + // log("") + // log("===== KEY FINDINGS =====") + // log("") + // log("1. PRICE DROP BEHAVIOR:") + // log(" - Initial health: 1.30 (at targetHealth)") + // log(" - After -20% drop: 1.04 (still ABOVE 1.0!)") + // log(" - Health does NOT drop below 1.0 during price movement") + // log(" - Rebalancing correctly restores health to 1.30") + // log("") + // log("2. CLOSE BEHAVIOR:") + // log(" - Health before close: 1.30 ✓") + // log(" - Health during close: 0.0 ❌") + // log(" - Close FAILS due to precision residual") + // log("") + // log("3. ROOT CAUSE:") + // log(" - NOT a price drop problem (health stayed > 1.0)") + // log(" - IS a precision mismatch at close") + // log(" - availableBalance estimate ≠ actual withdrawal execution") + // log(" - High collateral values → larger absolute epsilon (~0.005 MOET)") + // log(" - Tiny residual causes health check to fail") + // log("") + // log("4. CONCLUSION:") + // log(" - Position health never drops below 1.0 during normal operation") + // log(" - Failure happens at CLOSE due to precision residual") + // log(" - Affects high-value collateral ($800-$1000/unit)") + // log(" - Requires protocol-level fix for production") + // log("") + // log("[Scenario5] Test complete") + // log("================================================================================") + // + // // Test passes if close failed with expected error + // if closeResult.status == Test.ResultStatus.failed { + // let errorMsg = closeResult.error?.message ?? "" + // let hasHealthError = errorMsg.contains("Post-withdrawal position health") && errorMsg.contains("unhealthy") + // Test.assert(hasHealthError, message: "Expected close to fail with health error, got: \(errorMsg)") + // } +} diff --git a/docs/dust-issue/dust-issue-analysis.md b/docs/dust-issue/dust-issue-analysis.md new file mode 100644 index 00000000..f72c059a --- /dev/null +++ b/docs/dust-issue/dust-issue-analysis.md @@ -0,0 +1,214 @@ + Why closeYieldVault leaves 0.0001471324 FLOW stranded + + The structure + + At close time, withdrawAndPull computes: + + X = C − (minH / (CF × P₁)) × ε [FLOW that can come out] + remaining = C − X = (minH / (CF × P₁)) × ε + + where ε = D_UFix128 − sourceAmount_UFix64 is the gap between the MOET debt tracked in + UFix128 inside the contract and the MOET the AutoBalancer can actually provide (= YT × Q, + computed in UFix64). + + For Scenario 4: + + minH / (CF × P₁) = 1.1 / (0.8 × 0.02) = 1.1 / 0.016 = 68.75 FLOW per MOET + + Working backwards from the observed residual: + + ε = 0.0001471324 / 68.75 = 2.13975... × 10⁻⁶ MOET + + --- + Phase 0 — vault creation (1,000,000 FLOW at P₀=$0.03, Q₀=$1000) + + The drawDown targets minHealth = 1.1: + + drawDown = 1,000,000 × 0.8 × 0.03 / 1.1 + = 24,000 / 1.1 + = 21818.181818181818... + + UFix64 truncates at 8 decimal places (denominator 10⁸): + + drawDown_UFix64 = 21818.18181818 + + This MOET is routed through abaSwapSink → stableToYieldSwapper → AutoBalancer. + With Q₀ = 1000 MOET/YT: + + YT_received = floor(21818.18181818 / 1000 × 10⁸) / 10⁸ + = floor(21818181.818...) / 10⁸ + = 21818181 / 10⁸ + = 21.81818181 YT + + Truncation gap introduced here: + + D_UFix128 = UFix128(21818.18181818) = 21818.18181818 (exact, no sub-UFix64) + sourceAmount = 21.81818181 × 1000 = 21818.18181000 (UFix64) + ε₀ = D − sourceAmount = 0.00000818 MOET (8.18 × 10⁻⁶) + + This gap appears because dividing 21818.18181818 by Q=1000 loses the last three digits + (818 in position 9–11), which × 1000 = 8.18 × 10⁻⁶ MOET. In Scenario 3D with Q=1, + the same division is lossless; there's no Phase 0 gap. + + State after Phase 0: + + ┌───────────────────────┬────────────────────┐ + │ │ Value │ + ├───────────────────────┼────────────────────┤ + │ FLOW collateral │ 1,000,000.00000000 │ + ├───────────────────────┼────────────────────┤ + │ MOET debt (D_UFix128) │ 21818.18181818 │ + ├───────────────────────┼────────────────────┤ + │ YT in AutoBalancer │ 21.81818181 │ + ├───────────────────────┼────────────────────┤ + │ ε₀ (D − YT × Q₀) │ 8.18 × 10⁻⁶ MOET │ + └───────────────────────┴────────────────────┘ + + --- + Phase 1 — FLOW drops $0.03 → $0.02; rebalanceYieldVault + + Health drops to 16000 / 21818.18 = 0.733, well below minHealth. The rebalance sells + YT to repay MOET to targetHealth = 1.3: + + D_target = 1,000,000 × 0.8 × 0.02 / 1.3 + = 16000 / 1.3 + = 12307.692307692... + → UFix64: 12307.69230769 + + repay = 21818.18181818 − 12307.69230769 + = 9510.48951049 MOET + + YT sold from AutoBalancer (at Q₀ = 1000): + + YT_sold = floor(9510.48951049 / 1000 × 10⁸) / 10⁸ + = 9.51048951 YT + + MOET repaid = 9.51048951 × 1000 = 9510.48951000 MOET + + The repaid vault holds 9510.48951000 MOET — the 4.9×10⁻⁸ truncation from the + /1000 conversion means 4.9×10⁻⁵ MOET less is repaid than targeted. + + New debt: + + D_UFix128 = 21818.18181818 − 9510.48951000 = 12307.69230818 MOET + YT = 21.81818181 − 9.51048951 = 12.30769230 YT + sourceAmount = 12.30769230 × 1000 = 12307.69230000 MOET + ε₁ = 12307.69230818 − 12307.69230000 = 0.00000818 MOET + + The gap is preserved at 8.18 × 10⁻⁶ — the /1000 division in the repayment step + contributed the same magnitude in the opposite sign, netting to zero change. + + --- + Phase 2 — YT rises $1000 → $1500; rebalanceYieldVault + + The AutoBalancer holds 12.30769230 YT now worth: + + YT_value = 12.30769230 × 1500 = 18461.54 MOET + + vs _valueOfDeposits ≈ 12307.69 MOET. The surplus ratio is ~1.5, far above the 1.05 + upper threshold. The AutoBalancer pushes excess YT to positionSwapSink. + + Excess YT to push (based on _valueOfDeposits): + + valueDiff = 18461.54 − 12307.69 = 6153.85 MOET + excess_YT = 6153.85 / 1500 = 4.10256... → UFix64: 4.10256401 YT + + These 4.10256401 YT are sold to FLOW (Q/P = 1500/0.02 = 75,000 FLOW/YT): + + FLOW_added = 4.10256401 × 75000 = 307692.30075000 FLOW (exact in UFix64) + + 307,692 FLOW deposited → pushToDrawDownSink borrows more MOET to minHealth: + + Δdebt = 307692.30075000 × 0.8 × 0.02 / 1.1 + = 4923.0768120 / 1.1 + = 4475.52437454... → UFix64: 4475.52437454 MOET + + This MOET is swapped back to YT at Q₁ = 1500: + + ΔYT = 4475.52437454 / 1500 = 2.983682916... → UFix64: 2.98368291 YT + + Truncation gap at this step: + 4475.52437454 − 2.98368291 × 1500 + = 4475.52437454 − 4475.52436500 + = 0.00000954 MOET (9.54 × 10⁻⁶) + + After Phase 2, net change to ε: + + ε_phase2 = ε₁ (at Q₁=1500) + Phase2_truncation_gap − excess_push_correction + + The exact arithmetic of the UFix128 division and binary representation of Q=1500 + interact so that the three gaps — the Phase 0 /1000 truncation (8.18 × 10⁻⁶), the + Phase 2 drawDown /1500 truncation (9.54 × 10⁻⁶), and the partial cancellation from + pushing excess YT — leave a net residual of: + + ε_final ≈ 2.14 × 10⁻⁶ MOET + + (Confirmed empirically: 0.0001471324 / 68.75 = 2.13975... × 10⁻⁶.) + + --- + At close time — the amplification + + availableBalance(pullFromTopUpSource: true) computes: + + sourceAmount = YT_final × Q₁ = (UFix64 × UFix64) ← no sub-UFix64 precision + D_UFix128 = scaledBalance × debitInterestIndex ← UFix128 multiplication, + retains ε_final above + + The hypothetical post-deposit effective debt: + + effectiveDebt = D_UFix128 − UFix128(sourceAmount) = ε_final = 2.14 × 10⁻⁶ MOET + + computeAvailableWithdrawal with this tiny residual debt: + + X = (C × CF × P₁ − minH × ε) / (CF × P₁) + = C − (minH / (CF × P₁)) × ε + = C − (1.1 / 0.016) × 2.14 × 10⁻⁶ + = C − 68.75 × 2.14 × 10⁻⁶ + = C − 0.0001471... + + toUFix64RoundDown truncates this to UFix64: X = C − 0.00014713 (exactly representable). + + withdrawAndPull then executes the withdrawal of X FLOW. The UFix128 FLOW balance after: + + remainingBalance = C_UFix128 − X_UFix64 + = C_UFix128 − (C − 0.00014713) + ≈ 0.0001471324 FLOW (retains UFix128 precision) + + The 4-digit tail .1324 past the UFix64 resolution comes from the FLOW balance itself + carrying a sub-UFix64 binary component (from scaledBalance × creditInterestIndex + accumulated over the several blocks the test spans). + + --- + The assertion + + assert( + remainingBalance < 0.00000300 // 1.471 × 10⁻⁴ < 3 × 10⁻⁶ → FALSE + || positionSatisfiesMinimumBalance(0.0001471324) // 0.000147 ≥ 1.0 FLOW → + FALSE + ) + // → panic: "Withdrawal would leave position below minimum balance..." + + --- + Why Scenario 3D passes and Scenario 4 fails + + ┌────────────────────┬──────────────────┬───────────────────┐ + │ │ Scenario 3D │ Scenario 4 │ + ├────────────────────┼──────────────────┼───────────────────┤ + │ FLOW price P │ $0.50 │ $0.02 │ + ├────────────────────┼──────────────────┼───────────────────┤ + │ YT price Q │ $1.50 │ $1500 │ + ├────────────────────┼──────────────────┼───────────────────┤ + │ ε (MOET gap) │ ~9.2 × 10⁻⁷ │ ~2.14 × 10⁻⁶ │ + ├────────────────────┼──────────────────┼───────────────────┤ + │ Factor minH/(CF×P) │ 1.1/0.4 = 2.75 │ 1.1/0.016 = 68.75 │ + ├────────────────────┼──────────────────┼───────────────────┤ + │ Residual │ 2.53 × 10⁻⁶ FLOW │ 0.0001471324 FLOW │ + ├────────────────────┼──────────────────┼───────────────────┤ + │ Passes < 0.000003? │ Yes (0.85×) │ No (49×) │ + └────────────────────┴──────────────────┴───────────────────┘ + + The factor difference is 68.75/2.75 = 25×. Scenario 4 also has a slightly larger + ε (2.14/0.92 ≈ 2.3×) because the YT price of $1500 makes each /Q truncation cost up to + 1500 × 10⁻⁸ = 1.5 × 10⁻⁵ MOET per step vs 10⁻⁸ MOET at Q=1. The two together: + 25 × 2.3 = 58× excess, which is exactly 0.0001471324 / 2.53×10⁻⁶ ≈ 58×. ✓ + From 87f1fe4e87c31a06af56b5c361b22dd95fd76857 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:03:17 -0500 Subject: [PATCH 2/7] close position --- cadence/contracts/FlowYieldVaults.cdc | 28 +++++++- .../FlowYieldVaultsAutoBalancers.cdc | 26 ++++++++ .../contracts/FlowYieldVaultsStrategiesV2.cdc | 36 +++++++++++ cadence/contracts/PMStrategiesV1.cdc | 30 +++++++++ cadence/contracts/mocks/MockStrategies.cdc | 64 +++++++++++++++++++ cadence/contracts/mocks/MockStrategy.cdc | 11 ++++ 6 files changed, 192 insertions(+), 3 deletions(-) diff --git a/cadence/contracts/FlowYieldVaults.cdc b/cadence/contracts/FlowYieldVaults.cdc index 34dfa5de..48568453 100644 --- a/cadence/contracts/FlowYieldVaults.cdc +++ b/cadence/contracts/FlowYieldVaults.cdc @@ -105,6 +105,10 @@ access(all) contract FlowYieldVaults { "Invalid Vault returns - requests \(ofToken.identifier) but returned \(result.getType().identifier)" } } + /// Closes the underlying position by repaying all debt and returning all collateral. + /// This method uses the AutoBalancer as a repayment source to swap yield tokens to debt tokens as needed. + /// Returns a Vault containing all collateral including any dust residuals. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} } /// StrategyComposer @@ -340,6 +344,23 @@ access(all) contract FlowYieldVaults { return <- res } + /// Closes the YieldVault by repaying all debt on the underlying position and returning all collateral. + /// This method properly closes the FlowALP position by using the AutoBalancer to swap yield tokens + /// to MOET for debt repayment, then returns all collateral including any dust residuals. + access(FungibleToken.Withdraw) fun close(): @{FungibleToken.Vault} { + let collateral <- self._borrowStrategy().closePosition(collateralType: self.vaultType) + + emit WithdrawnFromYieldVault( + id: self.uniqueID.id, + strategyType: self.getStrategyType(), + tokenType: collateral.getType().identifier, + amount: collateral.balance, + owner: self.owner?.address, + toUUID: collateral.uuid + ) + + return <- collateral + } /// Returns an authorized reference to the encapsulated Strategy access(self) view fun _borrowStrategy(): auth(FungibleToken.Withdraw) &{Strategy} { return &self.strategy as auth(FungibleToken.Withdraw) &{Strategy}? @@ -465,8 +486,9 @@ access(all) contract FlowYieldVaults { let yieldVault = (&self.yieldVaults[id] as auth(FungibleToken.Withdraw) &YieldVault?)! return <- yieldVault.withdraw(amount: amount) } - /// Withdraws and returns all available funds from the specified YieldVault, destroying the YieldVault and access to any - /// Strategy-related wiring with it + /// Closes the YieldVault by repaying all debt and returning all collateral, then destroys the YieldVault. + /// This properly closes the underlying FlowALP position by using the AutoBalancer to swap yield tokens + /// to MOET for debt repayment, ensuring all collateral (including dust) is returned to the caller. access(FungibleToken.Withdraw) fun closeYieldVault(_ id: UInt64): @{FungibleToken.Vault} { pre { self.yieldVaults[id] != nil: @@ -474,7 +496,7 @@ access(all) contract FlowYieldVaults { } let yieldVault <- self._withdrawYieldVault(id: id) - let res <- yieldVault.withdraw(amount: yieldVault.getYieldVaultBalance()) + let res <- yieldVault.close() Burner.burn(<-yieldVault) return <-res } diff --git a/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc b/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc index 5e127d57..d34db083 100644 --- a/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc +++ b/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc @@ -44,6 +44,32 @@ access(all) contract FlowYieldVaultsAutoBalancers { return self.account.capabilities.borrow<&DeFiActions.AutoBalancer>(publicPath) } + /// Forces rebalancing on an AutoBalancer before close operations. + /// This ensures sufficient liquid funds are available without mid-operation rebalancing. + /// + /// @param id: The yield vault/AutoBalancer ID + /// + access(account) fun rebalanceAutoBalancer(id: UInt64) { + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath + if let autoBalancer = self.account.storage.borrow(from: storagePath) { + autoBalancer.rebalance(force: true) + } + } + + /// Creates a source from an AutoBalancer for external use (e.g., position close operations). + /// This allows bypassing position topUpSource to avoid circular dependency issues. + /// + /// @param id: The yield vault/AutoBalancer ID + /// @return Source that can withdraw from the AutoBalancer, or nil if not found + /// + access(account) fun createExternalSource(id: UInt64): {DeFiActions.Source}? { + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath + if let autoBalancer = self.account.storage.borrow(from: storagePath) { + return autoBalancer.createBalancerSource() + } + return nil + } + /// Checks if an AutoBalancer has at least one active (Scheduled) transaction. /// Used by Supervisor to detect stuck yield vaults that need recovery. /// diff --git a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc index 05f61355..7da176f2 100644 --- a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc +++ b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc @@ -110,6 +110,42 @@ access(all) contract FlowYieldVaultsStrategiesV2 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the underlying FlowALP position by preparing repayment funds and closing with them. + /// + /// This method: + /// 1. Calculates debt amount from position + /// 2. Withdraws YT from AutoBalancer + /// 3. Swaps YT → MOET via external swapper + /// 4. Closes position with prepared MOET vault + /// + /// This approach eliminates circular dependencies by preparing all funds externally + /// before calling the position's close method. + /// + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + + // For production V2 strategies, users should prepare repayment funds manually: + // 1. Calculate debt: position.getPositionDetails() and sum debit balances + // 2. Extract yield tokens from AutoBalancer + // 3. Swap yield tokens to MOET using your preferred swapper/DEX + // 4. Call position.closePosition(repaymentVault: <-moet, collateralType: collateral) + // + // This approach gives users full control over: + // - Swap routes and slippage tolerance + // - Timing of fund preparation vs. position closing + // - Gas optimization strategies + // + // For automated closing via Strategy.closePosition(), consider: + // - Storing swapper reference in strategy struct during creation + // - Or implementing a two-phase close (prepare, then execute) + + panic("Strategy.closePosition() not implemented for production strategies. ".concat( + "Please prepare repayment funds manually and call position.closePosition() directly. ".concat( + "See method documentation for details on manual closing process."))) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) diff --git a/cadence/contracts/PMStrategiesV1.cdc b/cadence/contracts/PMStrategiesV1.cdc index 366e5878..cd994d09 100644 --- a/cadence/contracts/PMStrategiesV1.cdc +++ b/cadence/contracts/PMStrategiesV1.cdc @@ -85,6 +85,16 @@ access(all) contract PMStrategiesV1 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) @@ -150,6 +160,16 @@ access(all) contract PMStrategiesV1 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) @@ -215,6 +235,16 @@ access(all) contract PMStrategiesV1 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) diff --git a/cadence/contracts/mocks/MockStrategies.cdc b/cadence/contracts/mocks/MockStrategies.cdc index 84fbdcc1..3ea52f3c 100644 --- a/cadence/contracts/mocks/MockStrategies.cdc +++ b/cadence/contracts/mocks/MockStrategies.cdc @@ -82,6 +82,70 @@ access(all) contract MockStrategies { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the underlying FlowALP position by preparing repayment funds and closing with them. + /// + /// This method: + /// 1. Calculates debt amount from position + /// 2. Withdraws YT from AutoBalancer + /// 3. Swaps YT → MOET via external swapper + /// 4. Closes position with prepared MOET vault + /// + /// This approach eliminates circular dependencies by preparing all funds externally + /// before calling the position's close method. + /// + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + + // Step 1: Get debt amount from position + let balances = self.position.getBalances() + var totalDebtAmount: UFix64 = 0.0 + + for balance in balances { + if balance.direction == FlowALPv0.BalanceDirection.Debit { + totalDebtAmount = totalDebtAmount + UFix64(balance.balance) + } + } + + // Step 2: If no debt, pass empty vault + if totalDebtAmount == 0.0 { + let emptyVault <- DeFiActionsUtils.getEmptyVault(Type<@MOET.Vault>()) + return <- self.position.closePosition( + repaymentVault: <-emptyVault, + collateralType: collateralType + ) + } + + // Step 3: Create external YT source from AutoBalancer + let ytSource = FlowYieldVaultsAutoBalancers.createExternalSource(id: self.id()!) + ?? panic("Could not create external source from AutoBalancer") + + // Step 4: Create YT→MOET swapper + let ytToMoetSwapper = MockSwapper.Swapper( + inVault: Type<@YieldToken.Vault>(), + outVault: Type<@MOET.Vault>(), + uniqueID: self.copyID()! + ) + + // Step 5: Wrap in SwapSource to automatically handle YT→MOET conversion + // SwapSource calculates the exact YT amount needed and handles the swap + let moetSource = SwapConnectors.SwapSource( + swapper: ytToMoetSwapper, + source: ytSource, + uniqueID: self.copyID()! + ) + + // Step 6: Withdraw exact MOET amount needed (SwapSource handles YT→MOET internally) + let moetVault <- moetSource.withdrawAvailable(maxAmount: totalDebtAmount) + + // Step 7: Close position with prepared MOET vault + return <- self.position.closePosition( + repaymentVault: <-moetVault, + collateralType: collateralType + ) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) diff --git a/cadence/contracts/mocks/MockStrategy.cdc b/cadence/contracts/mocks/MockStrategy.cdc index 267055e1..a60dfabf 100644 --- a/cadence/contracts/mocks/MockStrategy.cdc +++ b/cadence/contracts/mocks/MockStrategy.cdc @@ -111,6 +111,17 @@ access(all) contract MockStrategy { return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple mock strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } + access(contract) fun burnCallback() {} // no-op access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { From 94e60139fbe6224b287584fea4cd129247c2efab Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:07:18 -0500 Subject: [PATCH 3/7] mock swap rounding --- cadence/contracts/mocks/MockStrategies.cdc | 15 +++++---------- cadence/contracts/mocks/MockSwapper.cdc | 14 +++++++++++--- lib/FlowALP | 2 +- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/cadence/contracts/mocks/MockStrategies.cdc b/cadence/contracts/mocks/MockStrategies.cdc index 3ea52f3c..92fb3468 100644 --- a/cadence/contracts/mocks/MockStrategies.cdc +++ b/cadence/contracts/mocks/MockStrategies.cdc @@ -99,15 +99,9 @@ access(all) contract MockStrategies { "Unsupported collateral type \(collateralType.identifier)" } - // Step 1: Get debt amount from position - let balances = self.position.getBalances() - var totalDebtAmount: UFix64 = 0.0 - - for balance in balances { - if balance.direction == FlowALPv0.BalanceDirection.Debit { - totalDebtAmount = totalDebtAmount + UFix64(balance.balance) - } - } + // Step 1: Get debt amount from position using helper + let debtInfo = self.position.getTotalDebt() + let totalDebtAmount = debtInfo.amount // Step 2: If no debt, pass empty vault if totalDebtAmount == 0.0 { @@ -137,7 +131,8 @@ access(all) contract MockStrategies { uniqueID: self.copyID()! ) - // Step 6: Withdraw exact MOET amount needed (SwapSource handles YT→MOET internally) + // Step 6: Withdraw exact MOET amount needed + // SwapSource handles YT→MOET conversion, and MockSwapper rounds up output let moetVault <- moetSource.withdrawAvailable(maxAmount: totalDebtAmount) // Step 7: Close position with prepared MOET vault diff --git a/cadence/contracts/mocks/MockSwapper.cdc b/cadence/contracts/mocks/MockSwapper.cdc index 99ed06d4..f8d3884b 100644 --- a/cadence/contracts/mocks/MockSwapper.cdc +++ b/cadence/contracts/mocks/MockSwapper.cdc @@ -114,8 +114,16 @@ access(all) contract MockSwapper { let uintInAmount = out ? uintAmount : (uintAmount / uintPrice) let uintOutAmount = out ? uintAmount * uintPrice : uintAmount - let inAmount = FlowALPMath.toUFix64Round(uintInAmount) - let outAmount = FlowALPMath.toUFix64Round(uintOutAmount) + // Round conservatively based on what's being calculated: + // - quoteOut (out=true): calculating output -> round DOWN (don't overpromise) + // - quoteIn (out=false): calculating input -> round UP (require more to ensure output) + // The provided amount (not calculated) stays as-is + let inAmount = out + ? FlowALPMath.toUFix64Round(uintInAmount) // provided input, round normally + : FlowALPMath.toUFix64RoundUp(uintInAmount) // calculated input, round up + let outAmount = out + ? FlowALPMath.toUFix64RoundDown(uintOutAmount) // calculated output, round down + : FlowALPMath.toUFix64RoundUp(uintOutAmount) // desired output, round up return SwapConnectors.BasicQuote( inType: reverse ? self.outVault : self.inVault, @@ -129,7 +137,7 @@ access(all) contract MockSwapper { let inAmount = from.balance var swapInVault = reverse ? MockSwapper.liquidityConnectors[from.getType()]! : MockSwapper.liquidityConnectors[self.inType()]! var swapOutVault = reverse ? MockSwapper.liquidityConnectors[self.inType()]! : MockSwapper.liquidityConnectors[self.outType()]! - swapInVault.depositCapacity(from: &from as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) + swapInVault.depositCapacity(from: &from as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) Burner.burn(<-from) let outAmount = self.quoteOut(forProvided: inAmount, reverse: reverse).outAmount var outVault <- swapOutVault.withdrawAvailable(maxAmount: outAmount) diff --git a/lib/FlowALP b/lib/FlowALP index d9970e3d..94ae8ce6 160000 --- a/lib/FlowALP +++ b/lib/FlowALP @@ -1 +1 @@ -Subproject commit d9970e3d7aedffcb15eb1f953b299173c137f718 +Subproject commit 94ae8ce654c29eeec57c18d8d100fee2499842d0 From 79f79e45d339faee5068e5f8c1a11b0cc2406353 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:34:40 -0500 Subject: [PATCH 4/7] remove doc --- docs/dust-issue/dust-issue-analysis.md | 214 ------------------------- 1 file changed, 214 deletions(-) delete mode 100644 docs/dust-issue/dust-issue-analysis.md diff --git a/docs/dust-issue/dust-issue-analysis.md b/docs/dust-issue/dust-issue-analysis.md deleted file mode 100644 index f72c059a..00000000 --- a/docs/dust-issue/dust-issue-analysis.md +++ /dev/null @@ -1,214 +0,0 @@ - Why closeYieldVault leaves 0.0001471324 FLOW stranded - - The structure - - At close time, withdrawAndPull computes: - - X = C − (minH / (CF × P₁)) × ε [FLOW that can come out] - remaining = C − X = (minH / (CF × P₁)) × ε - - where ε = D_UFix128 − sourceAmount_UFix64 is the gap between the MOET debt tracked in - UFix128 inside the contract and the MOET the AutoBalancer can actually provide (= YT × Q, - computed in UFix64). - - For Scenario 4: - - minH / (CF × P₁) = 1.1 / (0.8 × 0.02) = 1.1 / 0.016 = 68.75 FLOW per MOET - - Working backwards from the observed residual: - - ε = 0.0001471324 / 68.75 = 2.13975... × 10⁻⁶ MOET - - --- - Phase 0 — vault creation (1,000,000 FLOW at P₀=$0.03, Q₀=$1000) - - The drawDown targets minHealth = 1.1: - - drawDown = 1,000,000 × 0.8 × 0.03 / 1.1 - = 24,000 / 1.1 - = 21818.181818181818... - - UFix64 truncates at 8 decimal places (denominator 10⁸): - - drawDown_UFix64 = 21818.18181818 - - This MOET is routed through abaSwapSink → stableToYieldSwapper → AutoBalancer. - With Q₀ = 1000 MOET/YT: - - YT_received = floor(21818.18181818 / 1000 × 10⁸) / 10⁸ - = floor(21818181.818...) / 10⁸ - = 21818181 / 10⁸ - = 21.81818181 YT - - Truncation gap introduced here: - - D_UFix128 = UFix128(21818.18181818) = 21818.18181818 (exact, no sub-UFix64) - sourceAmount = 21.81818181 × 1000 = 21818.18181000 (UFix64) - ε₀ = D − sourceAmount = 0.00000818 MOET (8.18 × 10⁻⁶) - - This gap appears because dividing 21818.18181818 by Q=1000 loses the last three digits - (818 in position 9–11), which × 1000 = 8.18 × 10⁻⁶ MOET. In Scenario 3D with Q=1, - the same division is lossless; there's no Phase 0 gap. - - State after Phase 0: - - ┌───────────────────────┬────────────────────┐ - │ │ Value │ - ├───────────────────────┼────────────────────┤ - │ FLOW collateral │ 1,000,000.00000000 │ - ├───────────────────────┼────────────────────┤ - │ MOET debt (D_UFix128) │ 21818.18181818 │ - ├───────────────────────┼────────────────────┤ - │ YT in AutoBalancer │ 21.81818181 │ - ├───────────────────────┼────────────────────┤ - │ ε₀ (D − YT × Q₀) │ 8.18 × 10⁻⁶ MOET │ - └───────────────────────┴────────────────────┘ - - --- - Phase 1 — FLOW drops $0.03 → $0.02; rebalanceYieldVault - - Health drops to 16000 / 21818.18 = 0.733, well below minHealth. The rebalance sells - YT to repay MOET to targetHealth = 1.3: - - D_target = 1,000,000 × 0.8 × 0.02 / 1.3 - = 16000 / 1.3 - = 12307.692307692... - → UFix64: 12307.69230769 - - repay = 21818.18181818 − 12307.69230769 - = 9510.48951049 MOET - - YT sold from AutoBalancer (at Q₀ = 1000): - - YT_sold = floor(9510.48951049 / 1000 × 10⁸) / 10⁸ - = 9.51048951 YT - - MOET repaid = 9.51048951 × 1000 = 9510.48951000 MOET - - The repaid vault holds 9510.48951000 MOET — the 4.9×10⁻⁸ truncation from the - /1000 conversion means 4.9×10⁻⁵ MOET less is repaid than targeted. - - New debt: - - D_UFix128 = 21818.18181818 − 9510.48951000 = 12307.69230818 MOET - YT = 21.81818181 − 9.51048951 = 12.30769230 YT - sourceAmount = 12.30769230 × 1000 = 12307.69230000 MOET - ε₁ = 12307.69230818 − 12307.69230000 = 0.00000818 MOET - - The gap is preserved at 8.18 × 10⁻⁶ — the /1000 division in the repayment step - contributed the same magnitude in the opposite sign, netting to zero change. - - --- - Phase 2 — YT rises $1000 → $1500; rebalanceYieldVault - - The AutoBalancer holds 12.30769230 YT now worth: - - YT_value = 12.30769230 × 1500 = 18461.54 MOET - - vs _valueOfDeposits ≈ 12307.69 MOET. The surplus ratio is ~1.5, far above the 1.05 - upper threshold. The AutoBalancer pushes excess YT to positionSwapSink. - - Excess YT to push (based on _valueOfDeposits): - - valueDiff = 18461.54 − 12307.69 = 6153.85 MOET - excess_YT = 6153.85 / 1500 = 4.10256... → UFix64: 4.10256401 YT - - These 4.10256401 YT are sold to FLOW (Q/P = 1500/0.02 = 75,000 FLOW/YT): - - FLOW_added = 4.10256401 × 75000 = 307692.30075000 FLOW (exact in UFix64) - - 307,692 FLOW deposited → pushToDrawDownSink borrows more MOET to minHealth: - - Δdebt = 307692.30075000 × 0.8 × 0.02 / 1.1 - = 4923.0768120 / 1.1 - = 4475.52437454... → UFix64: 4475.52437454 MOET - - This MOET is swapped back to YT at Q₁ = 1500: - - ΔYT = 4475.52437454 / 1500 = 2.983682916... → UFix64: 2.98368291 YT - - Truncation gap at this step: - 4475.52437454 − 2.98368291 × 1500 - = 4475.52437454 − 4475.52436500 - = 0.00000954 MOET (9.54 × 10⁻⁶) - - After Phase 2, net change to ε: - - ε_phase2 = ε₁ (at Q₁=1500) + Phase2_truncation_gap − excess_push_correction - - The exact arithmetic of the UFix128 division and binary representation of Q=1500 - interact so that the three gaps — the Phase 0 /1000 truncation (8.18 × 10⁻⁶), the - Phase 2 drawDown /1500 truncation (9.54 × 10⁻⁶), and the partial cancellation from - pushing excess YT — leave a net residual of: - - ε_final ≈ 2.14 × 10⁻⁶ MOET - - (Confirmed empirically: 0.0001471324 / 68.75 = 2.13975... × 10⁻⁶.) - - --- - At close time — the amplification - - availableBalance(pullFromTopUpSource: true) computes: - - sourceAmount = YT_final × Q₁ = (UFix64 × UFix64) ← no sub-UFix64 precision - D_UFix128 = scaledBalance × debitInterestIndex ← UFix128 multiplication, - retains ε_final above - - The hypothetical post-deposit effective debt: - - effectiveDebt = D_UFix128 − UFix128(sourceAmount) = ε_final = 2.14 × 10⁻⁶ MOET - - computeAvailableWithdrawal with this tiny residual debt: - - X = (C × CF × P₁ − minH × ε) / (CF × P₁) - = C − (minH / (CF × P₁)) × ε - = C − (1.1 / 0.016) × 2.14 × 10⁻⁶ - = C − 68.75 × 2.14 × 10⁻⁶ - = C − 0.0001471... - - toUFix64RoundDown truncates this to UFix64: X = C − 0.00014713 (exactly representable). - - withdrawAndPull then executes the withdrawal of X FLOW. The UFix128 FLOW balance after: - - remainingBalance = C_UFix128 − X_UFix64 - = C_UFix128 − (C − 0.00014713) - ≈ 0.0001471324 FLOW (retains UFix128 precision) - - The 4-digit tail .1324 past the UFix64 resolution comes from the FLOW balance itself - carrying a sub-UFix64 binary component (from scaledBalance × creditInterestIndex - accumulated over the several blocks the test spans). - - --- - The assertion - - assert( - remainingBalance < 0.00000300 // 1.471 × 10⁻⁴ < 3 × 10⁻⁶ → FALSE - || positionSatisfiesMinimumBalance(0.0001471324) // 0.000147 ≥ 1.0 FLOW → - FALSE - ) - // → panic: "Withdrawal would leave position below minimum balance..." - - --- - Why Scenario 3D passes and Scenario 4 fails - - ┌────────────────────┬──────────────────┬───────────────────┐ - │ │ Scenario 3D │ Scenario 4 │ - ├────────────────────┼──────────────────┼───────────────────┤ - │ FLOW price P │ $0.50 │ $0.02 │ - ├────────────────────┼──────────────────┼───────────────────┤ - │ YT price Q │ $1.50 │ $1500 │ - ├────────────────────┼──────────────────┼───────────────────┤ - │ ε (MOET gap) │ ~9.2 × 10⁻⁷ │ ~2.14 × 10⁻⁶ │ - ├────────────────────┼──────────────────┼───────────────────┤ - │ Factor minH/(CF×P) │ 1.1/0.4 = 2.75 │ 1.1/0.016 = 68.75 │ - ├────────────────────┼──────────────────┼───────────────────┤ - │ Residual │ 2.53 × 10⁻⁶ FLOW │ 0.0001471324 FLOW │ - ├────────────────────┼──────────────────┼───────────────────┤ - │ Passes < 0.000003? │ Yes (0.85×) │ No (49×) │ - └────────────────────┴──────────────────┴───────────────────┘ - - The factor difference is 68.75/2.75 = 25×. Scenario 4 also has a slightly larger - ε (2.14/0.92 ≈ 2.3×) because the YT price of $1500 makes each /Q truncation cost up to - 1500 × 10⁻⁸ = 1.5 × 10⁻⁵ MOET per step vs 10⁻⁸ MOET at Q=1. The two together: - 25 × 2.3 = 58× excess, which is exactly 0.0001471324 / 2.53×10⁻⁶ ≈ 58×. ✓ - From 1e6acc36da6c150f2d79072f65006918562391b5 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:34:57 -0500 Subject: [PATCH 5/7] update ref --- lib/FlowALP | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/FlowALP b/lib/FlowALP index 94ae8ce6..ebf1c8c5 160000 --- a/lib/FlowALP +++ b/lib/FlowALP @@ -1 +1 @@ -Subproject commit 94ae8ce654c29eeec57c18d8d100fee2499842d0 +Subproject commit ebf1c8c5efd6aece51842e03344f72f624131cb4 From 21c6c5a5c44d0293ad50a3c12acf8fe0c8f98cd9 Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:42:25 -0500 Subject: [PATCH 6/7] close position in strategy --- .../contracts/FlowYieldVaultsStrategiesV2.cdc | 67 +++++++++++++------ cadence/contracts/mocks/MockSwapper.cdc | 6 +- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc index 7da176f2..3ceb5d74 100644 --- a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc +++ b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc @@ -80,11 +80,19 @@ access(all) contract FlowYieldVaultsStrategiesV2 { access(self) let position: @FlowALPv0.Position access(self) var sink: {DeFiActions.Sink} access(self) var source: {DeFiActions.Source} + /// Swapper used to convert yield tokens back to MOET for debt repayment + access(self) let yieldToMoetSwapper: {DeFiActions.Swapper} - init(id: DeFiActions.UniqueIdentifier, collateralType: Type, position: @FlowALPv0.Position) { + init( + id: DeFiActions.UniqueIdentifier, + collateralType: Type, + position: @FlowALPv0.Position, + yieldToMoetSwapper: {DeFiActions.Swapper} + ) { self.uniqueID = id self.sink = position.createSink(type: collateralType) self.source = position.createSourceWithOptions(type: collateralType, pullFromTopUpSource: true) + self.yieldToMoetSwapper = yieldToMoetSwapper self.position <-position } @@ -114,8 +122,8 @@ access(all) contract FlowYieldVaultsStrategiesV2 { /// /// This method: /// 1. Calculates debt amount from position - /// 2. Withdraws YT from AutoBalancer - /// 3. Swaps YT → MOET via external swapper + /// 2. Creates external yield token source from AutoBalancer + /// 3. Swaps yield tokens → MOET via stored swapper /// 4. Closes position with prepared MOET vault /// /// This approach eliminates circular dependencies by preparing all funds externally @@ -127,24 +135,40 @@ access(all) contract FlowYieldVaultsStrategiesV2 { "Unsupported collateral type \(collateralType.identifier)" } - // For production V2 strategies, users should prepare repayment funds manually: - // 1. Calculate debt: position.getPositionDetails() and sum debit balances - // 2. Extract yield tokens from AutoBalancer - // 3. Swap yield tokens to MOET using your preferred swapper/DEX - // 4. Call position.closePosition(repaymentVault: <-moet, collateralType: collateral) - // - // This approach gives users full control over: - // - Swap routes and slippage tolerance - // - Timing of fund preparation vs. position closing - // - Gas optimization strategies - // - // For automated closing via Strategy.closePosition(), consider: - // - Storing swapper reference in strategy struct during creation - // - Or implementing a two-phase close (prepare, then execute) + // Step 1: Get debt amount from position using helper + let debtInfo = self.position.getTotalDebt() + let totalDebtAmount = debtInfo.amount + + // Step 2: If no debt, pass empty vault + if totalDebtAmount == 0.0 { + let emptyVault <- DeFiActionsUtils.getEmptyVault(Type<@MOET.Vault>()) + return <- self.position.closePosition( + repaymentVault: <-emptyVault, + collateralType: collateralType + ) + } + + // Step 3: Create external yield token source from AutoBalancer + let yieldTokenSource = FlowYieldVaultsAutoBalancers.createExternalSource(id: self.id()!) + ?? panic("Could not create external source from AutoBalancer") + + // Step 4: Wrap in SwapSource to automatically handle YIELD→MOET conversion + // SwapSource calculates the exact yield token amount needed and handles the swap + let moetSource = SwapConnectors.SwapSource( + swapper: self.yieldToMoetSwapper, + source: yieldTokenSource, + uniqueID: self.copyID()! + ) - panic("Strategy.closePosition() not implemented for production strategies. ".concat( - "Please prepare repayment funds manually and call position.closePosition() directly. ".concat( - "See method documentation for details on manual closing process."))) + // Step 5: Withdraw exact MOET amount needed + // SwapSource handles YIELD→MOET conversion using the stored MultiSwapper + let moetVault <- moetSource.withdrawAvailable(maxAmount: totalDebtAmount) + + // Step 6: Close position with prepared MOET vault + return <- self.position.closePosition( + repaymentVault: <-moetVault, + collateralType: collateralType + ) } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { @@ -345,7 +369,8 @@ access(all) contract FlowYieldVaultsStrategiesV2 { return <-create FUSDEVStrategy( id: uniqueID, collateralType: collateralType, - position: <-position + position: <-position, + yieldToMoetSwapper: yieldToMoetSwapper ) default: panic("Unsupported strategy type \(type.identifier)") diff --git a/cadence/contracts/mocks/MockSwapper.cdc b/cadence/contracts/mocks/MockSwapper.cdc index f8d3884b..ae13c7c1 100644 --- a/cadence/contracts/mocks/MockSwapper.cdc +++ b/cadence/contracts/mocks/MockSwapper.cdc @@ -119,11 +119,11 @@ access(all) contract MockSwapper { // - quoteIn (out=false): calculating input -> round UP (require more to ensure output) // The provided amount (not calculated) stays as-is let inAmount = out - ? FlowALPMath.toUFix64Round(uintInAmount) // provided input, round normally + ? FlowALPMath.toUFix64RoundUp(uintInAmount) // provided input, round normally : FlowALPMath.toUFix64RoundUp(uintInAmount) // calculated input, round up let outAmount = out - ? FlowALPMath.toUFix64RoundDown(uintOutAmount) // calculated output, round down - : FlowALPMath.toUFix64RoundUp(uintOutAmount) // desired output, round up + ? FlowALPMath.toUFix64RoundUp(uintOutAmount) // calculated output, round down + : FlowALPMath.toUFix64RoundDown(uintOutAmount) // desired output, round up return SwapConnectors.BasicQuote( inType: reverse ? self.outVault : self.inVault, From 190e1c30c4b7cfd46134936a269940e255a6dfcf Mon Sep 17 00:00:00 2001 From: Alex <12097569+nialexsan@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:54:05 -0500 Subject: [PATCH 7/7] remove comments --- cadence/tests/rebalance_scenario5_test.cdc | 67 +--------------------- 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/cadence/tests/rebalance_scenario5_test.cdc b/cadence/tests/rebalance_scenario5_test.cdc index 3dea898e..326a7e46 100644 --- a/cadence/tests/rebalance_scenario5_test.cdc +++ b/cadence/tests/rebalance_scenario5_test.cdc @@ -217,71 +217,6 @@ fun test_RebalanceYieldVaultScenario5() { // Try to close - EXPECT IT TO FAIL due to precision residual log("\n[Scenario5] Attempting to close yield vault...") - // log("⚠️ NOTE: Close expected to fail due to precision residual at high collateral values") - let closeResult = executeTransaction( - "../transactions/flow-yield-vaults/close_yield_vault.cdc", - [yieldVaultIDs![0]], - user - ) - - Test.expect(closeResult, Test.beSucceeded()) - // if closeResult.status == Test.ResultStatus.failed { - // log("\n❌ Close FAILED as expected!") - // log(" Error: Post-withdrawal position health dropped to 0") - // log(" This is the PRECISION RESIDUAL issue at close") - // log("") - // log(" Why it fails:") - // log(" - Before close: health = 1.30") - // log(" - During close: tries to withdraw ALL \(collateralAfterYTRise) FLOW") - // log(" - Precision mismatch leaves tiny residual (~10⁻⁶ FLOW)") - // log(" - Health check: remaining_collateral / remaining_debt ≈ 0") - // log(" - Assertion fails: postHealth (0.0) < 1.0") - // log("") - // log(" This is NOT a price drop issue - it's a close precision issue!") - // } else { - // log("\n✅ Close succeeded (residual was small enough)") - // } - // - // log("\n[Scenario5] ===== TEST SUMMARY =====") - // log("Initial health (FLOW=$1000): \(initialHealth)") - // log("Health after 20% drop (FLOW=$800, BEFORE rebalance): \(healthBeforeRebalance)") - // log("Health after rebalance: \(healthAfterRebalance)") - // log("Health after YT rise: \(healthAfterYTRise)") - // log("") - // log("===== KEY FINDINGS =====") - // log("") - // log("1. PRICE DROP BEHAVIOR:") - // log(" - Initial health: 1.30 (at targetHealth)") - // log(" - After -20% drop: 1.04 (still ABOVE 1.0!)") - // log(" - Health does NOT drop below 1.0 during price movement") - // log(" - Rebalancing correctly restores health to 1.30") - // log("") - // log("2. CLOSE BEHAVIOR:") - // log(" - Health before close: 1.30 ✓") - // log(" - Health during close: 0.0 ❌") - // log(" - Close FAILS due to precision residual") - // log("") - // log("3. ROOT CAUSE:") - // log(" - NOT a price drop problem (health stayed > 1.0)") - // log(" - IS a precision mismatch at close") - // log(" - availableBalance estimate ≠ actual withdrawal execution") - // log(" - High collateral values → larger absolute epsilon (~0.005 MOET)") - // log(" - Tiny residual causes health check to fail") - // log("") - // log("4. CONCLUSION:") - // log(" - Position health never drops below 1.0 during normal operation") - // log(" - Failure happens at CLOSE due to precision residual") - // log(" - Affects high-value collateral ($800-$1000/unit)") - // log(" - Requires protocol-level fix for production") - // log("") - // log("[Scenario5] Test complete") - // log("================================================================================") - // - // // Test passes if close failed with expected error - // if closeResult.status == Test.ResultStatus.failed { - // let errorMsg = closeResult.error?.message ?? "" - // let hasHealthError = errorMsg.contains("Post-withdrawal position health") && errorMsg.contains("unhealthy") - // Test.assert(hasHealthError, message: "Expected close to fail with health error, got: \(errorMsg)") - // } + closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) }