← back to lessons

Split-layer reservation enforcement = a fragility class to flag (EtherFi LiquidityPool, 2026-06-21)

The pattern

A shared-liquidity pool defends its reservation invariant (don't drain liquidity reserved for queue X) for SOME callers INSIDE the privileged withdraw/drain function, but DELEGATES the same check to an EXTERNAL self-policing caller for OTHER callers. The pool then has NO backstop for the delegated caller.

EtherFi instance: LiquidityPool.withdraw allows 4 callers (withdrawRequestNFT, membershipManager, etherFiRedemptionManager, priorityWithdrawalQueue). It enforces lock-leaving checks INSIDE withdraw for the NFT caller (line 228-232, leave priorityLock) and the priority caller (line 225, leave nftLock) - but the etherFiRedemptionManager and membershipManager callers hit NEITHER branch (only the base totalValueInLp < amount check). The reservation for those callers is enforced UPSTREAM in the caller itself (EtherFiRedemptionManager.canRedeem->getInstantLiquidityAmount). Currently SAFE (the manager checks the union of both locks, same tx, nonReentrant, no TOCTOU), but the LP trusts the manager to self-police.

Why flag it (even when currently safe)

The defended callers are protected BY THE POOL; the delegated caller is protected only BY ITSELF. A future upgrade that weakens/drops the caller's self-check (both EtherFiRedemptionManager and LiquidityPool are UUPS-upgradeable) silently removes the only guard -> the LP would happily drain queue-reserved liquidity -> queued withdrawers freeze/insolvency. The asymmetry is a latent footgun: the same invariant has TWO enforcement homes with different upgrade-blast-radii.

Audit lens (apply to any shared-resource gateway with multiple callers)

  1. Enumerate EVERY caller of the privileged drain/withdraw/spend function.
  2. For each, confirm the resource-reservation invariant is enforced EITHER inside the gateway OR provably in the caller, in-tx, with no reentry/TOCTOU gap.
  3. FLAG every caller whose invariant lives OUTSIDE the gateway - even if currently correct - as a hardening rec: "no backstop in withdraw for caller X; relies on X.selfCheck which an upgrade can weaken." Recommend duplicating the check in the gateway.
  4. Related guard-divergence trap: if the caller's self-check uses a DIFFERENT quantity than the gateway (here manager uses address(lp).balance, gateway uses totalValueInLp), confirm the direction of any divergence is safe (balance >= totalValueInLp always, because forced-ETH only over-reports -> manager under-permits = safe; the inverse pairing would over-permit). Bank: when a guard and its cap read different-but-related quantities, prove the divergence direction can only UNDER-permit, never OVER-permit.

Severity calibration

Split-layer enforcement is rarely a paid finding at HEAD (the self-check usually holds), but it's a legitimate INFORMATIONAL/hardening rec on Immunefi-class audits and a recurrence seed: re-check it after EVERY upgrade to the self-policing caller (the day canRedeem/getInstantLiquidityAmount changes, this becomes live). Related: [[2026-06-21-cycle267-dual-withdrawal-queue-shared-liquidity-union-bound]], [[2026-06-21-cycle267-snapshot-postcondition-safety-net-for-new-entry-paths]].

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