Flow Guards (Reentrancy and MEV Protection)

A pool has to survive attacks on two timescales: a callback re-entering mid-operation inside one transaction, and a searcher depositing ahead of a known swap and withdrawing after it across a block. Three guards answer those two shapes.

LayerMechanismScopeAttack vectorStorage
L1Reentrancy mutexSame transactionCallback reentrancyEIP-1153 transient
L1bFlash-in-flight flagSame transactionRepay-via-deposit principal double-countEIP-1153 transient
L2Per-holder receipt cooldownCross-transactionJIT liquidity / MEV bundlesPersistent, one slot per holder

Layer 1: reentrancy guard (transaction level)

What it protects against

Reentrancy is when an external contract calls back into the pool during the execution of an operation, before that operation completes.

TokenPoolUserTokenPoolUserInconsistent statedeposit()transferFrom()callback deposit()

Implementation (EIP-1153)

// TransientGuard.sol abstract contract TransientGuard is ReentrancyGuardTransient { // Solady gates its transient path on mainnet by default; this override // forces TSTORE/TLOAD on every chain. function _useTransientReentrancyGuardOnlyOnMainnet() internal view virtual override returns (bool) { return false; } }

Pool inherits it and marks every external entrypoint nonReentrant: deposit, withdraw, swap, swapLiability, donate and the rest. The mutex is Solady’s slot, not a hand-rolled one: 0 = unlocked, 1 = operation in progress, cleared at the end of the transaction. Transient-cache helpers (oracle feeds, flash state) live in the dex-local TransientCacheLib.

What the reentrancy guard does not prevent

Same-block attacks that use no callback: JIT liquidity bundles, multi-transaction atomic sequences in one block, sandwiches, and oracle staleness attacks. Those need Layer 2, or the oracle gates (Oracles §8.2).


Layer 1b: flash-loan reserve guard (transaction level)

What it protects against

A flash loan pushes tokens out via flashSend without debiting reserves (the reserves are made whole when the borrower repays). The reserve-accounting must therefore ensure the borrower cannot “repay” through a path that credits reserves: otherwise the same principal is counted twice: once as the outstanding loan, once as a fresh deposit.

Attack (CRITICAL, now blocked): an ERC-3156 borrower, inside the flash callback, calls deposit() (or donate/swap, any reserve-crediting inflow) to “repay”. The pool credits the reserve for the deposit and treats the flash as repaid, so the attacker walks away with the loan principal. This was proven with a PoC (test/PoolFlashExploit.t.sol, EvilBorrower) and is guarded against by a regression test.

Implementation (EIP-1153)

A dedicated transient flag marks the window while a flash callback runs, and the single reserve-crediting chokepoint (PoolIOLib.pull, used by deposit, donate and swap: its only three call sites) refuses to run while it is set:

// libraries/PoolIOLib.sol uint256 private constant FLASH_INFLIGHT_SLOT = 0x9b4f3bbfca54a0e6e7a1f989e7a8421747090cf08b7f435d15e27a960bfc0532; // keccak256("btr.pool.flashInFlight.v1") function enterFlash() internal { assembly { tstore(FLASH_INFLIGHT_SLOT, 1) } } function exitFlash() internal { assembly { tstore(FLASH_INFLIGHT_SLOT, 0) } } function requireNoFlash() internal view { uint256 v; assembly { v := tload(FLASH_INFLIGHT_SLOT) } if (v != 0) revert Err.InvalidState(); } // pull(), the reserve-crediting inflow chokepoint, is gated at its top: function pull(...) internal returns (uint256) { requireNoFlash(); // blocked while a flash callback is in flight // ... }

PoolLiquidity.flashSend calls enterFlash() before pushing the loan and flashAccount calls exitFlash() after settlement, so the flag is set for exactly the callback window.

Key properties:

  • Slot-isolated from Solady’s ReentrancyGuard (distinct keccak namespace), so the two guards compose without interference.
  • Repayment path unaffected: legitimate ERC-3156 repayment is a plain transfer/approve, which never routes through pull.

