← David Ryan's builder page

WETH9 security audit

Ethereum mainnet: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
Reviewed 2026‑08‑19 against block 25,787,146

Ether is the native currency of Ethereum, and ETH transfers are controlled by the chain itself. Tokens are not controlled by the chain directly, but are issued and controlled from a token contract, which follows the ERC-20 standard. DeFi contracts are written to accept any ERC-20 tokens, but they cannot handle raw ether.

WETH9 (usually called just "WETH") was made to tokenise raw ether for use in DeFi. Send ether to WETH contract and it credits you the same number of WETH tokens; send the tokens back and it returns your ether. Every WETH in circulation is backed one for one by ether sitting in the contract.

The wrapper was written in 2017, before ERC-20 practice had settled, so it keeps to the standard only loosely. Supply enters and leaves without the Transfer event that indexers expect. A call to a function that does not exist returns success instead of reverting. There is no permit, so every approval costs a transaction.

At the reviewed block the WETH contract held 2,209,291 ETH, roughly 1.8% of the total ether supply. Almost every Uniswap pool quotes its pair against WETH rather than against ether, and lending markets take WETH as collateral. Routers settle through it and bridges wrap before they move. That makes these 57 lines of Solidity for the WETH contract one of the most heavily depended-on pieces of code on any blockchain.

The contract is immutable. It has no owner and no upgrade path, so nobody can patch it, whatever the author or a governance vote might want. A serious flaw in it would have no fix, and every protocol holding WETH would have to evacuate by hand. So a clean result here is critical to the ongoing operation of DeFi.

This review found no exploitable vulnerability. The hazards it did find sit at the boundary where other contracts call WETH, which is where an integrator can act on them.

How this audit was made

I commissioned this audit from myself, to find out how far an AI can take a smart contract review. Claude (Opus 5) read the bytecode, ran the analysis and wrote most of what you see below. I set the scope and the method, checked every finding against the chain myself, and decided what was worth publishing.

So read it as AI work with a human check on it, not as a firm's audit. The findings are reproducible: every figure is pinned to one block, and the method section says exactly how each claim was reached.

Audit summary

WETH9 holds no exploitable vulnerability. The contract is 57 lines of Solidity with no owner, no upgrade path and no way to destroy it. All nine findings sit at the boundary where other contracts call WETH, and every one is a documented behaviour rather than a flaw in the wrapper.

Two findings rated Medium have caused real losses in downstream protocols. Neither can be fixed, because the contract is immutable. Integrators have to handle them, so the integration checklist is the part of this document most worth acting on.

Findings at a glance

IDSeverityFinding
M-1Mediumwithdraw() forwards only 2300 gas
M-2MediumNo Transfer event on deposit or withdraw
L-1LowUnknown function selectors succeed silently
L-2LowNo zero-address guard; ~2,134 WETH already stranded
L-3LowtotalSupply() reports the ETH balance, not the sum of balances
L-4LowMaximum allowance is never decremented
L-5Lowapprove() carries the classic ERC-20 race
I-1InfoETH cannot be sent to WETH with transfer() or send()
I-2InfoNo EIP-2612 permit

Counts: 0 critical, 0 high, 2 medium, 5 low, 2 informational.

Verified onchain facts

Address0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
DeployedBlock 4,719,568, 2017-12-12 11:17:35 UTC, 8.67 years ago
Deployer0x4F26FfBe5F04ED43630fdC30A87638d53D0b0876, an externally owned account
Creation tx0xb95343413e459a0f97461812111254163ae53467855c0d73e0f1e7c5b8442fa3
Compilersolc 0.4.19+commit.c4cbbb05, optimiser disabled
Runtime code3,124 bytes: 3,081 executable plus a 43-byte bzzr0 metadata trailer
Instructions1,555 decoded, zero unknown opcodes
Dispatch table11 selectors, matching the ERC-20 surface exactly
ETH held2,209,291.3665 ETH at block 25,787,146
Storage slots0 name, 1 symbol, 2 decimals, 3 balanceOf, 4 allowance

Method

I read the runtime bytecode from mainnet over JSON-RPC and disassembled it with an opcode walker that skips PUSH immediates. Findings about what the contract cannot do rest on that disassembly, not on reading the source.

