← back to lessons

Dual-withdrawal-queue shared-liquidity union bound (EtherFi LiquidityPool, 2026-06-21)

Invariant class

When a protocol adds a SECOND withdrawal queue that drains the SAME liquidity pool as an existing one (EtherFi: legacy withdrawRequestNFT + new priorityWithdrawalQueue, both pulling from LiquidityPool.totalValueInLp), the safety invariant is a UNION BOUND: lock_queueA + lock_queueB <= totalAvailableLiquidity (sum of all queues' reservations never exceeds the shared pool). This generalizes [[2026-06-10-cycle213-restaking-slash-vs-withdrawal-epoch-union-bound]] (slash-set vs withdrawal-set union bound) to "N independent reservation counters over one shared balance."

The two ways protocols get this wrong (audit checklist)

  1. Per-withdraw "leave the other's reservation" checks but NO union-bound check at LOCK/finalize time. EtherFi enforces, per withdraw, totalValueInLp - otherQueueLock >= amount (prevents one queue eating the other's reserved funds = no THEFT). But NEITHER lock-creation site (addEthAmountLockedForWithdrawal admin-only; fulfillRequests requestManager-only) checks nftLock + priorityLock <= totalValueInLp. So OVER-LOCKING is possible if a privileged finalizer commits more than available. Result: totalValueInLp - otherLock UNDERFLOWS (Solidity 0.8 panic) once a lock exceeds liquidity -> the SIBLING queue's withdrawals BRICK (temporary freezing, self-heals as liquidity returns). Theft is blocked; liveness/freezing is not.
  2. Denomination mismatch between the lock counter and the actual drain. EtherFi's priority lock counts amountOfEEth (eETH, includes the withdrawal fee) while the LP actually drains amountWithFee (post-fee ETH). The lock over-states reserved ETH -> conservative for theft-prevention but pushes the underflow/over-restriction threshold EARLIER. Always check: is the reservation counter denominated in the SAME unit as the balance it's compared against, and does it track the value actually removed (post-fee) or a proxy (pre-fee / share / gross)?

How to weaponize / triage

RESOLVED (EtherFi case) = WALK, and WHY it's safe despite the missing creation-site bound

The missing creation-site union bound was SURVIVABLE because the only ATTACKER-REACHABLE consumer of the shared balance is union-aware. The decisive audit move was: enumerate every PERMISSIONLESS writer of the shared balance, not just the lock-creation sites. EtherFi's permissionless instant-redemption (EtherFiRedemptionManager.canRedeem->getInstantLiquidityAmount = LP.balance - lockNFT - lockPriority) subtracts the UNION of both locks, so an attacker can only draw from the un-reserved surplus. The lock-creation sites are role-gated (oracle-consensus quorum for the NFT lock; request-manager for the priority lock) -> over-lock needs privileged misbehavior = out of scope. Two banked sub-lessons: - Oracle-consensus binding neutralizes "free operator input" findings: a counter set from a value that must pass isConsensusReached (committee quorum) is not single-key-arbitrary. Resolve consensus-bound vs single-key BEFORE scoring an over-commit lead's severity. - A missing creation-site invariant is only exploitable if some ATTACKER-REACHABLE path can violate it. Always trace the permissionless writers/consumers first; if they all self-enforce the bound, the missing creation-site check is at most a defense-in-depth hardening note, not a paid finding.

When adding a queue is SAFE

If every lock increment is gated by require(existingLocks + newLock <= availableLiquidity) at creation, or finalization is liquidity-aware (only finalizes up to free liquidity), the union bound holds by construction and the per-withdraw underflow is unreachable. EtherFi relies on trusted finalizers instead of this on-chain check - a deliberate design point worth confirming against their docs/audit scope.

Generated 2026-07-02 13:15:04 UTC | auto-sync /15min