diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index d6bb8022..66cf3129 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 _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 @@ -141,6 +141,27 @@ 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: + /// + /// - **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). + /// + /// - **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. + /// + /// `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) @@ -311,9 +332,11 @@ access(all) contract FlowALPv0 { } } - /// Returns true if the position is under the global liquidation trigger (health < 1.0) + /// 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.positionHealth(pid: pid) + let health = self._getQueuedBalanceSheet(pid: pid).health return health < 1.0 } @@ -383,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 { @@ -433,12 +458,15 @@ 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 **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. + /// + /// This is the appropriate value for borrow-capacity and available-balance queries. Use + /// `_getQueuedBalanceSheet` for liquidation and rebalancing decisions. access(all) fun positionHealth(pid: UInt64): UFix128 { - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) return balanceSheet.health } @@ -543,8 +571,11 @@ access(all) contract FlowALPv0 { self.lockPosition(pid) let positionView = self.buildPositionView(pid: pid) - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) - let initialHealth = balanceSheet.health + // 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 assert(initialHealth < 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>=1") // Ensure liquidation amounts don't exceed position amounts @@ -561,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 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() @@ -650,6 +682,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). @@ -671,12 +707,9 @@ 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 position = self._borrowPosition(pid: pid) - - let adjusted = self.computeAdjustedBalancesAfterWithdrawal( - initialBalanceSheet: balanceSheet, - position: position, + let balanceSheet = self._getWithdrawalBalanceSheet( + pid: pid, withdrawType: withdrawType, withdrawAmount: withdrawAmount ) @@ -684,11 +717,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. /// @@ -769,6 +846,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. @@ -787,6 +865,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. @@ -815,7 +894,7 @@ access(all) contract FlowALPv0 { return fundsAvailable + depositAmount } - let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let balanceSheet = self._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterDeposit( @@ -893,7 +972,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._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterDeposit( initialBalanceSheet: balanceSheet, @@ -916,7 +995,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._getCreditedBalanceSheet(pid: pid) let position = self._borrowPosition(pid: pid) let adjusted = self.computeAdjustedBalancesAfterWithdrawal( initialBalanceSheet: balanceSheet, @@ -1201,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: @@ -1214,7 +1296,7 @@ access(all) contract FlowALPv0 { withdrawType: type, withdrawAmount: amount ) - + let pulledVault <- topUpSource.withdrawAvailable(maxAmount: targetHealthDeposit) assert(pulledVault.getType() == purportedTopUpType, message: "topUpSource returned unexpected token type") self._depositEffectsOnly( @@ -1224,23 +1306,56 @@ access(all) contract FlowALPv0 { } } - if position.getBalance(type) == nil { - position.setBalance(type, FlowALPModels.InternalBalance( - direction: FlowALPModels.BalanceDirection.Credit, - scaledBalance: 0.0 - )) + // 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. + 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) } - // Reflect the withdrawal in the position's balance - position.borrowBalance(type)!.recordWithdrawal( - amount: UFix128(amount), - tokenState: tokenState - ) + // 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 + )) + } + + 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) + } // Safety checks! self._assertPositionSatisfiesMinimumBalance(type: type, position: position, tokenSnapshot: tokenSnapshot) - let postHealth = self.positionHealth(pid: pid) + // 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 if postHealth < position.getMinHealth() { if self.config.isDebugLogging() { let topUpType = position.borrowTopUpSource()?.getSourceType() ?? self.state.getDefaultToken() @@ -1265,8 +1380,6 @@ access(all) contract FlowALPv0 { } self._queuePositionForUpdateIfNecessary(pid: pid) - let reserveVault = self.state.borrowReserve(type)! - let withdrawn <- reserveVault.withdraw(amount: amount) FlowALPEvents.emitWithdrawn( pid: pid, @@ -1613,6 +1726,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 **credited 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" @@ -1621,21 +1740,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 creditedBalanceSheet = self._getCreditedBalanceSheet(pid: pid) + let queuedBalanceSheet = self._getQueuedBalanceSheet(pid: pid) - if !force && (position.getMinHealth() <= balanceSheet.health && balanceSheet.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 balanceSheet.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: balanceSheet, + initialBalanceSheet: queuedBalanceSheet, targetHealth: position.getTargetHealth() ) if self.config.isDebugLogging() { @@ -1649,7 +1770,7 @@ access(all) contract FlowALPv0 { FlowALPEvents.emitRebalanced( pid: pid, poolUUID: self.uuid, - atHealth: balanceSheet.health, + atHealth: creditedBalanceSheet.health, amount: pulledVault.balance, fromUnder: true ) @@ -1659,13 +1780,14 @@ access(all) contract FlowALPv0 { from: <-pulledVault, ) - // 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") + // 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") } - } else if balanceSheet.health > position.getTargetHealth() { - // The position is overcollateralized, - // we'll withdraw funds to match the target health and offer it to the sink. + } 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 return @@ -1675,7 +1797,7 @@ access(all) contract FlowALPv0 { let idealWithdrawal = self.computeAvailableWithdrawal( position: position, withdrawType: sinkType, - initialBalanceSheet: balanceSheet, + initialBalanceSheet: creditedBalanceSheet, targetHealth: position.getTargetHealth() ) if self.config.isDebugLogging() { @@ -1706,7 +1828,7 @@ access(all) contract FlowALPv0 { FlowALPEvents.emitRebalanced( pid: pid, poolUUID: self.uuid, - atHealth: balanceSheet.health, + atHealth: creditedBalanceSheet.health, amount: sinkVault.balance, fromUnder: false ) @@ -1754,36 +1876,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. @@ -1933,7 +2054,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 @@ -1949,6 +2073,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() { @@ -1958,9 +2083,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 **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 _getUpdatedBalanceSheet(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. @@ -1998,6 +2126,32 @@ access(all) contract FlowALPv0 { ) } + /// 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 + /// 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 credited health only). + access(self) fun _getQueuedBalanceSheet(pid: UInt64): FlowALPModels.BalanceSheet { + let position = self._borrowPosition(pid: pid) + var balanceSheet = self._getCreditedBalanceSheet(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..f2ef5762 --- /dev/null +++ b/cadence/tests/queued_deposits_health_test.cdc @@ -0,0 +1,454 @@ +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) +// Credited health = 560 / 615.38 ≈ 0.91 (< 1.0 → normally liquidatable) +// +// Queuing 200 FLOW @ $0.70, cf=0.8 adds 112 to effectiveCollateral: +// Queued 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 credited 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 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( + 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 queued 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 +// Queued 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, queued 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 +// 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: +// 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() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + let userMoetBefore = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)! + + // 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 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 queued 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 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. + 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 +// Credited health ≈ 0.78 (badly unhealthy — topUp required regardless) +// +// Queuing 200 FLOW @ $0.60, cf=0.8 adds 96 to effectiveCollateral: +// Queued health ≈ 0.94 (still below MIN_HEALTH, so rebalance fires) +// +// 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 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!) +// +// 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 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 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( + 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)" + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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 +// Credited health ≈ 0.975 (< 1.0 — below liquidation threshold) +// +// Queue 200 FLOW → queued health = (1000+200)*0.75*0.8 / 615.38 ≈ 1.17 +// +// 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() { + safeReset() + let pid: UInt64 = 0 + + let user = setupPositionWithDebt() + + // Drop FLOW price so that credited health falls below 1.0. + setMockOraclePrice( + signer: PROTOCOL_ACCOUNT, + forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, + price: 0.75 + ) + + // 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 queued 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). + // Queued 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: 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) +// 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). +// 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 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, 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 +// 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()) +} 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)) +}