Two method notes are worth recording, because both changed the result.

A naive byte search gives false positives. Searching the raw runtime code for dangerous opcodes reports one DELEGATECALL, two STATICCALLs and one EXTCODECOPY. None of them exists. Those bytes sit inside the 32-byte Swarm hash that Solidity appends after the executable region, where they are data. Stripping the 43-byte trailer removed all four and left a clean sweep with nothing unrecognised. A DELEGATECALL would have meant a proxy, so trusting the first pass would have inverted the most important claim in this audit.

The published source is not the deployed source. The canonical GitHub repository carries pragma solidity >=0.4.22 <0.6 and uses emit. The bytecode at this address was compiled from the earlier ^0.4.18 revision. All analysis below uses the deployed version, confirmed through Blockscout.

Every RPC read for the figures quoted here was pinned to a single block. An earlier pass mixed results across nodes and picked up a reader that lagged the tip by four days.

Findings

M-1 · withdraw() forwards only 2300 gas Medium

withdraw() pays out through msg.sender.transfer(wad). At program counter 2700 the bytecode pushes 0x08fc, which is 2300, and the surrounding ISZERO MUL pattern is the Solidity idiom that pins the callee to exactly that stipend.

A contract whose receive path costs more than 2300 gas cannot unwrap its own WETH. The tokens survive, since they still move as ERC-20, but that account can never convert them back to ETH by itself.

The risk grows over time. EIP-1884 raised SLOAD from 200 to 800 gas in the Istanbul fork, which broke contracts that were well inside budget when they were written. A future repricing can break more. Smart contract wallets and account-abstraction accounts are the usual casualties.

Mitigation. Never let a contract you control depend on WETH withdraw() paying into an expensive fallback. Withdraw to a thin receiver, or hold the position in WETH and let a user-facing router unwrap.

M-2 · No Transfer event on deposit or withdraw Medium

deposit() emits only Deposit. withdraw() emits only Withdrawal. Neither emits the Transfer event that ERC-20 indexers expect for a mint or a burn.

The bytecode settles this. Four LOG sites exist in the whole contract, and only one is the LOG3 that emits Transfer, at program counter 2510 inside transferFrom. Supply enters and leaves without a single Transfer log.

Any balance tracker built by replaying Transfer logs will therefore compute wrong WETH balances. Subgraphs, accounting pipelines and portfolio trackers all hit this, and the error grows the more an account wraps and unwraps.

Mitigation. Index Deposit and Withdrawal alongside Transfer, and treat them as mint and burn against the zero address. Reconcile against balanceOf rather than trusting a replayed log stream.

L-1 · Unknown function selectors succeed silently Low

The dispatcher falls through to the fallback when no selector matches, and the fallback calls deposit(). A call to a function that does not exist returns success with empty data instead of reverting. Confirmed live:

eth_call  mint(address,uint256)  0x40c10f19  ->  SUCCESS, returndata = "0x"
eth_call  owner()                0x8da5cb5b  ->  SUCCESS, returndata = "0x"
eth_call  paused()               0x5c975abb  ->  SUCCESS, returndata = "0x"
eth_call  garbage                0xdeadbeef  ->  SUCCESS, returndata = "0x"

Two consequences follow. Code that probes for an interface by testing whether a call reverts will wrongly conclude that WETH has an owner() or a mint(). A mistyped call carrying ETH silently converts that ETH to WETH instead of rejecting it.

Mitigation. Never infer WETH capabilities from call success. Check the length of returned data, and decode strictly.

L-2 · No zero-address guard, and ~2,134 WETH is already stranded Low

transfer and transferFrom accept any destination. Tokens sent to the zero address, to the burn address, or to the WETH contract itself are unrecoverable, because the contract has no rescue function.

AddressBalance
Zero address1,088.8792 WETH
WETH contract itself764.7711 WETH
Burn address 0x…dEaD280.4562 WETH
Total stranded2,134.1064 WETH

That is 0.0966% of supply. The matching ETH stays locked in the contract permanently. Solvency is unaffected, since the wrapper is left over-collateralised, but nobody can ever redeem that ETH.

L-3 · totalSupply() reports the ETH balance Low

