Every audited line was correct. Vyper versions 0.2.15, 0.2.16 and 0.3.0 contained a bug in set_storage_slots that gave each @nonreentrant('lock') its own storage slot instead of one shared slot per lock name. Two functions declaring the same named lock therefore did not share it. Source code that read as fully protected compiled to bytecode that had no cross-function protection at all. The attacker re-entered through the raw-ETH callback in remove_liquidity and drained four pools.
# What every auditor read, and correctly approved: @external @payable @nonreentrant('lock') def add_liquidity(...): ... @external @nonreentrant('lock') def remove_liquidity(_amount: uint256, _min_amounts: uint256[N]): ... raw_call(msg.sender, b"", value=eth_amount) # attacker's fallback runs here self.balances = ... # state written after # In Vyper 0.2.15 – 0.3.0 those two 'lock's compiled to # add_liquidity → storage slot A # remove_liquidity → storage slot B # One lock per function is the same as no lock at all. → fixed in Vyper 0.3.1+ · "audited source" ≠ "audited bytecode"
Entries in the SAFE database that describe this failure. These share its failure class.
Every figure on this page comes from the post-mortems above, not from us. Losses are US dollars at the time of the incident.
Compiler / toolchain + Reentrancy is one of the 203 classes the SaferICO scanner checks for. It will not review your signing process — but it will read your Solidity.