Why it is distinct from layer 1

The reentrancy guard answers “can you re-enter the same operation?”; the flash-loan guard answers “can you credit reserves while a loan is out?”. A flash callback is an intended external call (not reentrancy), so the reentrancy mutex does not cover it, the reserve guard is the specific defense.


Layer 2: flow guard (block-level MEV protection)

What it protects against

JIT (Just-In-Time) Liquidity is an MEV strategy where sophisticated actors:

  1. Observe a pending large swap in the mempool
  2. Deposit liquidity in the block ahead of it
  3. Capture swap fees from the large trade
  4. Withdraw liquidity in the same block, in a third transaction ordered after the victim’s

The shape is live on this AMM: a swap’s LP fee is booked into the leg’s liabilities and raises liquidityIndexWad (PoolLiquidity.accrueLpFee, called from PoolIOLib.settle and the flash and cross-withdraw paths), so a depositor present for the swap earns a pro-rata share of it.

Step 4 is not atomic-achievable against a third party’s swap. It needs three transactions in one block (deposit, victim, withdraw), which a bundle builder can order but a single transaction cannot contain, and the flash-callback shape that would collapse it into one is blocked: withdrawTo calls requireNoFlash (PoolLiquidity.sol). The attacker holds real inventory for at least one block.

This extracts value that should go to long-term LPs, at one block of price risk rather than none.

Protected flows

Every outflow of a freshly minted receipt is covered, all at one enforcement point:

FlowEntryExitWhere enforced
Deposit → withdrawdeposit()withdraw(), withdrawTo()LPToken._beforeTokenTransfer (burn is the redeem path)
Deposit → rebalance outdeposit()swapLiability()LPToken._beforeTokenTransfer
Deposit → transfer outdeposit()LPToken.transferLPToken._beforeTokenTransfer
PoolAttackerPoolAttackerWithout Flow Guard (3 tx, 1 block)With Flow Guardtx1 deposittx2 victim swap, LP fee raises the indextx3 withdrawdeposit starts timerwithdraw BLOCKED

How the time-based flow guard works

Architecture

LPToken

locks[holder] = stamp,frozen

PoolStorage

flowCooldownSecs

Each leg receipt (LPToken, one clone per (pool, leg)) holds locks[holder] = {uint32 stamp, uint224 frozen}. mint stamps block.timestamp and adds the minted quantity to frozen, resetting it when the previous lock has already expired. _beforeTokenTransfer then bounds outflow by balance - frozen until stamp + cooldown, so the guard covers withdraw (a burn), swapLiability and plain ERC-20 transfers alike. A lock older than MAX_FLOW_COOLDOWN is short-circuited without reading the pool at all, since no live window can reach that far.

The window is the pool’s own flowCooldownSecs, read by STATICCALL: one source of truth, no per-token copy to keep in sync.

The lock freezes an amount, not an account: a dust deposit routed through an ERC-4626 wrapper locks the dust, not the wrapper’s whole pooled balance. Minting to an arbitrary recipient is therefore forbidden by construction; otherwise a third party could dust-mint once per window and hold a victim’s whole recent balance frozen indefinitely.

Time-based, not block-based

The window is measured in seconds, so it is identical under the variable block times of different EVM chains rather than tracking validator behaviour.


Configuration

Default value

uint16 constant DEFAULT_FLOW_COOLDOWN = 15; // 15 seconds

15 s spans several blocks on every target chain and stays inside a normal user round-trip.

Admin control

The owner adjusts the cooldown through Admin:

Admin.setFlowCooldown(pool, 30); // 30 seconds Admin.setFlowCooldown(pool, 0); // disables the JIT guard entirely

flowCooldownSecs = 0 disables the flow guard outright. Load-bearing, since swap and flash fees move the liquidity index: with no cooldown an LP can deposit, wait for a known-inbound swap, and withdraw the fee.

Maximum value

Constants.MAX_FLOW_COOLDOWN = 300 seconds, enforced at the write (PoolConfig.setFlowCooldown reverts InvalidInput above it). The cap exists because the guard gates ERC-20 transfers of a live receipt: an unbounded uint16 would let one untimelocked admin key freeze every holder for 216-1 s, about 18.2 h.


Error handling

A cooldown violation reverts Err.CooldownActive(). The lock is public (LPToken.locks(holder)), so an integrator sizes an exit from balance - frozen rather than guessing at a remaining-seconds argument.


Defense-in-depth architecture

L1 vs L2 comparison

PropertyReentrancy guard (L1)Flow guard (L2)
ScopeSame transactionCross-transaction
DurationSingle function callflowCooldownSecs (default 15 s, max 300 s)
Attack vectorCallback reentrancyJIT liquidity / MEV
StorageTransient TLOAD/TSTORE flagPersistent {stamp, frozen} per holder
Gas~100 per TSTORE/TLOAD, ~100-200 per operation~22,100 to arm a holder’s first lock, ~5,000 on every later one; ~2,100 to check (cold SLOAD), ~100 warm
FrequencyEvery external callEntry + exit check
CleanupAutomatic at end of transactionNone needed; the stamp expires

Each layer covers what the other cannot. Without L1, a malicious token could re-enter withdraw() from its transfer callback during deposit() and pull funds out mid-operation; without L2, that same attacker needs no callback at all, only three transactions a builder will order for them.

The arm cost has two cases and they are far apart, because mint writes the packed {stamp, frozen} slot:

CaseSlot transitionCost
Holder’s first deposit into this legzero → non-zero20,000 SSTORE_SET + 2,100 cold access = ~22,100
Every later depositnon-zero → non-zero2,900 SSTORE_RESET + 2,100 cold access = ~5,000

The slot never returns to zero (_beforeTokenTransfer is view and nothing clears it on expiry), so a holder pays the 22,100 exactly once per leg and 5,000 thereafter. mint additionally STATICCALLs the pool for flowCooldownSecs, so the true arm cost is a little above both figures.

The exit check writes nothing: it is a view SLOAD, ~2,100 cold and ~100 warm, plus the same STATICCALL only when the lock is live and younger than MAX_FLOW_COOLDOWN.

Against a ~100-200k gas deposit that is roughly 11-22% on a holder’s first deposit into a leg and 2.5-5% on every later one. The reentrancy guard stays under 1% in both cases.

Layer 2 storage

// LPToken.sol struct Lock { uint32 stamp; uint224 frozen; } mapping(address => Lock) public locks;

stamp (uint32, good until year 2106) and frozen pack into one slot, so the check is one SLOAD. The window itself is a single uint16 flowCooldownSecs in the Pool’s own default storage, no ERC-7201 namespacing; each beacon proxy is a fresh storage space, so pools and leg receipts cannot collide.


What flow guards do not protect against

Layer 1 limitations (reentrancy guard)

  1. Multi-step attacks that require user interaction outside the callback.
  2. Cross-contract reentrancy: pool → A → B → pool.

Layer 2 limitations (time-based flow guard)

  1. Long-term statistical arbitrage: a depositor who stays a day still earns that day’s fees.
  2. Information-based trading: traders with alpha on future prices; not MEV extraction.
  3. Cross-pool attacks: coordination across multiple pools.
  4. Off-chain coordination: multiple wallets controlled by one entity.
  5. Oracle manipulation: handled by the oracle layer, not here.

Complementary defenses

LayerMechanismPrevents
L1: ReentrancyTransient storage flagCallback attacks during operation
L1b: Flash guardTransient flash-in-flight flagRepay-via-deposit principal double-count
L2: Flow guardTimestamped cooldownsJIT liquidity and MEV bundling
L3: PricingInventory-skew mid shift + convex coverage toll (spread itself is symmetric)Toxic/coverage-worsening trades
L4: OracleExternal keeper mark: TTL + confidence gates, staleness surcharge, push deviation clampPrice feed manipulation (no internal TWAP)
L5: VolatilitySpread adjustmentExploitation during uncertainty