totalSupply() returns this.balance, which appears in the bytecode as ADDRESS followed by BALANCE. ETH can reach the contract without minting any WETH, through a SELFDESTRUCT beneficiary or by naming the address as a block fee recipient.

When that happens, totalSupply() exceeds the sum of all balances and the wrapper becomes over-collateralised. The direction is safe. The number is still wrong for anyone reading it as circulating supply.

L-4 · Maximum allowance is never decremented Low

transferFrom skips the allowance write when the allowance equals uint(-1). An approval set to the maximum uint256 value behaves as infinite and holds that value forever.

The behaviour is deliberate, saves gas, and most of the ecosystem relies on it. It does break the assumption that allowance() falls after a spend, so accounting that infers spend volume from allowance movement reads zero.

L-5 · approve() carries the classic ERC-20 race Low

approve overwrites the allowance with no guard, and the contract offers no increaseAllowance or decreaseAllowance. A spender watching the mempool can spend the old allowance and then the new one.

This affects the whole ERC-20 generation rather than WETH specifically. Setting the allowance to zero before setting a new value avoids it.

I-1 · ETH cannot be sent to WETH with transfer() or send() Info

The fallback runs deposit(), which writes a storage slot and emits a log. A plain ETH send to the contract measures 27,977 gas, far above the 2300 stipend that .transfer() and .send() provide.

A contract must use call{value: v}(""), or call deposit() directly with enough gas. This is the mirror of M-1, and the two together catch integrators from both directions.

I-2 · No EIP-2612 permit Info

WETH9 predates signed approvals, so every allowance change costs a transaction. That gap is why Permit2 and similar routers exist. Adding permit is impossible, because the contract cannot be upgraded.

What the contract cannot do

Each item below is confirmed against the disassembled runtime code.

Why 2017 arithmetic is safe here

Solidity 0.4.19 has no checked arithmetic, and the contract uses no SafeMath. That combination usually earns a high-severity finding. It does not here, and the reasoning needs stating precisely.

Both subtractions are guarded. withdraw and transferFrom each run a require before decrementing, so neither wraps below zero. The allowance decrement sits behind its own require.

Both additions are bounded by economics. balanceOf[dst] += wad can only overflow if a balance approaches 2256, about 1.16 × 1077. Every WETH is backed one to one by real ETH, and the entire ETH supply is around 1.2 × 1026 wei. Fifty orders of magnitude separate the two, and no attacker can close that gap.

The contract is safe because it is small enough to enumerate by hand. The argument does not transfer to any larger contract on the same compiler.

Compiler risk

The Solidity project lists 19 known bugs affecting 0.4.19. I checked each against this contract. None applies.

Most require language features WETH9 never uses. It has no arrays, which clears LostStorageArrayWriteOnSlotOverflow, SignedArrayStorageCopy, DynamicArrayCleanup, MemoryArrayCreationOverflow and NestedArrayFunctionCallDecoder. It has no structs in events, no libraries, no inheritance, no function pointers and no tuple assignment, clearing five more. ABIEncoderV2 is not enabled, clearing the three bugs specific to it. It has no exponentiation, so ExpExponentCleanup is irrelevant.

Two need a specific answer. ImplicitConstructorCallvalueCheck concerns contracts with no explicit constructor, which WETH9 is, but construction happened once in 2017 and cannot repeat. DirtyBytesArrayToStorage touches string storage, so I read slots 0 and 1 from the chain to confirm both are clean: "Wrapped Ether" with length 26 and "WETH" with length 8, exactly as the short-string encoding requires.

The optimiser was disabled at compile time, which removes the whole class of optimiser miscompilation bugs at once.

Integration checklist

  1. Send ETH in with call{value: v}("") or deposit(). transfer() and send() revert.
  2. Do not call withdraw() into a receive path costing more than 2300 gas.
  3. Index Deposit and Withdrawal as mint and burn, or balances will be wrong.
  4. Set an allowance to zero before changing it to a new non-zero value.
  5. Do not read totalSupply() as circulating supply.
  6. Do not detect features by whether a call reverts. WETH answers every selector.
  7. Guard destination addresses yourself. WETH will burn tokens to the zero address without complaint.

Limits of this review

State these alongside the findings whenever the audit is quoted.

Links