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.
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.
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.
| ID | Severity | Finding |
|---|---|---|
| M-1 | Medium | withdraw() forwards only 2300 gas |
| M-2 | Medium | No Transfer event on deposit or withdraw |
| L-1 | Low | Unknown function selectors succeed silently |
| L-2 | Low | No zero-address guard; ~2,134 WETH already stranded |
| L-3 | Low | totalSupply() reports the ETH balance, not the sum of balances |
| L-4 | Low | Maximum allowance is never decremented |
| L-5 | Low | approve() carries the classic ERC-20 race |
| I-1 | Info | ETH cannot be sent to WETH with transfer() or send() |
| I-2 | Info | No EIP-2612 permit |
Counts: 0 critical, 0 high, 2 medium, 5 low, 2 informational.
| Address | 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 |
|---|---|
| Deployed | Block 4,719,568, 2017-12-12 11:17:35 UTC, 8.67 years ago |
| Deployer | 0x4F26FfBe5F04ED43630fdC30A87638d53D0b0876, an externally owned account |
| Creation tx | 0xb95343413e459a0f97461812111254163ae53467855c0d73e0f1e7c5b8442fa3 |
| Compiler | solc 0.4.19+commit.c4cbbb05, optimiser disabled |
| Runtime code | 3,124 bytes: 3,081 executable plus a 43-byte bzzr0 metadata trailer |
| Instructions | 1,555 decoded, zero unknown opcodes |
| Dispatch table | 11 selectors, matching the ERC-20 surface exactly |
| ETH held | 2,209,291.3665 ETH at block 25,787,146 |
| Storage slots | 0 name, 1 symbol, 2 decimals, 3 balanceOf, 4 allowance |
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.
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.
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.
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.
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.
| Address | Balance |
|---|---|
| Zero address | 1,088.8792 WETH |
| WETH contract itself | 764.7711 WETH |
Burn address 0x…dEaD | 280.4562 WETH |
| Total stranded | 2,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.
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.
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.
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.
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.
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.
Each item below is confirmed against the disassembled runtime code.
DELEGATECALL anywhere, so the contract is not a proxy and
has no upgrade path. Blockscout independently reports no implementation and
no proxy type.SELFDESTRUCT, so it cannot be destroyed and the ETH cannot
be swept.CREATE or CREATE2, and the account nonce is
1, so it has never deployed anything.CALL, the payout in withdraw(). No
other external call exists.TIMESTAMP, NUMBER or ORIGIN, so
no time dependence and no tx.origin authentication.withdraw() writes the balance at pc 2675 before the call at
pc 2724, so checks-effects-interactions holds. Reentrancy fails on ordering
as well as on gas.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.
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.
call{value: v}("") or deposit().
transfer() and send() revert.withdraw() into a receive path costing more than
2300 gas.Deposit and Withdrawal as mint and burn,
or balances will be wrong.totalSupply() as circulating supply.State these alongside the findings whenever the audit is quoted.
totalSupply() against the contract balance is circular, because
totalSupply() returns that balance. A real proof needs the sum
of every holder balance from a full state dump, which needs archive-node
indexing.