Curve Finance Hack: When the Vyper Compiler Itself Has the Bug
In July 2023, Curve Finance lost $73M because Vyper's @nonreentrant decorator silently broke in versions 0.2.15–0.3.0. The contracts were correct. The compiler wasn't. Here's how a toolchain bug drained pETH, msETH, and alETH pools.
The bug: a decorator that did nothing
On July 30, 2023, four Curve Finance stable pools were drained for roughly $73M in a coordinated reentrancy attack. The pools shared one thing in common — they were written in Vyper, and specifically compiled with one of three versions: 0.2.15, 0.2.16, or 0.3.0.
Nothing was wrong with the Curve source code. The pools used `@nonreentrant('lock')` on every sensitive function, exactly as the Vyper docs recommend. The problem was that the compiler itself emitted broken bytecode for that decorator. The reentrancy mutex's storage slot allocation was botched, meaning the lock either never wrote, never checked, or wrote to a slot that wasn't read on entry. Either way: the door was unlocked.
This is the worst class of bug in smart contract development. You can read the source line by line, run Slither, run Mythril, hire three auditors, and still ship a vulnerable contract — because the vulnerability isn't in the source. It's in the translation layer between source and EVM.
What the Vyper code looked like
Here's a simplified version of the pattern that got drained. This is what `remove_liquidity` looked like in the pETH/ETH pool:
```solidity
# Vyper (illustrative)
@external
@nonreentrant('lock')
def remove_liquidity(
_amount: uint256,
_min_amounts: uint256[N_COINS]
) -> uint256[N_COINS]:
amounts: uint256[N_COINS] = self._balances()
lp_token: address = self.lp_token
total_supply: uint256 = ERC20(lp_token).totalSupply()
for i in range(N_COINS):
value: uint256 = amounts[i] * _amount / total_supply
assert value >= _min_amounts[i]
if i == 0:
raw_call(msg.sender, b"", value=value) # sends ETH
else:
ERC20(self.coins[i]).transfer(msg.sender, value)
CurveToken(lp_token).burnFrom(msg.sender, _amount)
return amounts
```
Reads correctly. The `@nonreentrant('lock')` is right there. ETH gets sent before the LP token is burned — which is fine, because the lock should block reentry.
Except it didn't.
What the attacker did
The attacker's flow against the pETH/ETH pool, condensed:
1. Take a flash loan in WETH, unwrap to ETH.
2. Call `add_liquidity` to mint LP tokens at a normal-looking price.
3. Call `remove_liquidity`. Mid-execution, the pool sends ETH to the attacker's contract via `raw_call`.
4. The attacker's `receive()` fallback re-enters the same pool — calling `add_liquidity` or `remove_liquidity` again.
5. Because the lock is broken, the pool's internal state (specifically the `D` invariant and token balances) is now inconsistent. Virtual price reads stale balances. LP tokens get minted at a wildly favorable rate, or removed liquidity is double-counted.
6. Repeat, repay flash loan, walk away with ~$11M from pETH alone.
The same template, with minor variations, hit msETH ($1.6M), alETH ($13.6M), and CRV/ETH ($47M). The CRV/ETH drain was the largest single hit and almost cascaded into a CRV-collateralized lending crisis on Aave and Frax.
The fix: at the contract level and the compiler level
There is no fix you can write in Vyper if the compiler is broken. Curve's mitigation, for new deployments, was simply to pin to a known-good Vyper version (0.3.1 or later, where the slot allocation bug was patched). Older pools — those already deployed with 0.2.15/0.2.16/0.3.0 bytecode — could not be retroactively fixed; the bytecode is immutable.
If you must defend at the contract level against a compiler you don't trust, you write the lock manually:
```solidity
# Vyper — manual reentrancy guard, compiler-version-agnostic
locked: bool
@external
@payable
def remove_liquidity(_amount: uint256, _min_amounts: uint256[N_COINS]):
assert not self.locked, "reentrant call"
self.locked = True
... balance math, transfers, raw_call ...
self.locked = False
```
This is uglier, costs more gas, and is exactly what the decorator was supposed to do for you — but it survives a miscompiled decorator because you're not relying on the decorator at all. The bytecode for `assert not self.locked` and `self.locked = True` is mechanical SLOAD/SSTORE; there's nothing for the compiler to get clever about.
The Solidity equivalent of the same defense is OpenZeppelin's `ReentrancyGuard`, which uses a uint256 status flag and the checks-effects-interactions pattern. It's not immune to compiler bugs in principle, but it's been hammered on by every Solidity version since 0.6 and the pattern is simple enough that miscompilation would be obvious.
Real lessons for anyone shipping Vyper or Solidity
**Pin your compiler version exactly.** Not `^0.8.0`. Not `>=0.3.0`. Exactly `0.8.24` or `0.3.10`. Track which version has known issues — Vyper maintains a list, Solidity does too in the release notes. If a version has been used in production for less than six months, treat it as suspect.
**Diff the bytecode of critical functions.** When you upgrade the compiler, recompile the old contract with the new version and compare. If a security-critical function's bytecode changed in a way you can't explain, stop and read the changelog. This is tedious. Do it anyway.
**Don't trust language-level abstractions on security boundaries.** Decorators, modifiers, and standard library guards are all syntactic sugar over storage operations. If the sugar matters for security, write the storage operations yourself for the most critical paths.
**Get a fresh set of eyes on every deploy.** Static analysis won't catch a compiler bug. Manual review by someone who reads bytecode might. We run an automated tool at [cryptohawking.com/audit](https://www.cryptohawking.com/audit) that flags pinned vs. floating compiler versions and known-vulnerable releases for free, and for pools or protocols holding real TVL we offer a [3-day manual audit](https://www.cryptohawking.com/audit/manual) that includes bytecode-level verification against known compiler issues.
The uncomfortable conclusion
The Curve hack was not a Curve bug. The team did everything you'd want a protocol team to do: they used the language's recommended primitive, they had audits, they had years of production hardening. They lost $73M because of a tool. Every Solidity dev should sit with that for a minute.
Your threat model has to include the compiler. The runtime. The RPC node. The frontend. The wallet. Everything in the stack between intent and execution can betray you, and "the source code looks fine" is not — and has never been — a security argument.
FAQ
Why didn't audits catch the Curve Vyper bug?
Audits review source code. The Vyper compiler bug was in the bytecode emitted for the @nonreentrant decorator — the source was correct. Catching it would have required either bytecode-level review of every compiled function, or running the contracts through a fuzzer that specifically tested cross-function reentrancy on ETH callbacks. Both are uncommon in standard audits. After this incident, several audit firms (including us) added compiler-version checks against known-vulnerable releases as a standard pre-flight step. It's cheap, mechanical, and would have flagged the affected Curve pools immediately.
Which Vyper versions are affected by the reentrancy lock bug?
Vyper 0.2.15, 0.2.16, and 0.3.0 emit broken bytecode for the @nonreentrant decorator. The reentrancy mutex storage slot was miscompiled such that the lock never properly engaged. Versions 0.3.1 and later fixed the issue. If you have a contract compiled with one of the affected versions and it relies on @nonreentrant for security, that contract is exploitable and cannot be patched — bytecode is immutable. Migration to a redeployed contract on a fixed compiler is the only remediation.
Can a compiler bug like this happen in Solidity?
Yes, and it has. Solidity has shipped multiple compiler bugs over the years — the 'storage write removal' bug in 0.8.13–0.8.17, the ABI encoder v2 calldata bug in 0.5.x, the immutable variable initialization bug, and others. None have caused a Curve-scale incident yet, but the risk class is identical. The Solidity team publishes a bugs.json listing affected versions per release. Any serious deployment should pin an exact version, check it against that list, and re-check whenever the team announces a fix.
Should I write my own reentrancy guard instead of using a library?
For most cases, no — use OpenZeppelin's ReentrancyGuard. It's battle-tested across hundreds of billions in TVL and the pattern is simple enough that miscompilation would be loud and obvious. But for the most security-critical functions in a large protocol — anything handling native ETH callbacks, anything in a stable pool, anything where reentry could break an invariant — writing the lock inline as an explicit storage flag is a reasonable defense-in-depth move. It costs ~100 gas more and removes one layer of trust from your stack.
How do I know if my deployed contracts are using a vulnerable compiler?
Check the metadata hash. Every Solidity and Vyper contract embeds a compiler version in its deployed bytecode metadata. Tools like Etherscan show it on the verified source page; programmatically you can decode the CBOR metadata at the end of the runtime bytecode. Cross-reference against the language's published bug list. If you want this automated across a whole protocol, our free scanner at cryptohawking.com/audit pulls compiler version from on-chain bytecode and flags any contract built with a release that has known security issues.