From 98cee8de1763a5e5e463d14de3074e4ffb004d3b Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Tue, 31 Mar 2026 09:24:50 -0700 Subject: [PATCH 01/13] queued deposits: withdraw from queued deposits first Co-Authored-By: Claude Sonnet 4.6 --- cadence/contracts/FlowALPv0.cdc | 63 ++-- .../withdraw_from_queued_deposits_test.cdc | 318 ++++++++++++++++++ 2 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 cadence/tests/withdraw_from_queued_deposits_test.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 1a8c70b2..b5f0a6a7 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1207,6 +1207,13 @@ access(all) contract FlowALPv0 { // Global interest indices are updated via tokenState() helper + // Queued deposits are held in the position but have not yet been credited to the reserve + // or the position's balance. Satisfy as much of the withdrawal as possible from them + // first; only the remainder needs to come from (and affect) the reserve. + let queuedBalanceForType: UFix64 = position.getQueuedDepositBalance(type) ?? 0.0 + let queuedUsable = queuedBalanceForType < amount ? queuedBalanceForType : amount + let reserveWithdrawAmount: UFix64 = amount - queuedUsable + // Preflight to see if the funds are available let topUpSource = position.borrowTopUpSource() let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() @@ -1216,7 +1223,7 @@ access(all) contract FlowALPv0 { depositType: topUpType, targetHealth: position.getMinHealth(), withdrawType: type, - withdrawAmount: amount + withdrawAmount: reserveWithdrawAmount ) var canWithdraw = false @@ -1233,7 +1240,7 @@ access(all) contract FlowALPv0 { depositType: topUpType, targetHealth: position.getTargetHealth(), withdrawType: type, - withdrawAmount: amount + withdrawAmount: reserveWithdrawAmount ) let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit) @@ -1277,25 +1284,43 @@ access(all) contract FlowALPv0 { panic("Cannot withdraw \(amount) of \(type.identifier) from position ID \(pid) - Insufficient funds for withdrawal") } - // If this position doesn't currently have an entry for this token, create one. - if position.getBalance(type) == nil { - position.setBalance(type, FlowALPModels.InternalBalance( - direction: FlowALPModels.BalanceDirection.Credit, - scaledBalance: 0.0 - )) + // Pull any queued (un-credited) deposits for this token type first. + // These tokens are held in the position and have never entered the reserve, + // so they can be returned directly with no balance or reserve accounting. + var withdrawn <- DeFiActionsUtils.getEmptyVault(type) + if queuedUsable > 0.0 { + let fullQueuedVault <- position.removeQueuedDeposit(type)! + if fullQueuedVault.balance > queuedUsable { + // We only need part of the queued deposit; re-queue the remainder. + let excess <- fullQueuedVault.withdraw(amount: fullQueuedVault.balance - queuedUsable) + position.depositToQueue(type, vault: <-excess) + } + withdrawn.deposit(from: <-fullQueuedVault) } - let reserveVault = self.state.borrowReserve(type)! + // Withdraw the remaining amount from the reserve and reflect it in the position's balance. + if reserveWithdrawAmount > 0.0 { + // If this position doesn't currently have an entry for this token, create one. + if position.getBalance(type) == nil { + position.setBalance(type, FlowALPModels.InternalBalance( + direction: FlowALPModels.BalanceDirection.Credit, + scaledBalance: 0.0 + )) + } - // Reflect the withdrawal in the position's balance - let uintAmount = UFix128(amount) - position.borrowBalance(type)!.recordWithdrawal( - amount: uintAmount, - tokenState: tokenState - ) - // Attempt to pull additional collateral from the top-up source (if configured) - // to keep the position above minHealth after the withdrawal. - // Regardless of whether a top-up occurs, the position must be healthy post-withdrawal. + let reserveVault = self.state.borrowReserve(type)! + + // Reflect the withdrawal in the position's balance + position.borrowBalance(type)!.recordWithdrawal( + amount: UFix128(reserveWithdrawAmount), + tokenState: tokenState + ) + + let fromReserve <- reserveVault.withdraw(amount: reserveWithdrawAmount) + withdrawn.deposit(from: <-fromReserve) + } + + // Regardless of whether a top-up occurred, the position must be healthy post-withdrawal. let postHealth = self.positionHealth(pid: pid) assert( postHealth >= 1.0, @@ -1317,8 +1342,6 @@ access(all) contract FlowALPv0 { // Queue for update if necessary self._queuePositionForUpdateIfNecessary(pid: pid) - let withdrawn <- reserveVault.withdraw(amount: amount) - FlowALPEvents.emitWithdrawn( pid: pid, poolUUID: self.uuid, diff --git a/cadence/tests/withdraw_from_queued_deposits_test.cdc b/cadence/tests/withdraw_from_queued_deposits_test.cdc new file mode 100644 index 00000000..4fc352eb --- /dev/null +++ b/cadence/tests/withdraw_from_queued_deposits_test.cdc @@ -0,0 +1,318 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "FlowALPModels" +import "test_helpers.cdc" + +// Tests that withdrawAndPull pulls from queued (un-credited) deposits before +// touching the position's reserve balance. + +access(all) var snapshot: UInt64 = 0 + +/// Shared pool setup: FLOW token with a small capacity cap (100) and a 50% +/// per-user limit fraction (user limit = 50). Creating a position with 50 FLOW +/// therefore exhausts the user's allowance, so any subsequent deposit lands +/// entirely in the queue rather than the reserve. +access(all) +fun setup() { + deployContracts() + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 100.0, + depositCapacityCap: 100.0 + ) + setDepositLimitFraction( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + fraction: 0.5 // user cap = 50 FLOW + ) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +/// Helper: create a fresh user with 10 000 FLOW, open a 50-FLOW position +/// (exhausting the per-user deposit limit), then queue `queueAmount` FLOW. +/// Returns the user account and the position ID. +access(all) +fun setupPositionWithQueue(queueAmount: UFix64): Test.TestAccount { + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // 50 FLOW accepted into reserve; user is now at the per-user deposit limit. + createPosition( + admin: PROTOCOL_ACCOUNT, + signer: user, + amount: 50.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + // All of queueAmount is queued (user is already at limit). + depositToPosition( + signer: user, + positionID: 0, + amount: queueAmount, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + return user +} + +// ----------------------------------------------------------------------------- +// Test 1: Withdrawal entirely from the queue +// When the requested amount is ≤ the queued balance, no reserve tokens should +// be touched: the position's credited balance is unchanged, the reserve balance +// is unchanged, and only the queue shrinks. +// ----------------------------------------------------------------------------- +access(all) +fun test_withdraw_fully_from_queue() { + safeReset() + + let user = setupPositionWithQueue(queueAmount: 100.0) + let flowType = CompositeType(FLOW_TOKEN_IDENTIFIER)! + let pid: UInt64 = 0 + + // Sanity-check the setup. + var queued = getQueuedDeposits(pid: pid, beFailed: false) + Test.assert( + equalWithinVariance(100.0, queued[flowType]!, DEFAULT_UFIX_VARIANCE), + message: "Expected 100 FLOW queued before withdrawal" + ) + let reserveBefore = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + let creditBefore = getCreditBalanceForType( + details: getPositionDetails(pid: pid, beFailed: false), + vaultType: flowType + ) + + // Withdraw 60 FLOW — less than the 100 in the queue. + let userFlowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + withdrawFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 60.0, + pullFromTopUpSource: false + ) + + // User received exactly 60 FLOW. + let userFlowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + Test.assert( + equalWithinVariance(userFlowBefore + 60.0, userFlowAfter, DEFAULT_UFIX_VARIANCE), + message: "User should have received 60 FLOW from the queue" + ) + + // Queue shrank by 60 (40 remain). + queued = getQueuedDeposits(pid: pid, beFailed: false) + Test.assert( + equalWithinVariance(40.0, queued[flowType]!, DEFAULT_UFIX_VARIANCE), + message: "Queue should hold 40 FLOW after withdrawing 60 from it" + ) + + // Reserve balance is unchanged — no tokens left the reserve. + let reserveAfter = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + Test.assert( + equalWithinVariance(reserveBefore, reserveAfter, DEFAULT_UFIX_VARIANCE), + message: "Reserve balance should not change when withdrawing from the queue" + ) + + // Position credit balance is unchanged. + let creditAfter = getCreditBalanceForType( + details: getPositionDetails(pid: pid, beFailed: false), + vaultType: flowType + ) + Test.assert( + equalWithinVariance(creditBefore, creditAfter, DEFAULT_UFIX_VARIANCE), + message: "Position credit balance should not change when withdrawing from the queue" + ) +} + +// ----------------------------------------------------------------------------- +// Test 2: Withdrawal exhausts the queue, remainder comes from the reserve +// When the requested amount exceeds the queued balance, the queue is drained +// first and the shortfall is taken from the reserve. +// ----------------------------------------------------------------------------- +access(all) +fun test_withdraw_drains_queue_then_reserve() { + safeReset() + + let user = setupPositionWithQueue(queueAmount: 100.0) + let flowType = CompositeType(FLOW_TOKEN_IDENTIFIER)! + let pid: UInt64 = 0 + + // Withdraw 130 FLOW: 100 from the queue, 30 from the reserve. + let reserveBefore = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + let creditBefore = getCreditBalanceForType( + details: getPositionDetails(pid: pid, beFailed: false), + vaultType: flowType + ) + let userFlowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + withdrawFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 130.0, + pullFromTopUpSource: false + ) + + // User received 130 FLOW in total. + let userFlowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + Test.assert( + equalWithinVariance(userFlowBefore + 130.0, userFlowAfter, DEFAULT_UFIX_VARIANCE), + message: "User should have received 130 FLOW total" + ) + + // Queue is now empty. + let queued = getQueuedDeposits(pid: pid, beFailed: false) + Test.assertEqual(UInt64(0), UInt64(queued.length)) + + // Reserve decreased by only 30 (the part that wasn't covered by the queue). + let reserveAfter = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + Test.assert( + equalWithinVariance(reserveBefore - 30.0, reserveAfter, DEFAULT_UFIX_VARIANCE), + message: "Reserve should decrease by 30 (the non-queued portion)" + ) + + // Position credit balance decreased by 30 only (queue portion had no credit entry). + let creditAfter = getCreditBalanceForType( + details: getPositionDetails(pid: pid, beFailed: false), + vaultType: flowType + ) + Test.assert( + equalWithinVariance(creditBefore - 30.0, creditAfter, DEFAULT_UFIX_VARIANCE), + message: "Position credit balance should decrease by the reserve portion only (30)" + ) +} + +// ----------------------------------------------------------------------------- +// Test 3: Withdrawal exactly equal to the queued balance +// The queue is drained exactly; the reserve is not touched. +// ----------------------------------------------------------------------------- +access(all) +fun test_withdraw_exactly_queue_balance() { + safeReset() + + let user = setupPositionWithQueue(queueAmount: 80.0) + let flowType = CompositeType(FLOW_TOKEN_IDENTIFIER)! + let pid: UInt64 = 0 + + let reserveBefore = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + let userFlowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + // Withdraw exactly the queued amount. + withdrawFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 80.0, + pullFromTopUpSource: false + ) + + let userFlowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + Test.assert( + equalWithinVariance(userFlowBefore + 80.0, userFlowAfter, DEFAULT_UFIX_VARIANCE), + message: "User should have received exactly 80 FLOW" + ) + + // Queue is empty. + let queued = getQueuedDeposits(pid: pid, beFailed: false) + Test.assertEqual(UInt64(0), UInt64(queued.length)) + + // Reserve is untouched. + let reserveAfter = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + Test.assert( + equalWithinVariance(reserveBefore, reserveAfter, DEFAULT_UFIX_VARIANCE), + message: "Reserve should be unchanged when withdrawal matches the queued balance exactly" + ) +} + +// ----------------------------------------------------------------------------- +// Test 4: Normal withdrawal when no queue exists +// Verifies that the existing reserve-only path is unaffected by the change. +// ----------------------------------------------------------------------------- +access(all) +fun test_withdraw_no_queue_uses_reserve() { + safeReset() + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user) + + // Deposit 50 FLOW (at user limit) — no queue. + createPosition( + admin: PROTOCOL_ACCOUNT, + signer: user, + amount: 50.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + let pid: UInt64 = 0 + let flowType = CompositeType(FLOW_TOKEN_IDENTIFIER)! + + // Confirm no queue. + let queuedBefore = getQueuedDeposits(pid: pid, beFailed: false) + Test.assertEqual(UInt64(0), UInt64(queuedBefore.length)) + + let reserveBefore = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + let creditBefore = getCreditBalanceForType( + details: getPositionDetails(pid: pid, beFailed: false), + vaultType: flowType + ) + let userFlowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + withdrawFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 20.0, + pullFromTopUpSource: false + ) + + // User received 20 FLOW. + let userFlowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + Test.assert( + equalWithinVariance(userFlowBefore + 20.0, userFlowAfter, DEFAULT_UFIX_VARIANCE), + message: "User should have received 20 FLOW from the reserve" + ) + + // Reserve decreased by 20. + let reserveAfter = getReserveBalance(vaultIdentifier: FLOW_TOKEN_IDENTIFIER) + Test.assert( + equalWithinVariance(reserveBefore - 20.0, reserveAfter, DEFAULT_UFIX_VARIANCE), + message: "Reserve should decrease by the full 20 when there is no queue" + ) + + // Position credit decreased by 20. + let creditAfter = getCreditBalanceForType( + details: getPositionDetails(pid: pid, beFailed: false), + vaultType: flowType + ) + Test.assert( + equalWithinVariance(creditBefore - 20.0, creditAfter, DEFAULT_UFIX_VARIANCE), + message: "Position credit balance should decrease by 20 when there is no queue" + ) + + // Queue remains empty. + let queuedAfter = getQueuedDeposits(pid: pid, beFailed: false) + Test.assertEqual(UInt64(0), UInt64(queuedAfter.length)) +} From 029a7fdf5181c4f998d1b89bf5f3c4bfff870c9c Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Wed, 1 Apr 2026 17:03:06 -0700 Subject: [PATCH 02/13] queued deposits: prevent liquidation and unnecessary rebalancing Co-Authored-By: Claude Sonnet 4.6 --- cadence/contracts/FlowALPv0.cdc | 53 +++- cadence/tests/queued_deposits_health_test.cdc | 296 ++++++++++++++++++ 2 files changed, 335 insertions(+), 14 deletions(-) create mode 100644 cadence/tests/queued_deposits_health_test.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index b5f0a6a7..7e2c22b5 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -311,9 +311,10 @@ access(all) contract FlowALPv0 { } } - /// Returns true if the position is under the global liquidation trigger (health < 1.0) + /// Returns true if the position is under the global liquidation trigger (health < 1.0), + /// accounting for any queued deposits that would, once credited, improve the health factor. access(all) fun isLiquidatable(pid: UInt64): Bool { - let health = self.positionHealth(pid: pid) + let health = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health return health < 1.0 } @@ -544,7 +545,7 @@ access(all) contract FlowALPv0 { let positionView = self.buildPositionView(pid: pid) let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) - let initialHealth = balanceSheet.health + let initialHealth = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health assert(initialHealth < 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>=1") // Ensure liquidation amounts don't exceed position amounts @@ -1681,19 +1682,21 @@ access(all) contract FlowALPv0 { } let position = self._borrowPosition(pid: pid) let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let effectiveBalanceSheet = self._getBalanceSheetIncludingQueuedDeposits(pid: pid) - if !force && (position.getMinHealth() <= balanceSheet.health && balanceSheet.health <= position.getMaxHealth()) { + if !force && (position.getMinHealth() <= effectiveBalanceSheet.health && effectiveBalanceSheet.health <= position.getMaxHealth()) { // We aren't forcing the update, and the position is already between its desired min and max. Nothing to do! return } - if balanceSheet.health < position.getTargetHealth() { + if effectiveBalanceSheet.health < position.getTargetHealth() { // The position is undercollateralized, // see if the source can get more collateral to bring it up to the target health. if let topUpSource = position.borrowTopUpSource() { - let idealDeposit = self.fundsRequiredForTargetHealth( - pid: pid, - type: topUpSource.getSourceType(), + let idealDeposit = self.computeRequiredDepositForHealth( + position: position, + depositType: topUpSource.getSourceType(), + initialBalanceSheet: effectiveBalanceSheet, targetHealth: position.getTargetHealth() ) if self.config.isDebugLogging() { @@ -1718,10 +1721,10 @@ access(all) contract FlowALPv0 { ) // Post-deposit health check: panic if the position is still liquidatable. - let newBalanceSheet = self._getUpdatedBalanceSheet(pid: pid) - assert(newBalanceSheet.health >= 1.0, message: "topUpSource insufficient to save position from liquidation") + let newEffectiveHealth = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health + assert(newEffectiveHealth >= 1.0, message: "topUpSource insufficient to save position from liquidation") } - } else if balanceSheet.health > position.getTargetHealth() { + } else if effectiveBalanceSheet.health > position.getTargetHealth() { // The position is overcollateralized, // we'll withdraw funds to match the target health and offer it to the sink. if self.isPausedOrWarmup() { @@ -1730,9 +1733,10 @@ access(all) contract FlowALPv0 { } if let drawDownSink = position.borrowDrawDownSink() { let sinkType = drawDownSink.getSinkType() - let idealWithdrawal = self.fundsAvailableAboveTargetHealth( - pid: pid, - type: sinkType, + let idealWithdrawal = self.computeAvailableWithdrawal( + position: position, + withdrawType: sinkType, + initialBalanceSheet: effectiveBalanceSheet, targetHealth: position.getTargetHealth() ) if self.config.isDebugLogging() { @@ -2006,6 +2010,7 @@ access(all) contract FlowALPv0 { return } + // If there are no queued deposits, we don't need to take them into account when calculating health let positionHealth = self.positionHealth(pid: pid) if positionHealth < position.getMinHealth() || positionHealth > position.getMaxHealth() { @@ -2055,6 +2060,26 @@ access(all) contract FlowALPv0 { ) } + /// Returns a balance sheet that accounts for all queued deposits as if they had already been processed. + /// Used to determine whether rebalancing or liquidation is appropriate when pending deposits + /// would, once credited, bring the position to a safe health factor. + access(self) fun _getBalanceSheetIncludingQueuedDeposits(pid: UInt64): FlowALPModels.BalanceSheet { + let position = self._borrowPosition(pid: pid) + var balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + + for depositType in position.getQueuedDepositKeys() { + let queuedAmount = position.getQueuedDepositBalance(depositType)! + balanceSheet = self.computeAdjustedBalancesAfterDeposit( + initialBalanceSheet: balanceSheet, + position: position, + depositType: depositType, + depositAmount: queuedAmount + ) + } + + return balanceSheet + } + /// A convenience function that returns a reference to a particular token state, making sure it's up-to-date for /// the passage of time. This should always be used when accessing a token state to avoid missing interest /// updates (duplicate calls to updateForTimeChange() are a nop within a single block). diff --git a/cadence/tests/queued_deposits_health_test.cdc b/cadence/tests/queued_deposits_health_test.cdc new file mode 100644 index 00000000..97784e66 --- /dev/null +++ b/cadence/tests/queued_deposits_health_test.cdc @@ -0,0 +1,296 @@ +import Test +import BlockchainHelpers + +import "MOET" +import "test_helpers.cdc" + +// Tests that queued deposits are counted when determining whether a position is +// liquidatable or needs rebalancing. + +access(all) var snapshot: UInt64 = 0 + +// Pool setup: +// - FLOW: cf=0.8, bf=1.0, depositCapacityCap=1000, depositRate=1.0/s, limitFraction=1.0 +// This means one position can deposit up to 1000 FLOW before the cap is hit. +// A second deposit lands entirely in the queue until capacity regenerates. +// - DEX: FLOW→MOET at 0.7 (used by liquidation tests) +access(all) +fun setup() { + deployContracts() + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1.0, // 1 FLOW/s regeneration — not advanced in tests + depositCapacityCap: 1000.0 + ) + setDepositLimitFraction( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + fraction: 1.0 + ) + setMockDexPriceForPair( + signer: PROTOCOL_ACCOUNT, + inVaultIdentifier: FLOW_TOKEN_IDENTIFIER, + outVaultIdentifier: MOET_TOKEN_IDENTIFIER, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: 0.7 + ) + snapshot = getCurrentBlockHeight() +} + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +// Helper: create a user with FLOW, open a 1000-FLOW position (filling the deposit cap) +// with pushToDrawDownSink: true so the pool draws down MOET debt. +// Returns the user account. The position ID is always 0. +access(all) +fun setupPositionWithDebt(): Test.TestAccount { + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 10_000.0) + createPosition( + admin: PROTOCOL_ACCOUNT, + signer: user, + amount: 1000.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: true + ) + return user +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 1: Liquidation is blocked when a queued deposit would restore health ≥ 1.0 +// +// With: 1000 FLOW @ $0.70, cf=0.8 → effectiveCollateral = 560 +// MOET debt ≈ 615.38 (drawn at initial price 1.0, targetHealth 1.3) +// Reserve health = 560 / 615.38 ≈ 0.91 (< 1.0 → normally liquidatable) +// +// Queuing 200 FLOW @ $0.70, cf=0.8 adds 112 to effectiveCollateral: +// Effective health = 672 / 615.38 ≈ 1.09 (≥ 1.0 → not liquidatable) +// ───────────────────────────────────────────────────────────────────────────── +access(all) +fun test_liquidation_blocked_by_queued_deposit() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + + // Drop FLOW price so reserve health < 1.0. + let crashedPrice: UFix64 = 0.7 + setMockOraclePrice( + signer: Test.getAccount(0x0000000000000007), + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: crashedPrice + ) + setMockDexPriceForPair( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: FLOW_TOKEN_IDENTIFIER, + outVaultIdentifier: MOET_TOKEN_IDENTIFIER, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: crashedPrice + ) + + // Confirm reserve health is below 1.0. + let reserveHealth = getPositionHealth(pid: pid, beFailed: false) + Test.assert(reserveHealth < 1.0, message: "Expected reserve health < 1.0 after price drop, got \(reserveHealth)") + + // Deposit 200 FLOW into the queue (deposit cap is exhausted, so it cannot enter the reserve). + depositToPosition( + signer: user, + positionID: pid, + amount: 200.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + // Confirm the deposit is queued, not credited. + let queued = getQueuedDeposits(pid: pid, beFailed: false) + let flowType = CompositeType(FLOW_TOKEN_IDENTIFIER)! + Test.assert(queued[flowType] != nil, message: "Expected 200 FLOW to be in the queue") + + // The position should now NOT be liquidatable because the queued deposit + // brings effective health above 1.0. + let liquidatable = getIsLiquidatable(pid: pid) + Test.assert(!liquidatable, message: "Position should not be liquidatable when queued deposit rescues health") + + // A manual liquidation attempt should be rejected. + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + let liqRes = manualLiquidation( + signer: liquidator, + pid: pid, + debtVaultIdentifier: Type<@MOET.Vault>().identifier, + seizeVaultIdentifier: FLOW_TOKEN_IDENTIFIER, + seizeAmount: 10.0, + repayAmount: 7.0 + ) + Test.expect(liqRes, Test.beFailed()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 2: Liquidation is still permitted when the queued deposit is insufficient +// to restore health ≥ 1.0. +// +// Same setup as Test 1, but only 50 FLOW is queued: +// Queued contribution = 50 × 0.7 × 0.8 = 28 +// Effective health = (560 + 28) / 615.38 ≈ 0.96 (< 1.0 → still liquidatable) +// ───────────────────────────────────────────────────────────────────────────── +access(all) +fun test_liquidation_allowed_when_queued_deposit_insufficient() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + + let crashedPrice: UFix64 = 0.7 + setMockOraclePrice( + signer: Test.getAccount(0x0000000000000007), + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: crashedPrice + ) + setMockDexPriceForPair( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: FLOW_TOKEN_IDENTIFIER, + outVaultIdentifier: MOET_TOKEN_IDENTIFIER, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: crashedPrice + ) + + // Queue only 50 FLOW — not enough to rescue the position. + depositToPosition( + signer: user, + positionID: pid, + amount: 50.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + // Even with the queued deposit, effective health is < 1.0. + let liquidatable = getIsLiquidatable(pid: pid) + Test.assert(liquidatable, message: "Position should still be liquidatable when queued deposit is insufficient") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 3: Rebalancing is skipped when queued deposits bring health within bounds. +// +// With: 1000 FLOW @ $0.80, cf=0.8 → effectiveCollateral = 640 +// MOET debt ≈ 615.38 +// Reserve health = 640 / 615.38 ≈ 1.04 (< MIN_HEALTH = 1.1 → would trigger topUp) +// +// Queuing 100 FLOW @ $0.80, cf=0.8 adds 64 to effectiveCollateral: +// Effective health = 704 / 615.38 ≈ 1.14 (within [1.1, 1.5] → no rebalance needed) +// ───────────────────────────────────────────────────────────────────────────── +access(all) +fun test_rebalance_skipped_when_queued_deposit_within_health_bounds() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + let userMoetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + // Drop FLOW price so reserve health falls below MIN_HEALTH (1.1) but not below 1.0. + setMockOraclePrice( + signer: PROTOCOL_ACCOUNT, + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: 0.8 + ) + + let reserveHealth = getPositionHealth(pid: pid, beFailed: false) + Test.assert(reserveHealth < UFix128(MIN_HEALTH), message: "Expected reserve health below MIN_HEALTH, got \(reserveHealth)") + Test.assert(reserveHealth >= 1.0, message: "Reserve health should still be above 1.0 (non-liquidatable), got \(reserveHealth)") + + // Queue 100 FLOW — sufficient to push effective health into [MIN_HEALTH, MAX_HEALTH]. + depositToPosition( + signer: user, + positionID: pid, + amount: 100.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + // With force=false the rebalancer should see effective health within bounds and do nothing. + rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: pid, force: false, beFailed: false) + + // The user's MOET vault should be unchanged — no topUp was pulled. + let userMoetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + Test.assert( + equalWithinVariance(userMoetBefore, userMoetAfter, DEFAULT_UFIX_VARIANCE), + message: "No MOET should have been pulled from the user during rebalance (before: \(userMoetBefore), after: \(userMoetAfter))" + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 4: The topUp amount is reduced to account for a queued deposit, preventing +// over-rebalancing that would require a subsequent drawdown. +// +// With: 1000 FLOW @ $0.60, cf=0.8 → effectiveCollateral = 480 +// MOET debt ≈ 615.38 +// Reserve health ≈ 0.78 (badly unhealthy — topUp required regardless) +// +// Queuing 200 FLOW @ $0.60, cf=0.8 adds 96 to effectiveCollateral: +// Effective health ≈ 0.94 (still below MIN_HEALTH, so rebalance fires) +// +// Ideal topUp based on EFFECTIVE balance sheet: +// debt_after = 576 / 1.3 ≈ 443.08 → topUp ≈ 172.30 MOET +// +// If instead the topUp were based on RESERVE health only (old behaviour): +// debt_after = 480 / 1.3 ≈ 369.23 → topUp ≈ 246.15 MOET +// After queued deposit processes: health ≈ 1.56 (above MAX_HEALTH = 1.5 — needs drawdown!) +// +// We verify the new behaviour: MOET debt after rebalance is ≈ 443, not ≈ 369. +// ───────────────────────────────────────────────────────────────────────────── +access(all) +fun test_rebalance_topup_reduced_by_queued_deposit() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + + // Drop FLOW price sharply so reserve health is well below 1.0. + setMockOraclePrice( + signer: PROTOCOL_ACCOUNT, + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: 0.6 + ) + + // Queue 200 FLOW. Even with it the effective health is below MIN_HEALTH, + // so a rebalance will still be triggered — but the topUp should be sized + // to reach targetHealth *including* the queued deposit's contribution. + depositToPosition( + signer: user, + positionID: pid, + amount: 200.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + let userMoetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: pid, force: true, beFailed: false) + let userMoetAfter = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + let topUpAmount = userMoetBefore - userMoetAfter + + // New behaviour: topUp ≈ 172 MOET (accounts for 200 queued FLOW). + // Old behaviour: topUp ≈ 246 MOET (ignores queued deposit). + // We verify the topUp is substantially less than the old value to confirm + // the queued deposit was taken into account. + let oldBehaviourTopUp: UFix64 = 246.0 + let newBehaviourTopUp: UFix64 = 172.0 + let tolerance: UFix64 = 10.0 + + Test.assert( + equalWithinVariance(newBehaviourTopUp, topUpAmount, tolerance), + message: "TopUp (\(topUpAmount)) should be close to \(newBehaviourTopUp) (accounting for queued deposit), not close to old value \(oldBehaviourTopUp)" + ) +} From f6ad37485870006936112dd0e73d8877d4295998 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 2 Apr 2026 15:12:48 -0700 Subject: [PATCH 03/13] queued deposits: withdrawal of queued deposits uses queued health factor Co-Authored-By: Claude Sonnet 4.6 --- cadence/contracts/FlowALPv0.cdc | 34 ++++-- cadence/tests/queued_deposits_health_test.cdc | 115 ++++++++++++++++++ 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 7e2c22b5..a1a29de1 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1215,16 +1215,25 @@ access(all) contract FlowALPv0 { let queuedUsable = queuedBalanceForType < amount ? queuedBalanceForType : amount let reserveWithdrawAmount: UFix64 = amount - queuedUsable - // Preflight to see if the funds are available + // Preflight to see if the funds are available. + // Use effective health (reserve + queued deposits) so that this check is consistent + // with the liquidation and rebalancing checks, which also use effective health. let topUpSource = position.borrowTopUpSource() let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() - let requiredDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( - pid: pid, - depositType: topUpType, - targetHealth: position.getMinHealth(), + let effectiveBalanceSheet = self._getBalanceSheetIncludingQueuedDeposits(pid: pid) + let effectiveBalanceAfterWithdrawal = self.computeAdjustedBalancesAfterWithdrawal( + initialBalanceSheet: effectiveBalanceSheet, + position: position, withdrawType: type, - withdrawAmount: reserveWithdrawAmount + withdrawAmount: amount + ) + + let requiredDeposit = self.computeRequiredDepositForHealth( + position: position, + depositType: topUpType, + initialBalanceSheet: effectiveBalanceAfterWithdrawal, + targetHealth: position.getMinHealth() ) var canWithdraw = false @@ -1236,12 +1245,11 @@ access(all) contract FlowALPv0 { // We need more funds to service this withdrawal, see if they are available from the top up source if let topUpSource = topUpSource { // If we have to rebalance, let's try to rebalance to the target health, not just the minimum - let idealDeposit = self.fundsRequiredForTargetHealthAfterWithdrawing( - pid: pid, + let idealDeposit = self.computeRequiredDepositForHealth( + position: position, depositType: topUpType, - targetHealth: position.getTargetHealth(), - withdrawType: type, - withdrawAmount: reserveWithdrawAmount + initialBalanceSheet: effectiveBalanceAfterWithdrawal, + targetHealth: position.getTargetHealth() ) let pulledVault <- topUpSource.withdrawAvailable(maxAmount: idealDeposit) @@ -1322,7 +1330,9 @@ access(all) contract FlowALPv0 { } // Regardless of whether a top-up occurred, the position must be healthy post-withdrawal. - let postHealth = self.positionHealth(pid: pid) + // Uses effective health (reserve + remaining queued deposits) for consistency with + // the liquidation and rebalancing checks. + let postHealth = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health assert( postHealth >= 1.0, message: "Post-withdrawal position health (\(postHealth)) is unhealthy" diff --git a/cadence/tests/queued_deposits_health_test.cdc b/cadence/tests/queued_deposits_health_test.cdc index 97784e66..617832b0 100644 --- a/cadence/tests/queued_deposits_health_test.cdc +++ b/cadence/tests/queued_deposits_health_test.cdc @@ -294,3 +294,118 @@ fun test_rebalance_topup_reduced_by_queued_deposit() { message: "TopUp (\(topUpAmount)) should be close to \(newBehaviourTopUp) (accounting for queued deposit), not close to old value \(oldBehaviourTopUp)" ) } + +// ───────────────────────────────────────────────────────────────────────────── +// Test 5: Withdrawal from the queue is permitted when reserve health < minHealth +// but effective health (reserve + remaining queue) >= minHealth. +// +// With: 1000 FLOW @ $0.75, cf=0.8 → reserve effectiveCollateral = 600 +// MOET debt ≈ 615.38 +// Reserve health ≈ 0.975 (< 1.0 — below liquidation threshold) +// +// Queue 200 FLOW → effective health = (1000+200)*0.75*0.8 / 615.38 ≈ 1.17 +// +// Withdraw 50 FLOW from the queue (reserveWithdrawAmount = 0): +// Effective health after = (1000+150)*0.75*0.8 / 615.38 ≈ 1.12 >= minHealth(1.1) ✓ +// +// Old behaviour: the preflight used reserve health (0.975 < minHealth) and blocked +// this withdrawal even though no reserve tokens were touched. +// New behaviour: the preflight uses effective health and permits it. +// ───────────────────────────────────────────────────────────────────────────── +access(all) +fun test_withdrawal_from_queue_permitted_when_reserve_health_below_min() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + + // Drop FLOW price so that reserve health falls below 1.0. + setMockOraclePrice( + signer: PROTOCOL_ACCOUNT, + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: 0.75 + ) + + // Confirm reserve health < 1.0. + let reserveHealth = getPositionHealth(pid: pid, beFailed: false) + Test.assert(reserveHealth < 1.0, message: "Expected reserve health < 1.0, got \(reserveHealth)") + + // Queue 200 FLOW — brings effective health to ≈ 1.17, well above minHealth(1.1). + depositToPosition( + signer: user, + positionID: pid, + amount: 200.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + let userFlowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + + // Withdraw 50 FLOW — entirely from the queue (reserveWithdrawAmount = 0). + // Effective health after ≈ 1.12 >= minHealth, so this should succeed. + withdrawFromPosition( + signer: user, + positionId: pid, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + amount: 50.0, + pullFromTopUpSource: false + ) + + let userFlowAfter = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! + Test.assert( + equalWithinVariance(userFlowBefore + 50.0, userFlowAfter, DEFAULT_UFIX_VARIANCE), + message: "User should have received 50 FLOW from the queue" + ) + + // Remaining queue should be 150. + let queued = getQueuedDeposits(pid: pid, beFailed: false) + let flowType = CompositeType(FLOW_TOKEN_IDENTIFIER)! + Test.assert( + equalWithinVariance(150.0, queued[flowType]!, DEFAULT_UFIX_VARIANCE), + message: "Queue should hold 150 FLOW after withdrawing 50" + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 6: Withdrawal is rejected when it would drop effective health below 1.0, +// even if part of it comes from the queue. +// +// With: 1000 FLOW @ $0.75, cf=0.8 → reserve effectiveCollateral = 600 +// MOET debt ≈ 615.38, reserve health ≈ 0.975 +// Queue 100 FLOW → effective health = 1100*0.75*0.8/615.38 ≈ 1.07 +// +// Withdraw 200 FLOW (100 from queue, 100 from reserve): +// Effective credit after = (1000-100) + (100-100) = 900 +// effectiveCollateral = 900*0.75*0.8 = 540 < 615.38 → health ≈ 0.88 < 1.0 → rejected +// ───────────────────────────────────────────────────────────────────────────── +access(all) +fun test_withdrawal_rejected_when_effective_health_would_drop_below_one() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + + setMockOraclePrice( + signer: PROTOCOL_ACCOUNT, + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: 0.75 + ) + + // Queue 100 FLOW. + depositToPosition( + signer: user, + positionID: pid, + amount: 100.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + // Attempt to withdraw 200 FLOW (drains queue and takes 100 from reserve). + // Effective health after ≈ 0.88 < 1.0 — should be rejected. + let res = _executeTransaction( + "./transactions/position-manager/withdraw_from_position.cdc", + [pid, FLOW_TOKEN_IDENTIFIER, 200.0, false], + user + ) + Test.expect(res, Test.beFailed()) +} From 6d943ed4f7060b647c710c6fd623bb26769712d4 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Fri, 3 Apr 2026 09:04:20 -0700 Subject: [PATCH 04/13] queued deposits: cannot be borrowed against Co-Authored-By: Claude Sonnet 4.6 --- cadence/tests/queued_deposits_health_test.cdc | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/cadence/tests/queued_deposits_health_test.cdc b/cadence/tests/queued_deposits_health_test.cdc index 617832b0..082fe3aa 100644 --- a/cadence/tests/queued_deposits_health_test.cdc +++ b/cadence/tests/queued_deposits_health_test.cdc @@ -367,7 +367,54 @@ fun test_withdrawal_from_queue_permitted_when_reserve_health_below_min() { } // ───────────────────────────────────────────────────────────────────────────── -// Test 6: Withdrawal is rejected when it would drop effective health below 1.0, +// Test 6: Cross-type borrow against queued collateral is blocked. +// +// A queued FLOW deposit should not increase borrowing capacity for MOET. +// Only reserve FLOW should govern how much MOET can be withdrawn. +// +// With: 1000 FLOW reserve @ $1.0, cf=0.8, bf=1.0 +// MOET debt ≈ 615.38 (drawn at position creation, targetHealth=1.3) +// Reserve health = 1000*1.0*0.8 / 615.38 ≈ 1.3 (at target) +// availableBalance(MOET) ≈ 0 (already at target health) +// +// Queue 500 FLOW (deposit cap exhausted, goes to queue). +// A cross-type borrow would incorrectly increase MOET borrowing capacity. +// The fix: queued FLOW provides no additional MOET borrow capacity. +// Attempting to withdraw any additional MOET beyond the reserve allowance must fail. +// ───────────────────────────────────────────────────────────────────────────── +access(all) +fun test_queued_collateral_does_not_enable_cross_type_borrow() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + + // Queue 500 FLOW (capacity is exhausted so it goes into the queue, not the reserve). + depositToPosition( + signer: user, + positionID: pid, + amount: 500.0, + vaultStoragePath: FLOW_VAULT_STORAGE_PATH, + pushToDrawDownSink: false + ) + + let queued = getQueuedDeposits(pid: pid, beFailed: false) + let flowType = CompositeType(FLOW_TOKEN_IDENTIFIER)! + Test.assert(queued[flowType] != nil, message: "Expected 500 FLOW to be queued") + + // Position is at targetHealth (1.3) with reserve FLOW only. No MOET should be available. + // A cross-type borrow would try to use queued FLOW to support additional MOET withdrawal. + // This must fail. + let res = _executeTransaction( + "./transactions/position-manager/withdraw_from_position.cdc", + [pid, MOET_TOKEN_IDENTIFIER, 100.0, false], + user + ) + Test.expect(res, Test.beFailed()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test 8: Withdrawal is rejected when it would drop effective health below 1.0, // even if part of it comes from the queue. // // With: 1000 FLOW @ $0.75, cf=0.8 → reserve effectiveCollateral = 600 From bb1ba3838a0d1bf7a791ee718e9cd8ae4eba2329 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Fri, 3 Apr 2026 09:04:32 -0700 Subject: [PATCH 05/13] queued deposits: documentation and naming Co-Authored-By: Claude Sonnet 4.6 --- cadence/contracts/FlowALPv0.cdc | 160 +++++++++++++++++++++----------- 1 file changed, 106 insertions(+), 54 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index a1a29de1..dd681a88 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -70,7 +70,7 @@ access(all) contract FlowALPv0 { return 0.0 } - // TODO: this logic partly duplicates FlowALPModels.BalanceSheet construction in _getUpdatedBalanceSheet + // TODO: this logic partly duplicates FlowALPModels.BalanceSheet construction in _getReserveBalanceSheet // This function differs in that it does not read any data from a Pool resource. Consider consolidating the two implementations. var effectiveCollateralTotal: UFix128 = 0.0 var effectiveDebtTotal: UFix128 = 0.0 @@ -141,6 +141,28 @@ access(all) contract FlowALPv0 { /// /// A Pool is the primary logic for protocol operations. It contains the global state of all positions, /// credit and debit balances for each supported token type, and reserves as they are deposited to positions. + /// + /// ## Health factors + /// + /// The Pool uses two distinct health factor concepts throughout its logic: + /// + /// - **Reserve health**: computed from balances that have been credited to the reserve only. + /// This is what `positionHealth()` and `_getReserveBalanceSheet()` return. + /// It is used for: available-balance queries, borrow-capacity checks, and the + /// pre/post-liquidation arithmetic (Ce_pre, De_pre). + /// + /// - **Queued health**: computed as if all queued deposits had already been credited to the + /// reserve. This is what `_getQueuedBalanceSheet()` returns. + /// It is used for: liquidation eligibility (`isLiquidatable`), rebalancing trigger/sizing, + /// and the post-withdrawal safety assertion. + /// Rationale: a deposit that is queued (pending capacity) is already in the protocol's + /// custody and committed to the position — it protects against liquidation and avoids + /// unnecessary rebalancing, but it does not yet support borrowing against other tokens. + /// + /// The withdrawal preflight (`_getWithdrawalBalanceSheet`) is a hybrid: it starts from the + /// reserve balance sheet but adds the queued deposit for the *withdrawn token only*, so that + /// withdrawing same-type queued funds is gated on the remaining queued + reserve health for + /// that token, while cross-type borrowing against uncredited collateral remains blocked. access(all) resource Pool: FlowALPModels.PositionPool { /// Pool state (extracted fields) @@ -311,10 +333,11 @@ access(all) contract FlowALPv0 { } } - /// Returns true if the position is under the global liquidation trigger (health < 1.0), - /// accounting for any queued deposits that would, once credited, improve the health factor. + /// Returns true if the position's **queued health** is below the liquidation trigger (< 1.0). + /// Queued deposits are included because they are already in the protocol's custody — a position + /// that would be safe once its pending deposits are credited should not be liquidatable. access(all) fun isLiquidatable(pid: UInt64): Bool { - let health = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health + let health = self._getQueuedBalanceSheet(pid: pid).health return health < 1.0 } @@ -434,12 +457,16 @@ access(all) contract FlowALPv0 { return FlowALPMath.toUFix64Round(uintMax) } - /// Returns the health of the given position, which is the ratio of the position's effective collateral - /// to its debt as denominated in the Pool's default token. - /// "Effective collateral" means the value of each credit balance times the liquidation threshold - /// for that token, i.e. the maximum borrowable amount + /// Returns the **reserve health** of the given position: the ratio of effective collateral to + /// effective debt, computed from credited reserve balances only (queued deposits excluded). + /// "Effective collateral" is each credit balance times its collateral factor; "effective debt" + /// is each debit balance divided by its borrow factor. Both are denominated in the default token. + /// + /// This is the appropriate value for borrow-capacity and available-balance queries. Use + /// `_getQueuedBalanceSheet` for liquidation and rebalancing decisions. + // TODO: make this output enumeration of effective debts/collaterals (or provide option that does) access(all) fun positionHealth(pid: UInt64): UFix128 { - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let balanceSheet = self._getReserveBalanceSheet(pid: pid) return balanceSheet.health } @@ -544,8 +571,11 @@ access(all) contract FlowALPv0 { self.lockPosition(pid) let positionView = self.buildPositionView(pid: pid) - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) - let initialHealth = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health + // Reserve balance sheet used for Ce_pre/De_pre (liquidation seizes reserve collateral only). + let reserveBalanceSheet = self._getReserveBalanceSheet(pid: pid) + // Queued health used for the eligibility check: a pending deposit that would rescue the + // position should prevent liquidation. + let initialHealth = self._getQueuedBalanceSheet(pid: pid).health assert(initialHealth < 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>=1") // Ensure liquidation amounts don't exceed position amounts @@ -562,9 +592,10 @@ access(all) contract FlowALPv0 { // Oracle says: "1 unit of collateral is worth `Pcd_oracle` units of debt" let Pcd_oracle = Pc_oracle / Pd_oracle - // Compute the health factor which would result if we were to accept this liquidation - let Ce_pre = balanceSheet.effectiveCollateral // effective collateral pre-liquidation - let De_pre = balanceSheet.effectiveDebt // effective debt pre-liquidation + // Compute the health factor which would result if we were to accept this liquidation. + // Uses reserve balance sheet: liquidation seizes reserve collateral, not queued deposits. + let Ce_pre = reserveBalanceSheet.effectiveCollateral // effective collateral pre-liquidation + let De_pre = reserveBalanceSheet.effectiveDebt // effective debt pre-liquidation let Fc = positionView.snapshots[seizeType]!.getRisk().getCollateralFactor() let Fd = positionView.snapshots[debtType]!.getRisk().getBorrowFactor() @@ -672,7 +703,7 @@ access(all) contract FlowALPv0 { log(" [CONTRACT] fundsRequiredForTargetHealthAfterWithdrawing(pid: \(pid), depositType: \(depositType.contractName!), targetHealth: \(targetHealth), withdrawType: \(withdrawType.contractName!), withdrawAmount: \(withdrawAmount))") } - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let balanceSheet = self._getReserveBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterWithdrawal( @@ -808,7 +839,7 @@ access(all) contract FlowALPv0 { return fundsAvailable + depositAmount } - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let balanceSheet = self._getReserveBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterDeposit( @@ -901,7 +932,7 @@ access(all) contract FlowALPv0 { /// @param amount The amount to deposit. /// @return The projected health after the deposit. access(all) fun healthAfterDeposit(pid: UInt64, type: Type, amount: UFix64): UFix128 { - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let balanceSheet = self._getReserveBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterDeposit( initialBalanceSheet: balanceSheet, @@ -924,7 +955,7 @@ access(all) contract FlowALPv0 { /// @param amount The amount to withdraw. /// @return The projected health after the withdrawal. access(all) fun healthAfterWithdrawal(pid: UInt64, type: Type, amount: UFix64): UFix128 { - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let balanceSheet = self._getReserveBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterWithdrawal( initialBalanceSheet: balanceSheet, @@ -1215,15 +1246,16 @@ access(all) contract FlowALPv0 { let queuedUsable = queuedBalanceForType < amount ? queuedBalanceForType : amount let reserveWithdrawAmount: UFix64 = amount - queuedUsable - // Preflight to see if the funds are available. - // Use effective health (reserve + queued deposits) so that this check is consistent - // with the liquidation and rebalancing checks, which also use effective health. + // Preflight: determine whether the withdrawal is permitted and whether a topUp is needed. + // Uses the withdrawal balance sheet (reserve + queued for the withdrawn token only), + // so same-type queue withdrawals are correctly gated on remaining queued+reserve health, + // while cross-type borrows cannot be backed by queued deposits of other tokens. let topUpSource = position.borrowTopUpSource() let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() - let effectiveBalanceSheet = self._getBalanceSheetIncludingQueuedDeposits(pid: pid) - let effectiveBalanceAfterWithdrawal = self.computeAdjustedBalancesAfterWithdrawal( - initialBalanceSheet: effectiveBalanceSheet, + let queuedBalanceSheet = self._getQueuedBalanceSheet(pid: pid) + let withdrawalBalanceSheet = self.computeAdjustedBalancesAfterWithdrawal( + initialBalanceSheet: queuedBalanceSheet, position: position, withdrawType: type, withdrawAmount: amount @@ -1232,7 +1264,7 @@ access(all) contract FlowALPv0 { let requiredDeposit = self.computeRequiredDepositForHealth( position: position, depositType: topUpType, - initialBalanceSheet: effectiveBalanceAfterWithdrawal, + initialBalanceSheet: withdrawalBalanceSheet, targetHealth: position.getMinHealth() ) @@ -1248,7 +1280,7 @@ access(all) contract FlowALPv0 { let idealDeposit = self.computeRequiredDepositForHealth( position: position, depositType: topUpType, - initialBalanceSheet: effectiveBalanceAfterWithdrawal, + initialBalanceSheet: withdrawalBalanceSheet, targetHealth: position.getTargetHealth() ) @@ -1329,10 +1361,10 @@ access(all) contract FlowALPv0 { withdrawn.deposit(from: <-fromReserve) } - // Regardless of whether a top-up occurred, the position must be healthy post-withdrawal. - // Uses effective health (reserve + remaining queued deposits) for consistency with + // Post-withdrawal safety check: queued health must be >= 1.0. + // Uses queued health (reserve + remaining queued deposits) for consistency with // the liquidation and rebalancing checks. - let postHealth = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health + let postHealth = self._getQueuedBalanceSheet(pid: pid).health assert( postHealth >= 1.0, message: "Post-withdrawal position health (\(postHealth)) is unhealthy" @@ -1683,6 +1715,12 @@ access(all) contract FlowALPv0 { /// This helper is intentionally "no-lock" and "effects-only" with respect to orchestration. /// Callers are responsible for acquiring and releasing the position lock and for enforcing /// any higher-level invariants. + /// + /// Health-factor usage: + /// - The trigger check (is rebalancing needed?) and the undercollateralised topUp sizing + /// use **queued health**, so a pending deposit suppresses an unnecessary topUp pull. + /// - The overcollateralised drawdown trigger and sizing use **reserve health**, so queued + /// deposits do not cause the pool to push out tokens prematurely. access(self) fun _rebalancePositionNoLock(pid: UInt64, force: Bool) { pre { !self.isPaused(): "Withdrawal, deposits, and liquidations are paused by governance" @@ -1691,22 +1729,23 @@ access(all) contract FlowALPv0 { log(" [CONTRACT] rebalancePosition(pid: \(pid), force: \(force))") } let position = self._borrowPosition(pid: pid) - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) - let effectiveBalanceSheet = self._getBalanceSheetIncludingQueuedDeposits(pid: pid) + let reserveBalanceSheet = self._getReserveBalanceSheet(pid: pid) + let queuedBalanceSheet = self._getQueuedBalanceSheet(pid: pid) - if !force && (position.getMinHealth() <= effectiveBalanceSheet.health && effectiveBalanceSheet.health <= position.getMaxHealth()) { - // We aren't forcing the update, and the position is already between its desired min and max. Nothing to do! + if !force && (position.getMinHealth() <= queuedBalanceSheet.health && queuedBalanceSheet.health <= position.getMaxHealth()) { + // Not forcing, and queued health is already within the desired min/max bounds. Nothing to do. return } - if effectiveBalanceSheet.health < position.getTargetHealth() { - // The position is undercollateralized, - // see if the source can get more collateral to bring it up to the target health. + if queuedBalanceSheet.health < position.getTargetHealth() { + // Queued health is below target — the position needs a topUp. + // Size the ideal topUp from the queued balance sheet so that any pending deposit + // is accounted for, avoiding over-pulling from the topUpSource. if let topUpSource = position.borrowTopUpSource() { let idealDeposit = self.computeRequiredDepositForHealth( position: position, depositType: topUpSource.getSourceType(), - initialBalanceSheet: effectiveBalanceSheet, + initialBalanceSheet: queuedBalanceSheet, targetHealth: position.getTargetHealth() ) if self.config.isDebugLogging() { @@ -1720,7 +1759,7 @@ access(all) contract FlowALPv0 { FlowALPEvents.emitRebalanced( pid: pid, poolUUID: self.uuid, - atHealth: balanceSheet.health, + atHealth: reserveBalanceSheet.health, amount: pulledVault.balance, fromUnder: true ) @@ -1730,13 +1769,14 @@ access(all) contract FlowALPv0 { from: <-pulledVault, ) - // Post-deposit health check: panic if the position is still liquidatable. - let newEffectiveHealth = self._getBalanceSheetIncludingQueuedDeposits(pid: pid).health - assert(newEffectiveHealth >= 1.0, message: "topUpSource insufficient to save position from liquidation") + // Post-topUp check: queued health must be >= 1.0 after the deposit. + let newQueuedHealth = self._getQueuedBalanceSheet(pid: pid).health + assert(newQueuedHealth >= 1.0, message: "topUpSource insufficient to save position from liquidation") } - } else if effectiveBalanceSheet.health > position.getTargetHealth() { - // The position is overcollateralized, - // we'll withdraw funds to match the target health and offer it to the sink. + } else if reserveBalanceSheet.health > position.getTargetHealth() { + // Reserve health is above target — the position has excess collateral to push to the sink. + // Uses reserve health (not queued) so that queued deposits of other tokens do not + // cause premature drawdown before those deposits have entered the reserve. if self.isPausedOrWarmup() { // Withdrawals (including pushing to the drawDownSink) are disabled during the warmup period return @@ -1746,7 +1786,7 @@ access(all) contract FlowALPv0 { let idealWithdrawal = self.computeAvailableWithdrawal( position: position, withdrawType: sinkType, - initialBalanceSheet: effectiveBalanceSheet, + initialBalanceSheet: reserveBalanceSheet, targetHealth: position.getTargetHealth() ) if self.config.isDebugLogging() { @@ -1777,7 +1817,7 @@ access(all) contract FlowALPv0 { FlowALPEvents.emitRebalanced( pid: pid, poolUUID: self.uuid, - atHealth: balanceSheet.health, + atHealth: reserveBalanceSheet.health, amount: sinkVault.balance, fromUnder: false ) @@ -2004,7 +2044,10 @@ access(all) contract FlowALPv0 { // INTERNAL //////////////// - /// Queues a position for asynchronous updates if the position has been marked as requiring an update + /// Queues a position for asynchronous updates if it has pending queued deposits or its + /// queued health is outside the configured [minHealth, maxHealth] bounds. + /// Uses queued health so that positions with pending deposits are not unnecessarily + /// scheduled for rebalancing when those deposits would already bring health within bounds. access(self) fun _queuePositionForUpdateIfNecessary(pid: UInt64) { if self.state.positionsNeedingUpdatesContains(pid) { // If this position is already queued for an update, no need to check anything else @@ -2030,9 +2073,12 @@ access(all) contract FlowALPv0 { } } - /// Returns a position's FlowALPModels.BalanceSheet containing its effective collateral and debt as well as its current health + /// Returns the **reserve balance sheet** for a position: effective collateral and debt computed + /// from credited reserve balances only, with current interest indices applied. + /// Queued deposits are not included. + /// See the Pool-level comment for the rationale behind the two health-factor concepts. /// TODO(jord): in all cases callers already are calling _borrowPosition, more efficient to pass in PositionView? - access(self) fun _getUpdatedBalanceSheet(pid: UInt64): FlowALPModels.BalanceSheet { + access(self) fun _getReserveBalanceSheet(pid: UInt64): FlowALPModels.BalanceSheet { let position = self._borrowPosition(pid: pid) // Get the position's collateral and debt values in terms of the default token. @@ -2070,12 +2116,18 @@ access(all) contract FlowALPv0 { ) } - /// Returns a balance sheet that accounts for all queued deposits as if they had already been processed. - /// Used to determine whether rebalancing or liquidation is appropriate when pending deposits - /// would, once credited, bring the position to a safe health factor. - access(self) fun _getBalanceSheetIncludingQueuedDeposits(pid: UInt64): FlowALPModels.BalanceSheet { + /// Returns the **queued balance sheet**: the reserve balance sheet plus all queued deposits + /// projected as if they had already been credited. + /// + /// Used for: liquidation eligibility, rebalancing trigger/direction, and the post-withdrawal + /// safety assertion. A queued deposit is already in the protocol's custody and committed to + /// the position, so it should protect against liquidation and suppress unnecessary rebalancing, + /// even though it has not yet entered the reserve. + /// + /// Not used for: borrow-capacity or available-balance queries (those use reserve health only). + access(self) fun _getQueuedBalanceSheet(pid: UInt64): FlowALPModels.BalanceSheet { let position = self._borrowPosition(pid: pid) - var balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + var balanceSheet = self._getReserveBalanceSheet(pid: pid) for depositType in position.getQueuedDepositKeys() { let queuedAmount = position.getQueuedDepositBalance(depositType)! From adef92e609752ed378e8b11cb17f00e900a49380 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Fri, 3 Apr 2026 12:11:39 -0700 Subject: [PATCH 06/13] queued deposits: rename reserve balance/health -> credited Co-Authored-By: Claude Sonnet 4.6 --- cadence/contracts/FlowALPv0.cdc | 60 ++++++++++++++++----------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index dd681a88..f20a8d57 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -70,7 +70,7 @@ access(all) contract FlowALPv0 { return 0.0 } - // TODO: this logic partly duplicates FlowALPModels.BalanceSheet construction in _getReserveBalanceSheet + // TODO: this logic partly duplicates FlowALPModels.BalanceSheet construction in _getCreditedBalanceSheet // This function differs in that it does not read any data from a Pool resource. Consider consolidating the two implementations. var effectiveCollateralTotal: UFix128 = 0.0 var effectiveDebtTotal: UFix128 = 0.0 @@ -146,8 +146,8 @@ access(all) contract FlowALPv0 { /// /// The Pool uses two distinct health factor concepts throughout its logic: /// - /// - **Reserve health**: computed from balances that have been credited to the reserve only. - /// This is what `positionHealth()` and `_getReserveBalanceSheet()` return. + /// - **Credited health**: computed from balances that have been credited to the reserve only. + /// This is what `positionHealth()` and `_getCreditedBalanceSheet()` return. /// It is used for: available-balance queries, borrow-capacity checks, and the /// pre/post-liquidation arithmetic (Ce_pre, De_pre). /// @@ -160,8 +160,8 @@ access(all) contract FlowALPv0 { /// unnecessary rebalancing, but it does not yet support borrowing against other tokens. /// /// The withdrawal preflight (`_getWithdrawalBalanceSheet`) is a hybrid: it starts from the - /// reserve balance sheet but adds the queued deposit for the *withdrawn token only*, so that - /// withdrawing same-type queued funds is gated on the remaining queued + reserve health for + /// credited balance sheet but adds the queued deposit for the *withdrawn token only*, so that + /// withdrawing same-type queued funds is gated on the remaining queued + credited health for /// that token, while cross-type borrowing against uncredited collateral remains blocked. access(all) resource Pool: FlowALPModels.PositionPool { @@ -457,7 +457,7 @@ access(all) contract FlowALPv0 { return FlowALPMath.toUFix64Round(uintMax) } - /// Returns the **reserve health** of the given position: the ratio of effective collateral to + /// Returns the **credited health** of the given position: the ratio of effective collateral to /// effective debt, computed from credited reserve balances only (queued deposits excluded). /// "Effective collateral" is each credit balance times its collateral factor; "effective debt" /// is each debit balance divided by its borrow factor. Both are denominated in the default token. @@ -466,7 +466,7 @@ access(all) contract FlowALPv0 { /// `_getQueuedBalanceSheet` for liquidation and rebalancing decisions. // TODO: make this output enumeration of effective debts/collaterals (or provide option that does) access(all) fun positionHealth(pid: UInt64): UFix128 { - let balanceSheet = self._getReserveBalanceSheet(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) return balanceSheet.health } @@ -571,8 +571,8 @@ access(all) contract FlowALPv0 { self.lockPosition(pid) let positionView = self.buildPositionView(pid: pid) - // Reserve balance sheet used for Ce_pre/De_pre (liquidation seizes reserve collateral only). - let reserveBalanceSheet = self._getReserveBalanceSheet(pid: pid) + // Credited balance sheet used for Ce_pre/De_pre (liquidation seizes reserve collateral only). + let creditedBalanceSheet = self._getCreditedBalanceSheet(pid: pid) // Queued health used for the eligibility check: a pending deposit that would rescue the // position should prevent liquidation. let initialHealth = self._getQueuedBalanceSheet(pid: pid).health @@ -593,9 +593,9 @@ access(all) contract FlowALPv0 { let Pcd_oracle = Pc_oracle / Pd_oracle // Compute the health factor which would result if we were to accept this liquidation. - // Uses reserve balance sheet: liquidation seizes reserve collateral, not queued deposits. - let Ce_pre = reserveBalanceSheet.effectiveCollateral // effective collateral pre-liquidation - let De_pre = reserveBalanceSheet.effectiveDebt // effective debt pre-liquidation + // Uses credited balance sheet: liquidation seizes reserve collateral, not queued deposits. + let Ce_pre = creditedBalanceSheet.effectiveCollateral // effective collateral pre-liquidation + let De_pre = creditedBalanceSheet.effectiveDebt // effective debt pre-liquidation let Fc = positionView.snapshots[seizeType]!.getRisk().getCollateralFactor() let Fd = positionView.snapshots[debtType]!.getRisk().getBorrowFactor() @@ -703,7 +703,7 @@ access(all) contract FlowALPv0 { log(" [CONTRACT] fundsRequiredForTargetHealthAfterWithdrawing(pid: \(pid), depositType: \(depositType.contractName!), targetHealth: \(targetHealth), withdrawType: \(withdrawType.contractName!), withdrawAmount: \(withdrawAmount))") } - let balanceSheet = self._getReserveBalanceSheet(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterWithdrawal( @@ -839,7 +839,7 @@ access(all) contract FlowALPv0 { return fundsAvailable + depositAmount } - let balanceSheet = self._getReserveBalanceSheet(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterDeposit( @@ -932,7 +932,7 @@ access(all) contract FlowALPv0 { /// @param amount The amount to deposit. /// @return The projected health after the deposit. access(all) fun healthAfterDeposit(pid: UInt64, type: Type, amount: UFix64): UFix128 { - let balanceSheet = self._getReserveBalanceSheet(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterDeposit( initialBalanceSheet: balanceSheet, @@ -955,7 +955,7 @@ access(all) contract FlowALPv0 { /// @param amount The amount to withdraw. /// @return The projected health after the withdrawal. access(all) fun healthAfterWithdrawal(pid: UInt64, type: Type, amount: UFix64): UFix128 { - let balanceSheet = self._getReserveBalanceSheet(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterWithdrawal( initialBalanceSheet: balanceSheet, @@ -1248,7 +1248,7 @@ access(all) contract FlowALPv0 { // Preflight: determine whether the withdrawal is permitted and whether a topUp is needed. // Uses the withdrawal balance sheet (reserve + queued for the withdrawn token only), - // so same-type queue withdrawals are correctly gated on remaining queued+reserve health, + // so same-type queue withdrawals are correctly gated on remaining queued+credited health, // while cross-type borrows cannot be backed by queued deposits of other tokens. let topUpSource = position.borrowTopUpSource() let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() @@ -1719,7 +1719,7 @@ access(all) contract FlowALPv0 { /// Health-factor usage: /// - The trigger check (is rebalancing needed?) and the undercollateralised topUp sizing /// use **queued health**, so a pending deposit suppresses an unnecessary topUp pull. - /// - The overcollateralised drawdown trigger and sizing use **reserve health**, so queued + /// - The overcollateralised drawdown trigger and sizing use **credited health**, so queued /// deposits do not cause the pool to push out tokens prematurely. access(self) fun _rebalancePositionNoLock(pid: UInt64, force: Bool) { pre { @@ -1729,7 +1729,7 @@ access(all) contract FlowALPv0 { log(" [CONTRACT] rebalancePosition(pid: \(pid), force: \(force))") } let position = self._borrowPosition(pid: pid) - let reserveBalanceSheet = self._getReserveBalanceSheet(pid: pid) + let creditedBalanceSheet = self._getCreditedBalanceSheet(pid: pid) let queuedBalanceSheet = self._getQueuedBalanceSheet(pid: pid) if !force && (position.getMinHealth() <= queuedBalanceSheet.health && queuedBalanceSheet.health <= position.getMaxHealth()) { @@ -1759,7 +1759,7 @@ access(all) contract FlowALPv0 { FlowALPEvents.emitRebalanced( pid: pid, poolUUID: self.uuid, - atHealth: reserveBalanceSheet.health, + atHealth: creditedBalanceSheet.health, amount: pulledVault.balance, fromUnder: true ) @@ -1773,9 +1773,9 @@ access(all) contract FlowALPv0 { let newQueuedHealth = self._getQueuedBalanceSheet(pid: pid).health assert(newQueuedHealth >= 1.0, message: "topUpSource insufficient to save position from liquidation") } - } else if reserveBalanceSheet.health > position.getTargetHealth() { - // Reserve health is above target — the position has excess collateral to push to the sink. - // Uses reserve health (not queued) so that queued deposits of other tokens do not + } else if creditedBalanceSheet.health > position.getTargetHealth() { + // Credited health is above target — the position has excess collateral to push to the sink. + // Uses credited health (not queued) so that queued deposits of other tokens do not // cause premature drawdown before those deposits have entered the reserve. if self.isPausedOrWarmup() { // Withdrawals (including pushing to the drawDownSink) are disabled during the warmup period @@ -1786,7 +1786,7 @@ access(all) contract FlowALPv0 { let idealWithdrawal = self.computeAvailableWithdrawal( position: position, withdrawType: sinkType, - initialBalanceSheet: reserveBalanceSheet, + initialBalanceSheet: creditedBalanceSheet, targetHealth: position.getTargetHealth() ) if self.config.isDebugLogging() { @@ -1817,7 +1817,7 @@ access(all) contract FlowALPv0 { FlowALPEvents.emitRebalanced( pid: pid, poolUUID: self.uuid, - atHealth: reserveBalanceSheet.health, + atHealth: creditedBalanceSheet.health, amount: sinkVault.balance, fromUnder: false ) @@ -2073,12 +2073,12 @@ access(all) contract FlowALPv0 { } } - /// Returns the **reserve balance sheet** for a position: effective collateral and debt computed + /// Returns the **credited balance sheet** for a position: effective collateral and debt computed /// from credited reserve balances only, with current interest indices applied. /// Queued deposits are not included. /// See the Pool-level comment for the rationale behind the two health-factor concepts. /// TODO(jord): in all cases callers already are calling _borrowPosition, more efficient to pass in PositionView? - access(self) fun _getReserveBalanceSheet(pid: UInt64): FlowALPModels.BalanceSheet { + access(self) fun _getCreditedBalanceSheet(pid: UInt64): FlowALPModels.BalanceSheet { let position = self._borrowPosition(pid: pid) // Get the position's collateral and debt values in terms of the default token. @@ -2116,7 +2116,7 @@ access(all) contract FlowALPv0 { ) } - /// Returns the **queued balance sheet**: the reserve balance sheet plus all queued deposits + /// Returns the **queued balance sheet**: the credited balance sheet plus all queued deposits /// projected as if they had already been credited. /// /// Used for: liquidation eligibility, rebalancing trigger/direction, and the post-withdrawal @@ -2124,10 +2124,10 @@ access(all) contract FlowALPv0 { /// the position, so it should protect against liquidation and suppress unnecessary rebalancing, /// even though it has not yet entered the reserve. /// - /// Not used for: borrow-capacity or available-balance queries (those use reserve health only). + /// Not used for: borrow-capacity or available-balance queries (those use credited health only). access(self) fun _getQueuedBalanceSheet(pid: UInt64): FlowALPModels.BalanceSheet { let position = self._borrowPosition(pid: pid) - var balanceSheet = self._getReserveBalanceSheet(pid: pid) + var balanceSheet = self._getCreditedBalanceSheet(pid: pid) for depositType in position.getQueuedDepositKeys() { let queuedAmount = position.getQueuedDepositBalance(depositType)! From bf9fe9f106346ebb02a9658230956cdabea5e454 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Tue, 7 Apr 2026 15:54:41 -0700 Subject: [PATCH 07/13] queued deposits: fix after rebase --- cadence/contracts/FlowALPv0.cdc | 34 ++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index f20a8d57..4e123ea6 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1253,13 +1253,33 @@ access(all) contract FlowALPv0 { let topUpSource = position.borrowTopUpSource() let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() - let queuedBalanceSheet = self._getQueuedBalanceSheet(pid: pid) - let withdrawalBalanceSheet = self.computeAdjustedBalancesAfterWithdrawal( - initialBalanceSheet: queuedBalanceSheet, - position: position, - withdrawType: type, - withdrawAmount: amount - ) + // Build the withdrawal balance sheet in three steps to stay internally consistent: + // 1. Start from the credited (reserve-only) balance sheet. + // 2. Apply the reserve portion of the withdrawal — uses position.getBalance(type), which + // is the reserve-only scaled balance, so the sheet and the delta are consistent. + // 3. Add back the remaining queued balance for the withdrawn type (the portion that was + // not consumed by this withdrawal and will stay in the queue). + // This correctly reflects the post-withdrawal state while (a) preventing cross-type + // borrowing against queued collateral of other tokens, and (b) allowing same-type queue + // withdrawals when the remaining reserve + remaining queue keeps health above the minimum. + let remainingQueued = queuedBalanceForType - queuedUsable + var withdrawalBalanceSheet = self._getCreditedBalanceSheet(pid: pid) + if reserveWithdrawAmount > 0.0 { + withdrawalBalanceSheet = self.computeAdjustedBalancesAfterWithdrawal( + initialBalanceSheet: withdrawalBalanceSheet, + position: position, + withdrawType: type, + withdrawAmount: reserveWithdrawAmount + ) + } + if remainingQueued > 0.0 { + withdrawalBalanceSheet = self.computeAdjustedBalancesAfterDeposit( + initialBalanceSheet: withdrawalBalanceSheet, + position: position, + depositType: type, + depositAmount: remainingQueued + ) + } let requiredDeposit = self.computeRequiredDepositForHealth( position: position, From 5fb007b33217f9c998a88f648422f5e1b4b9aebf Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Wed, 8 Apr 2026 14:11:24 -0700 Subject: [PATCH 08/13] queued deposits: factor out withdrawal balance sheet --- cadence/contracts/FlowALPv0.cdc | 105 +++++++++++++++++++------------- 1 file changed, 64 insertions(+), 41 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 4e123ea6..bf87dffb 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -159,10 +159,9 @@ access(all) contract FlowALPv0 { /// custody and committed to the position — it protects against liquidation and avoids /// unnecessary rebalancing, but it does not yet support borrowing against other tokens. /// - /// The withdrawal preflight (`_getWithdrawalBalanceSheet`) is a hybrid: it starts from the - /// credited balance sheet but adds the queued deposit for the *withdrawn token only*, so that - /// withdrawing same-type queued funds is gated on the remaining queued + credited health for - /// that token, while cross-type borrowing against uncredited collateral remains blocked. + /// `withdrawAndPull` uses a hybrid approach, only including queued deposits + /// for the token type being withdrawn. This lets withdrawals from the deposit queue function, + /// while cross-token borrowing against queued deposits is blocked. access(all) resource Pool: FlowALPModels.PositionPool { /// Pool state (extracted fields) @@ -407,6 +406,8 @@ access(all) contract FlowALPv0 { } /// Returns a position's balance available for withdrawal of a given Vault type. + /// Queued deposits of the requested type are NOT included in the result. + /// /// Phase 0 refactor: compute via pure helpers using a PositionView and TokenSnapshot for the base path. /// When `pullFromTopUpSource` is true and a topUpSource exists, preserve deposit-assisted semantics. access(all) fun availableBalance(pid: UInt64, type: Type, pullFromTopUpSource: Bool): UFix64 { @@ -682,6 +683,10 @@ access(all) contract FlowALPv0 { /// Returns 0.0 if the position would already be at or above the target health /// after the proposed withdrawal. /// + /// Mirrors the usage in `withdrawAndPull`: any queued deposit of + /// `withdrawType` is drained before the reserve, so only the net reserve withdrawal and + /// the remaining queued balance affect the resulting health. + /// /// @param pid The position ID. /// @param depositType The token type that would be deposited to restore health. /// @param targetHealth The desired health to reach (must be >= 1.0). @@ -703,12 +708,9 @@ access(all) contract FlowALPv0 { log(" [CONTRACT] fundsRequiredForTargetHealthAfterWithdrawing(pid: \(pid), depositType: \(depositType.contractName!), targetHealth: \(targetHealth), withdrawType: \(withdrawType.contractName!), withdrawAmount: \(withdrawAmount))") } - let balanceSheet = self._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) - - let adjusted = self.computeAdjustedBalancesAfterWithdrawal( - initialBalanceSheet: balanceSheet, - position: position, + let balanceSheet = self._getWithdrawalBalanceSheet( + pid: pid, withdrawType: withdrawType, withdrawAmount: withdrawAmount ) @@ -716,11 +718,55 @@ access(all) contract FlowALPv0 { return self.computeRequiredDepositForHealth( position: position, depositType: depositType, - initialBalanceSheet: adjusted, + initialBalanceSheet: balanceSheet, targetHealth: targetHealth ) } + /// Returns the balance sheet that reflects a position's state immediately after a + /// hypothetical withdrawal of `withdrawAmount` of `withdrawType`, accounting for the + /// fact that `withdrawAndPull` drains queued deposits before touching the reserve. + /// + /// This allows withdrawal from the queue as long as those queued deposits are not required + /// to maintain the position's health, while preventing borrowing against queued deposits + /// of other token types (their queued deposits are never added to this sheet). + /// + /// @param pid The position ID. + /// @param withdrawType The token type being withdrawn. + /// @param withdrawAmount The total amount being withdrawn (queue first, then reserve). + /// @return A BalanceSheet reflecting the post-withdrawal state. + access(self) fun _getWithdrawalBalanceSheet( + pid: UInt64, + withdrawType: Type, + withdrawAmount: UFix64 + ): FlowALPModels.BalanceSheet { + let position = self._borrowPosition(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) + let queuedForType = position.getQueuedDepositBalance(withdrawType) ?? 0.0 + // Either the withdrawal only touches the queued deposit, + // in which case we include the remaining portion of the queued deposit in the balance sheet + let remainingQueued = queuedForType.saturatingSubtract(withdrawAmount) + if remainingQueued > 0.0 { + return self.computeAdjustedBalancesAfterDeposit( + initialBalanceSheet: balanceSheet, + position: position, + depositType: withdrawType, + depositAmount: remainingQueued + ) + } + // Or it withdraws the entire queued deposit for the token and remaining amount from the reserve + let reserveWithdrawAmount = withdrawAmount.saturatingSubtract(queuedForType) + if reserveWithdrawAmount > 0.0 { + return self.computeAdjustedBalancesAfterWithdrawal( + initialBalanceSheet: balanceSheet, + position: position, + withdrawType: withdrawType, + withdrawAmount: reserveWithdrawAmount + ) + } + return balanceSheet + } + /// Computes the effective collateral and debt after a hypothetical withdrawal, /// accounting for whether the withdrawal reduces credit or increases debt. /// @@ -793,6 +839,7 @@ access(all) contract FlowALPv0 { /// Returns the quantity of the specified token that could be withdrawn /// while still keeping the position's health at or above the provided target. /// Equivalent to fundsAvailableAboveTargetHealthAfterDepositing with depositAmount=0. + /// Note: Does not account for queued deposits. /// /// @param pid The position ID. /// @param type The token type to compute available withdrawal for. @@ -811,6 +858,7 @@ access(all) contract FlowALPv0 { /// Returns the quantity of the specified token that could be withdrawn /// while still keeping the position's health at or above the provided target, /// assuming we also deposit a specified amount of another token. + /// Note: Does not account for queued deposits. /// /// @param pid The position ID. /// @param withdrawType The token type to compute available withdrawal for. @@ -1247,39 +1295,14 @@ access(all) contract FlowALPv0 { let reserveWithdrawAmount: UFix64 = amount - queuedUsable // Preflight: determine whether the withdrawal is permitted and whether a topUp is needed. - // Uses the withdrawal balance sheet (reserve + queued for the withdrawn token only), - // so same-type queue withdrawals are correctly gated on remaining queued+credited health, - // while cross-type borrows cannot be backed by queued deposits of other tokens. let topUpSource = position.borrowTopUpSource() let topUpType = topUpSource?.getSourceType() ?? self.state.getDefaultToken() - // Build the withdrawal balance sheet in three steps to stay internally consistent: - // 1. Start from the credited (reserve-only) balance sheet. - // 2. Apply the reserve portion of the withdrawal — uses position.getBalance(type), which - // is the reserve-only scaled balance, so the sheet and the delta are consistent. - // 3. Add back the remaining queued balance for the withdrawn type (the portion that was - // not consumed by this withdrawal and will stay in the queue). - // This correctly reflects the post-withdrawal state while (a) preventing cross-type - // borrowing against queued collateral of other tokens, and (b) allowing same-type queue - // withdrawals when the remaining reserve + remaining queue keeps health above the minimum. - let remainingQueued = queuedBalanceForType - queuedUsable - var withdrawalBalanceSheet = self._getCreditedBalanceSheet(pid: pid) - if reserveWithdrawAmount > 0.0 { - withdrawalBalanceSheet = self.computeAdjustedBalancesAfterWithdrawal( - initialBalanceSheet: withdrawalBalanceSheet, - position: position, - withdrawType: type, - withdrawAmount: reserveWithdrawAmount - ) - } - if remainingQueued > 0.0 { - withdrawalBalanceSheet = self.computeAdjustedBalancesAfterDeposit( - initialBalanceSheet: withdrawalBalanceSheet, - position: position, - depositType: type, - depositAmount: remainingQueued - ) - } + let withdrawalBalanceSheet = self._getWithdrawalBalanceSheet( + pid: pid, + withdrawType: type, + withdrawAmount: amount + ) let requiredDeposit = self.computeRequiredDepositForHealth( position: position, @@ -1789,7 +1812,7 @@ access(all) contract FlowALPv0 { from: <-pulledVault, ) - // Post-topUp check: queued health must be >= 1.0 after the deposit. + // Post-topUp health check: panic if the position is still liquidatable. let newQueuedHealth = self._getQueuedBalanceSheet(pid: pid).health assert(newQueuedHealth >= 1.0, message: "topUpSource insufficient to save position from liquidation") } From 9bcb48f3a52d226929589d8098f35852b43c2254 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Wed, 8 Apr 2026 15:07:52 -0700 Subject: [PATCH 09/13] queued deposits: finish renaming health factors in test Reserve health -> Credited health Effective health -> Queued health (includes queued deposits) --- cadence/tests/queued_deposits_health_test.cdc | 82 +++++++++---------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/cadence/tests/queued_deposits_health_test.cdc b/cadence/tests/queued_deposits_health_test.cdc index 082fe3aa..f2ef5762 100644 --- a/cadence/tests/queued_deposits_health_test.cdc +++ b/cadence/tests/queued_deposits_health_test.cdc @@ -73,10 +73,10 @@ fun setupPositionWithDebt(): Test.TestAccount { // // With: 1000 FLOW @ $0.70, cf=0.8 → effectiveCollateral = 560 // MOET debt ≈ 615.38 (drawn at initial price 1.0, targetHealth 1.3) -// Reserve health = 560 / 615.38 ≈ 0.91 (< 1.0 → normally liquidatable) +// Credited health = 560 / 615.38 ≈ 0.91 (< 1.0 → normally liquidatable) // // Queuing 200 FLOW @ $0.70, cf=0.8 adds 112 to effectiveCollateral: -// Effective health = 672 / 615.38 ≈ 1.09 (≥ 1.0 → not liquidatable) +// Queued health = 672 / 615.38 ≈ 1.09 (≥ 1.0 → not liquidatable) // ───────────────────────────────────────────────────────────────────────────── access(all) fun test_liquidation_blocked_by_queued_deposit() { @@ -85,7 +85,7 @@ fun test_liquidation_blocked_by_queued_deposit() { let user = setupPositionWithDebt() - // Drop FLOW price so reserve health < 1.0. + // Drop FLOW price so credited health < 1.0. let crashedPrice: UFix64 = 0.7 setMockOraclePrice( signer: Test.getAccount(0x0000000000000007), @@ -100,9 +100,9 @@ fun test_liquidation_blocked_by_queued_deposit() { priceRatio: crashedPrice ) - // Confirm reserve health is below 1.0. - let reserveHealth = getPositionHealth(pid: pid, beFailed: false) - Test.assert(reserveHealth < 1.0, message: "Expected reserve health < 1.0 after price drop, got \(reserveHealth)") + // Confirm credited health is below 1.0. + let creditedHealth = getPositionHealth(pid: pid, beFailed: false) + Test.assert(creditedHealth < 1.0, message: "Expected credited health < 1.0 after price drop, got \(creditedHealth)") // Deposit 200 FLOW into the queue (deposit cap is exhausted, so it cannot enter the reserve). depositToPosition( @@ -119,7 +119,7 @@ fun test_liquidation_blocked_by_queued_deposit() { Test.assert(queued[flowType] != nil, message: "Expected 200 FLOW to be in the queue") // The position should now NOT be liquidatable because the queued deposit - // brings effective health above 1.0. + // brings queued health above 1.0. let liquidatable = getIsLiquidatable(pid: pid) Test.assert(!liquidatable, message: "Position should not be liquidatable when queued deposit rescues health") @@ -144,7 +144,7 @@ fun test_liquidation_blocked_by_queued_deposit() { // // Same setup as Test 1, but only 50 FLOW is queued: // Queued contribution = 50 × 0.7 × 0.8 = 28 -// Effective health = (560 + 28) / 615.38 ≈ 0.96 (< 1.0 → still liquidatable) +// Queued health = (560 + 28) / 615.38 ≈ 0.96 (< 1.0 → still liquidatable) // ───────────────────────────────────────────────────────────────────────────── access(all) fun test_liquidation_allowed_when_queued_deposit_insufficient() { @@ -176,7 +176,7 @@ fun test_liquidation_allowed_when_queued_deposit_insufficient() { pushToDrawDownSink: false ) - // Even with the queued deposit, effective health is < 1.0. + // Even with the queued deposit, queued health is < 1.0. let liquidatable = getIsLiquidatable(pid: pid) Test.assert(liquidatable, message: "Position should still be liquidatable when queued deposit is insufficient") } @@ -186,10 +186,10 @@ fun test_liquidation_allowed_when_queued_deposit_insufficient() { // // With: 1000 FLOW @ $0.80, cf=0.8 → effectiveCollateral = 640 // MOET debt ≈ 615.38 -// Reserve health = 640 / 615.38 ≈ 1.04 (< MIN_HEALTH = 1.1 → would trigger topUp) +// Credited health = 640 / 615.38 ≈ 1.04 (< MIN_HEALTH = 1.1 → would trigger topUp) // // Queuing 100 FLOW @ $0.80, cf=0.8 adds 64 to effectiveCollateral: -// Effective health = 704 / 615.38 ≈ 1.14 (within [1.1, 1.5] → no rebalance needed) +// Queued health = 704 / 615.38 ≈ 1.14 (within [1.1, 1.5] → no rebalance needed) // ───────────────────────────────────────────────────────────────────────────── access(all) fun test_rebalance_skipped_when_queued_deposit_within_health_bounds() { @@ -199,18 +199,18 @@ fun test_rebalance_skipped_when_queued_deposit_within_health_bounds() { let user = setupPositionWithDebt() let userMoetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! - // Drop FLOW price so reserve health falls below MIN_HEALTH (1.1) but not below 1.0. + // Drop FLOW price so credited health falls below MIN_HEALTH (1.1) but not below 1.0. setMockOraclePrice( signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.8 ) - let reserveHealth = getPositionHealth(pid: pid, beFailed: false) - Test.assert(reserveHealth < UFix128(MIN_HEALTH), message: "Expected reserve health below MIN_HEALTH, got \(reserveHealth)") - Test.assert(reserveHealth >= 1.0, message: "Reserve health should still be above 1.0 (non-liquidatable), got \(reserveHealth)") + let creditedHealth = getPositionHealth(pid: pid, beFailed: false) + Test.assert(creditedHealth < UFix128(MIN_HEALTH), message: "Expected credited health below MIN_HEALTH, got \(creditedHealth)") + Test.assert(creditedHealth >= 1.0, message: "Credited health should still be above 1.0 (non-liquidatable), got \(creditedHealth)") - // Queue 100 FLOW — sufficient to push effective health into [MIN_HEALTH, MAX_HEALTH]. + // Queue 100 FLOW — sufficient to push queued health into [MIN_HEALTH, MAX_HEALTH]. depositToPosition( signer: user, positionID: pid, @@ -219,7 +219,7 @@ fun test_rebalance_skipped_when_queued_deposit_within_health_bounds() { pushToDrawDownSink: false ) - // With force=false the rebalancer should see effective health within bounds and do nothing. + // With force=false the rebalancer should see queued health within bounds and do nothing. rebalancePosition(signer: PROTOCOL_ACCOUNT, pid: pid, force: false, beFailed: false) // The user's MOET vault should be unchanged — no topUp was pulled. @@ -236,15 +236,15 @@ fun test_rebalance_skipped_when_queued_deposit_within_health_bounds() { // // With: 1000 FLOW @ $0.60, cf=0.8 → effectiveCollateral = 480 // MOET debt ≈ 615.38 -// Reserve health ≈ 0.78 (badly unhealthy — topUp required regardless) +// Credited health ≈ 0.78 (badly unhealthy — topUp required regardless) // // Queuing 200 FLOW @ $0.60, cf=0.8 adds 96 to effectiveCollateral: -// Effective health ≈ 0.94 (still below MIN_HEALTH, so rebalance fires) +// Queued health ≈ 0.94 (still below MIN_HEALTH, so rebalance fires) // -// Ideal topUp based on EFFECTIVE balance sheet: +// Ideal topUp based on Queued balance sheet: // debt_after = 576 / 1.3 ≈ 443.08 → topUp ≈ 172.30 MOET // -// If instead the topUp were based on RESERVE health only (old behaviour): +// If instead the topUp were based on Credited health only: // debt_after = 480 / 1.3 ≈ 369.23 → topUp ≈ 246.15 MOET // After queued deposit processes: health ≈ 1.56 (above MAX_HEALTH = 1.5 — needs drawdown!) // @@ -257,14 +257,14 @@ fun test_rebalance_topup_reduced_by_queued_deposit() { let user = setupPositionWithDebt() - // Drop FLOW price sharply so reserve health is well below 1.0. + // Drop FLOW price sharply so credited health is well below 1.0. setMockOraclePrice( signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.6 ) - // Queue 200 FLOW. Even with it the effective health is below MIN_HEALTH, + // Queue 200 FLOW. Even with it the queued health is below MIN_HEALTH, // so a rebalance will still be triggered — but the topUp should be sized // to reach targetHealth *including* the queued deposit's contribution. depositToPosition( @@ -296,21 +296,17 @@ fun test_rebalance_topup_reduced_by_queued_deposit() { } // ───────────────────────────────────────────────────────────────────────────── -// Test 5: Withdrawal from the queue is permitted when reserve health < minHealth -// but effective health (reserve + remaining queue) >= minHealth. +// Test 5: Withdrawal from the deposit queue is permitted when credited health < minHealth +// but queued health >= minHealth, and the withdrawal would not push queued health below minHealth. // // With: 1000 FLOW @ $0.75, cf=0.8 → reserve effectiveCollateral = 600 // MOET debt ≈ 615.38 -// Reserve health ≈ 0.975 (< 1.0 — below liquidation threshold) +// Credited health ≈ 0.975 (< 1.0 — below liquidation threshold) // -// Queue 200 FLOW → effective health = (1000+200)*0.75*0.8 / 615.38 ≈ 1.17 +// Queue 200 FLOW → queued health = (1000+200)*0.75*0.8 / 615.38 ≈ 1.17 // -// Withdraw 50 FLOW from the queue (reserveWithdrawAmount = 0): -// Effective health after = (1000+150)*0.75*0.8 / 615.38 ≈ 1.12 >= minHealth(1.1) ✓ -// -// Old behaviour: the preflight used reserve health (0.975 < minHealth) and blocked -// this withdrawal even though no reserve tokens were touched. -// New behaviour: the preflight uses effective health and permits it. +// Withdraw 50 FLOW from the queue (the reserve is not touched): +// Queued health after = (1000+150)*0.75*0.8 / 615.38 ≈ 1.12 >= minHealth(1.1) ✓ // ───────────────────────────────────────────────────────────────────────────── access(all) fun test_withdrawal_from_queue_permitted_when_reserve_health_below_min() { @@ -319,18 +315,18 @@ fun test_withdrawal_from_queue_permitted_when_reserve_health_below_min() { let user = setupPositionWithDebt() - // Drop FLOW price so that reserve health falls below 1.0. + // Drop FLOW price so that credited health falls below 1.0. setMockOraclePrice( signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.75 ) - // Confirm reserve health < 1.0. - let reserveHealth = getPositionHealth(pid: pid, beFailed: false) - Test.assert(reserveHealth < 1.0, message: "Expected reserve health < 1.0, got \(reserveHealth)") + // Confirm credited health < 1.0. + let creditedHealth = getPositionHealth(pid: pid, beFailed: false) + Test.assert(creditedHealth < 1.0, message: "Expected credited health < 1.0, got \(creditedHealth)") - // Queue 200 FLOW — brings effective health to ≈ 1.17, well above minHealth(1.1). + // Queue 200 FLOW — brings queued health to ≈ 1.17, well above minHealth(1.1). depositToPosition( signer: user, positionID: pid, @@ -342,7 +338,7 @@ fun test_withdrawal_from_queue_permitted_when_reserve_health_below_min() { let userFlowBefore = getBalance(address: user.address, vaultPublicPath: /public/flowTokenReceiver)! // Withdraw 50 FLOW — entirely from the queue (reserveWithdrawAmount = 0). - // Effective health after ≈ 1.12 >= minHealth, so this should succeed. + // Queued health after ≈ 1.12 >= minHealth, so this should succeed. withdrawFromPosition( signer: user, positionId: pid, @@ -374,7 +370,7 @@ fun test_withdrawal_from_queue_permitted_when_reserve_health_below_min() { // // With: 1000 FLOW reserve @ $1.0, cf=0.8, bf=1.0 // MOET debt ≈ 615.38 (drawn at position creation, targetHealth=1.3) -// Reserve health = 1000*1.0*0.8 / 615.38 ≈ 1.3 (at target) +// Credited health = 1000*1.0*0.8 / 615.38 ≈ 1.3 (at target) // availableBalance(MOET) ≈ 0 (already at target health) // // Queue 500 FLOW (deposit cap exhausted, goes to queue). @@ -414,12 +410,12 @@ fun test_queued_collateral_does_not_enable_cross_type_borrow() { } // ───────────────────────────────────────────────────────────────────────────── -// Test 8: Withdrawal is rejected when it would drop effective health below 1.0, +// Test 7: Withdrawal is rejected when it would drop queued health below 1.0, // even if part of it comes from the queue. // // With: 1000 FLOW @ $0.75, cf=0.8 → reserve effectiveCollateral = 600 -// MOET debt ≈ 615.38, reserve health ≈ 0.975 -// Queue 100 FLOW → effective health = 1100*0.75*0.8/615.38 ≈ 1.07 +// MOET debt ≈ 615.38, credited health ≈ 0.975 +// Queue 100 FLOW → queued health = 1100*0.75*0.8/615.38 ≈ 1.07 // // Withdraw 200 FLOW (100 from queue, 100 from reserve): // Effective credit after = (1000-100) + (100-100) = 900 From f820826e0b989e3db28368535f5e8e6fa40a987e Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Wed, 8 Apr 2026 16:25:45 -0700 Subject: [PATCH 10/13] fix post-withdrawal safety check --- cadence/contracts/FlowALPv0.cdc | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index bf87dffb..7e785148 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -465,7 +465,6 @@ access(all) contract FlowALPv0 { /// /// This is the appropriate value for borrow-capacity and available-balance queries. Use /// `_getQueuedBalanceSheet` for liquidation and rebalancing decisions. - // TODO: make this output enumeration of effective debts/collaterals (or provide option that does) access(all) fun positionHealth(pid: UInt64): UFix128 { let balanceSheet = self._getCreditedBalanceSheet(pid: pid) return balanceSheet.health @@ -1404,10 +1403,10 @@ access(all) contract FlowALPv0 { withdrawn.deposit(from: <-fromReserve) } - // Post-withdrawal safety check: queued health must be >= 1.0. - // Uses queued health (reserve + remaining queued deposits) for consistency with - // the liquidation and rebalancing checks. - let postHealth = self._getQueuedBalanceSheet(pid: pid).health + // Post-withdrawal safety check: credited health must be >= 1.0. + // Uses withdrawal balance sheet instead of credited balance sheet + // to allow withdrawals from the deposit queue. + let postHealth = self._getWithdrawalBalanceSheet(pid: pid, withdrawType: type, withdrawAmount: 0.0).health assert( postHealth >= 1.0, message: "Post-withdrawal position health (\(postHealth)) is unhealthy" From 1cbf2243bdbc759c793b28893666da765477da60 Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 9 Apr 2026 16:11:34 -0700 Subject: [PATCH 11/13] Factor out helper to update queued deposits --- cadence/contracts/FlowALPv0.cdc | 41 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 34ed35f0..efe18c6e 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1349,7 +1349,7 @@ access(all) contract FlowALPv0 { // Safety checks! self._assertPositionSatisfiesMinimumBalance(type: type, position: position, tokenSnapshot: tokenSnapshot) - // Post-withdrawal safety check: credited health must be >= 1.0. + // Post-withdrawal safety check: credited health must be >= minHealth. // Uses withdrawal balance sheet instead of credited balance sheet // to allow withdrawals from the deposit queue. let postHealth = self._getWithdrawalBalanceSheet(pid: pid, withdrawType: type, withdrawAmount: 0.0).health @@ -1873,36 +1873,35 @@ access(all) contract FlowALPv0 { !self.state.isPositionLocked(pid): "Position is not unlocked" } self.lockPosition(pid) + // First check if we can deposit from the deposit queue + self._updateQueuedDeposits(pid: pid) + + // Now that we've deposited a non-zero amount of any queued deposits, we can rebalance + // the position if necessary. + self._rebalancePositionNoLock(pid: pid, force: false) + self.unlockPosition(pid) + } + + /// Processes deposits to the position from the deposit queue, according to the position's current depositLimit for each token. + /// This helper is intentionally effects-only: it assumes all higher-level preconditions have already been enforced by the caller + access(self) fun _updateQueuedDeposits(pid: UInt64) { let position = self._borrowPosition(pid: pid) // store types to avoid iterating while mutating let depositTypes = position.getQueuedDepositKeys() - // First check queued deposits, their addition could affect the rebalance we attempt later for depositType in depositTypes { - let queuedVault <- position.removeQueuedDeposit(depositType)! - let queuedAmount = queuedVault.balance let depositTokenState = self._borrowUpdatedTokenState(type: depositType) let maxDeposit = depositTokenState.depositLimit(pid: pid) - - if maxDeposit >= queuedAmount { - // We can deposit all of the queued deposit, so just do it and remove it from the queue - + if maxDeposit > 0.0 { + let queuedVault <- position.removeQueuedDeposit(depositType)! + if maxDeposit < queuedVault.balance { + // We can't deposit the entire amount, so put the remainder back in the queue + let remainingQueued = queuedVault.balance - maxDeposit + position.depositToQueue(depositType, vault: <-queuedVault.withdraw(amount: remainingQueued)) + } self._depositEffectsOnly(pid: pid, from: <-queuedVault) - } else { - // We can only deposit part of the queued deposit, so do that and leave the rest in the queue - // for the next time we run. - let depositVault <- queuedVault.withdraw(amount: maxDeposit) - self._depositEffectsOnly(pid: pid, from: <-depositVault) - - // We need to update the queued vault to reflect the amount we used up - position.depositToQueue(depositType, vault: <-queuedVault) } } - - // Now that we've deposited a non-zero amount of any queued deposits, we can rebalance - // the position if necessary. - self._rebalancePositionNoLock(pid: pid, force: false) - self.unlockPosition(pid) } /// Updates interest rates for a token and collects stability fee. From 4cc2231c94744e13057d0bb70e9b385bf6e08c0c Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 9 Apr 2026 16:12:46 -0700 Subject: [PATCH 12/13] calculate queued amounts after potential topUp --- cadence/contracts/FlowALPv0.cdc | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index efe18c6e..cb6abe91 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1280,13 +1280,6 @@ access(all) contract FlowALPv0 { let tokenState = self._borrowUpdatedTokenState(type: type) let tokenSnapshot = self.buildTokenSnapshot(type: type) - // Queued deposits are held in the position but have not yet been credited to the reserve - // or the position's balance. Satisfy as much of the withdrawal as possible from them - // first; only the remainder needs to come from (and affect) the reserve. - let queuedBalanceForType: UFix64 = position.getQueuedDepositBalance(type) ?? 0.0 - let queuedUsable = queuedBalanceForType < amount ? queuedBalanceForType : amount - let reserveWithdrawAmount: UFix64 = amount - queuedUsable - if pullFromTopUpSource { if let topUpSource = position.borrowTopUpSource() { // NOTE: getSourceType can lie, but we are resilient to this because: @@ -1310,6 +1303,13 @@ access(all) contract FlowALPv0 { } } + // Queued deposits are held in the position but have not yet been credited to the reserve + // or the position's balance. Satisfy as much of the withdrawal as possible from them + // first; only the remainder needs to come from (and affect) the reserve. + let queuedBalanceForType: UFix64 = position.getQueuedDepositBalance(type) ?? 0.0 + let queuedUsable = queuedBalanceForType < amount ? queuedBalanceForType : amount + let reserveWithdrawAmount: UFix64 = amount - queuedUsable + // Pull any queued (un-credited) deposits for this token type first. // These tokens are held in the position and have never entered the reserve, // so they can be returned directly with no balance or reserve accounting. From 5335a4a3cbd858d40800ffe0182bbe5a094ace2f Mon Sep 17 00:00:00 2001 From: Tim Barry Date: Thu, 9 Apr 2026 16:13:30 -0700 Subject: [PATCH 13/13] Try process queued deposits before withdrawal This is necessary to prevent pulling an unnecessary topUp when there are already queued deposits that could be successfully deposited and back the new withdrawal. --- cadence/contracts/FlowALPv0.cdc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index cb6abe91..66cf3129 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1280,6 +1280,9 @@ access(all) contract FlowALPv0 { let tokenState = self._borrowUpdatedTokenState(type: type) let tokenSnapshot = self.buildTokenSnapshot(type: type) + // First process any queued deposits if we can, since queued deposits can't be borrowed against + self._updateQueuedDeposits(pid: pid) + if pullFromTopUpSource { if let topUpSource = position.borrowTopUpSource() { // NOTE: getSourceType can lie, but we are resilient to this because: