---
title: "Flow Guards (Reentrancy and MEV Protection)"
description: "Multi-layered temporal protection against callback, reentrancy, and JIT manipulation attacks"
audience: tech
type: reference
status: live
lang: en
updated: "2026-09-04"
publish: true
---
# 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.

| Layer | Mechanism | Scope | Attack vector | Storage |
|-------|-----------|-------|---------------|---------|
| **L1** | Reentrancy mutex | Same transaction | Callback reentrancy | EIP-1153 transient |
| **L1b** | Flash-in-flight flag | Same transaction | Repay-via-deposit principal double-count | EIP-1153 transient |
| **L2** | Per-holder receipt cooldown | Cross-transaction | JIT liquidity / MEV bundles | Persistent, 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.

```mermaid
sequenceDiagram
    participant User
    participant Pool
    participant Token

    User->>Pool: deposit()
    Pool->>Token: transferFrom()
    Token-->>Pool: callback deposit()
    Note over Pool: Inconsistent state
```

### Implementation (EIP-1153)

```solidity
// 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](/docs/3-4-oracles#82-staleness-protection)).

---

## 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:

```solidity
// 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:

| Flow | Entry | Exit | Where enforced |
|------|-------|------|----------------|
| Deposit → withdraw | `deposit()` | `withdraw()`, `withdrawTo()` | `LPToken._beforeTokenTransfer` (burn is the redeem path) |
| Deposit → rebalance out | `deposit()` | `swapLiability()` | `LPToken._beforeTokenTransfer` |
| Deposit → transfer out | `deposit()` | `LPToken.transfer` | `LPToken._beforeTokenTransfer` |

```mermaid
sequenceDiagram
    participant Attacker
    participant Pool

    Note over Attacker,Pool: Without Flow Guard (3 tx, 1 block)
    Attacker->>Pool: tx1 deposit
    Pool->>Pool: tx2 victim swap, LP fee raises the index
    Attacker->>Pool: tx3 withdraw

    Note over Attacker,Pool: With Flow Guard
    Attacker->>Pool: deposit starts timer
    Attacker--xPool: withdraw BLOCKED
```

---

## How the time-based flow guard works

### Architecture

```mermaid
graph LR
    LPT[LPToken] --> L["locks[holder] = stamp,frozen"]
    PS[PoolStorage] --> C[flowCooldownSecs]
    LPT --> C
```

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

```solidity
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`:

```solidity
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 $2^{16}-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

| Property | Reentrancy guard (L1) | Flow guard (L2) |
|----------|----------------------|-----------------|
| **Scope** | Same transaction | Cross-transaction |
| **Duration** | Single function call | `flowCooldownSecs` (default 15 s, max 300 s) |
| **Attack vector** | Callback reentrancy | JIT liquidity / MEV |
| **Storage** | Transient TLOAD/TSTORE flag | Persistent `{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 |
| **Frequency** | Every external call | Entry + exit check |
| **Cleanup** | Automatic at end of transaction | None 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:

| Case | Slot transition | Cost |
|---|---|---|
| Holder's **first** deposit into this leg | zero → non-zero | 20,000 `SSTORE_SET` + 2,100 cold access = **~22,100** |
| Every later deposit | non-zero → non-zero | 2,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

```solidity
// 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

| Layer | Mechanism | Prevents |
|-------|-----------|----------|
| **L1: Reentrancy** | Transient storage flag | Callback attacks during operation |
| **L1b: Flash guard** | Transient flash-in-flight flag | Repay-via-deposit principal double-count |
| **L2: Flow guard** | Timestamped cooldowns | JIT liquidity and MEV bundling |
| **L3: Pricing** | Inventory-skew mid shift + convex coverage toll (spread itself is symmetric) | Toxic/coverage-worsening trades |
| **L4: Oracle** | External keeper mark: TTL + confidence gates, staleness surcharge, push deviation clamp | Price feed manipulation (no internal TWAP) |
| **L5: Volatility** | Spread adjustment | Exploitation during uncertainty |

---

## Related documentation

- [Security Overview](/docs/3-overview)
- [1.1.4. Spread & Fees](/docs/1-1-4-spread-fees)
- [1.1.6. Toxic Flow Mitigation](/docs/1-1-6-toxic-flow-mitigation)
