julien/lovepublic Fork 0
27d0645
Commits
Clone
git clone https://git.rickub.com/julien/love.git
git clone ssh://git@rickub.com/julien/love.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

add weth registry gated on bytecode

julienbrg committed 2026-09-17T23:23:32+02:00 Browse files
27d0645 parent: 5a80e08
added src/IWETH.sol +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
6+/// @title IWETH
7+/// @author Julien Béranger
8+/// @notice The wrapped-ether surface `WETHRegistry` probes: ERC-20, plus the
9+/// two entrypoints that make a wrapper a wrapper.
10+/// @dev Deliberately not the full WETH9 ABI. The registry only needs to move
11+/// one wei in and back out again, and a narrower interface is a narrower
12+/// set of assumptions about what a candidate has to implement.
13+interface IWETH is IERC20 {
14+ /// @notice Wrap the ether sent with this call, one for one.
15+ function deposit() external payable;
16+
17+ /// @notice Unwrap `amount`, burning the wrapper token and returning ether.
18+ /// @param amount The wrapped ether to unwrap, in wei.
19+ function withdraw(uint256 amount) external;
20+}
new file mode 100644
@@ -0,0 +1,20 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
6+/// @title IWETH
7+/// @author Julien Béranger
8+/// @notice The wrapped-ether surface `WETHRegistry` probes: ERC-20, plus the
9+/// two entrypoints that make a wrapper a wrapper.
10+/// @dev Deliberately not the full WETH9 ABI. The registry only needs to move
11+/// one wei in and back out again, and a narrower interface is a narrower
12+/// set of assumptions about what a candidate has to implement.
13+interface IWETH is IERC20 {
14+ /// @notice Wrap the ether sent with this call, one for one.
15+ function deposit() external payable;
16+
17+ /// @notice Unwrap `amount`, burning the wrapper token and returning ether.
18+ /// @param amount The wrapped ether to unwrap, in wei.
19+ function withdraw(uint256 amount) external;
20+}
added src/WETHRegistry.sol +204 -0
new file mode 100644
@@ -0,0 +1,204 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {IWETH} from "./IWETH.sol";
5+
6+/// @title WETHRegistry
7+/// @author Julien Béranger
8+/// @notice Names the wETH that `Love` pegs to on this chain, and will only
9+/// accept an address whose code is one of a fixed set of reviewed wETH
10+/// implementations.
11+/// @dev The point is to take the wETH address out of `Love`'s creation code.
12+/// Anyone can call `register`, on any chain, without a factory, an owner
13+/// or a per-chain deployer — the contract decides for itself whether to
14+/// accept the candidate. Because it takes no constructor arguments, its
15+/// own creation code is identical everywhere, so CREATE2 puts it at one
16+/// address on every chain and `Love` can hardcode that address.
17+///
18+/// Two gates run at registration:
19+///
20+/// 1. `EXTCODEHASH` against the allowlist below. This is the real
21+/// defence: matching means the candidate is provably one of a handful
22+/// of implementations whose source has been read, not merely something
23+/// that behaves well today. It is what catches a hidden mint, an
24+/// upgrade hook or a backdoor, none of which a behavioural test can
25+/// see.
26+/// 2. A one-wei deposit/withdraw round trip, which catches the plain
27+/// mistake of an address that is not wETH at all, and proves the
28+/// wrapper actually works on this chain.
29+///
30+/// The registration is write-once. There is no setter, no owner and no
31+/// way to revoke an entry, so a `Love` deployed against this registry can
32+/// never have the token under its peg swapped.
33+///
34+/// Known limitation, and it is not a small one: the allowlist proves a
35+/// candidate *is* a reviewed wETH implementation, not that it is *the*
36+/// wETH the chain's ecosystem uses. Anyone can deploy their own copy of
37+/// WETH9 — identical code, identical codehash, no liquidity — and
38+/// register it first. The result is still fully backed and redeemable,
39+/// since it is real WETH9, but it is not the token anyone else holds, and
40+/// write-once means the mistake cannot be corrected on that chain. So
41+/// always check `weth()` against the chain's canonical wETH before
42+/// treating a `Love` instance as the real one; a squatted registry is
43+/// visible to anyone who looks, and the answer to it is a fresh salt.
44+contract WETHRegistry {
45+ /// @notice The wETH registered on this chain, or the zero address if none
46+ /// has been registered yet.
47+ /// @dev Written exactly once, by whoever calls `register` first with a
48+ /// candidate that passes both gates.
49+ IWETH public weth;
50+
51+ /// @notice The ether moved through the candidate to prove it wraps.
52+ uint256 public constant PROBE = 1 wei;
53+
54+ /// @dev Open only for the duration of the round trip, so the registry
55+ /// cannot be used as a place to park ether. Transient, so it costs
56+ /// almost nothing and cannot survive the call that set it.
57+ bool private transient _probing;
58+
59+ /// @notice Thrown when a wETH has already been registered on this chain.
60+ /// @param registered The wETH registered by the earlier call.
61+ error AlreadyRegistered(IWETH registered);
62+
63+ /// @notice Thrown when the candidate's code is not a reviewed wETH.
64+ /// @param candidate The rejected address.
65+ /// @param codeHash Its `EXTCODEHASH`, zero if there is no code there.
66+ error UnknownImplementation(IWETH candidate, bytes32 codeHash);
67+
68+ /// @notice Thrown when the call does not carry exactly `PROBE` wei.
69+ /// @param sent The value that came with the call.
70+ error ProbeValueRequired(uint256 sent);
71+
72+ /// @notice Thrown when wrapping `PROBE` wei did not mint `PROBE` wrapped.
73+ /// @param expected The balance a real wrapper would have produced.
74+ /// @param actual The balance the candidate produced.
75+ error DepositMismatch(uint256 expected, uint256 actual);
76+
77+ /// @notice Thrown when unwrapping did not burn the wrapped token.
78+ /// @param expected The balance a real wrapper would have left behind.
79+ /// @param actual The balance the candidate left behind.
80+ error WithdrawMismatch(uint256 expected, uint256 actual);
81+
82+ /// @notice Thrown when unwrapping did not return the ether.
83+ /// @param expected The ether balance the round trip should have restored.
84+ /// @param actual The ether balance it actually left.
85+ error EtherNotReturned(uint256 expected, uint256 actual);
86+
87+ /// @notice Thrown when ether is sent outside a round trip.
88+ error NotProbing();
89+
90+ /// @notice Thrown when the probe wei could not be sent back.
91+ error RefundFailed();
92+
93+ /// @notice Emitted once, when a chain's wETH is settled.
94+ /// @param weth The accepted wETH.
95+ /// @param registrar Whoever supplied and paid for it.
96+ /// @param codeHash The allowlisted hash its code matched.
97+ event Registered(IWETH indexed weth, address indexed registrar, bytes32 codeHash);
98+
99+ /// @notice Accept `candidate` as this chain's wETH, if its code is one of
100+ /// the reviewed implementations and it wraps ether correctly.
101+ /// @dev Send exactly `PROBE` wei; it makes the round trip and comes back.
102+ /// Reverts rather than degrading when the candidate is unrecognised —
103+ /// unreviewed bytecode cannot be shown safe by any test, static or
104+ /// behavioural, so a chain running its own wETH is a chain `Love`
105+ /// does not deploy on until that implementation is reviewed and
106+ /// added.
107+ /// @param candidate The wETH to register.
108+ /// @return The registered wETH, for the convenience of scripts.
109+ function register(IWETH candidate) external payable returns (IWETH) {
110+ if (address(weth) != address(0)) revert AlreadyRegistered(weth);
111+ if (msg.value != PROBE) revert ProbeValueRequired(msg.value);
112+
113+ bytes32 codeHash = address(candidate).codehash;
114+ if (!isKnownImplementation(codeHash)) revert UnknownImplementation(candidate, codeHash);
115+
116+ _probe(candidate);
117+
118+ weth = candidate;
119+ emit Registered(candidate, msg.sender, codeHash);
120+
121+ // After the state is settled, so a registrar that re-enters here finds
122+ // the registry already closed.
123+ (bool ok,) = msg.sender.call{value: PROBE}("");
124+ if (!ok) revert RefundFailed();
125+
126+ return candidate;
127+ }
128+
129+ /// @notice Whether `codeHash` is one of the reviewed wETH implementations.
130+ /// @dev Compile-time, so the list is ownerless and append-only by
131+ /// construction: extending it means publishing a new registry, which
132+ /// leaves every existing deployment exactly as it was. No key can
133+ /// revoke an entry and strand a live `Love`.
134+ ///
135+ /// These are exact `EXTCODEHASH` values, metadata included. Hashing
136+ /// the code with solc's trailing metadata stripped would fold each
137+ /// family into a single entry, but it would also accept a known
138+ /// implementation followed by arbitrary appended bytes, and the
139+ /// safety of that rests on control flow never reaching them — a
140+ /// property that holds for every family here and would have to keep
141+ /// holding for every family added later. Ten constants is the cheaper
142+ /// side of that trade.
143+ ///
144+ /// Grouped by implementation. Every value is reproducible from chain
145+ /// state with `script/weth-codehashes.sh`.
146+ /// @param codeHash The `EXTCODEHASH` to check.
147+ /// @return True if a candidate with this code may be registered.
148+ function isKnownImplementation(bytes32 codeHash) public pure returns (bool) {
149+ // OP Stack legacy WETH9, 2041 bytes, solc 0.5.17. One implementation,
150+ // four hashes: these chains run byte-identical code and differ only
151+ // inside solc's metadata blob, which never executes.
152+ if (codeHash == 0x779bbf2a738ef09d961c945116197e2ac764c1b39304b2b4418cd4e42668b173) return true; // optimism
153+ if (codeHash == 0x8a3a1f6a9f9dce633117adee5b458245835a8645a8c8726a26382a4622508b1c) return true; // base, mode, zora
154+ if (codeHash == 0x557c8e14d33f7cd67cad0141e1a49ebf3488a447fc3df7aa66b127778a0383d1) return true; // world-chain
155+ if (codeHash == 0xf35fe602ba2a3b96f2e27ff7c8b8010800a8d0d616a5fb1f902e087b590355f3) return true; // lisk
156+
157+ // Canonical WETH9, 3124 bytes, solc 0.4.19.
158+ if (codeHash == 0xd0a06b12ac47863b5c7be4185c2deaad1c61557033f56c7d4ea74429cbb25e23) return true; // ethereum
159+ if (codeHash == 0xa670ec6c272ddec6d328d6f3d5cad65a841a6ab45e8e5cf825150eb458be4f1f) return true; // linea
160+ if (codeHash == 0x032e9cab14331328530468e54f1b91777b4d5c9dbbb400884badb32bc4113585) return true; // polygon-zkevm
161+
162+ // OP Stack WETH, 2865 bytes, solc 0.8.15.
163+ if (codeHash == 0xd0f1614c5dacfbd34f1c6f500f397009e4c9a8bfd4e02db353edb2253d9a8012) return true; // unichain, soneium, ink
164+
165+ // Taiko, 3204 bytes.
166+ if (codeHash == 0x9f3d95086909fce850d997158aba31abe26c3aad6a413107ca0bf9d53a7c42e9) return true; // taiko
167+
168+ // Scroll, 5871 bytes.
169+ if (codeHash == 0xe8c4073351c26b9831c1e5af153b9be4713a4af9edfdf32b58077b735e120f14) return true; // scroll
170+
171+ return false;
172+ }
173+
174+ /// @notice Take `PROBE` wei through the candidate and back.
175+ /// @dev Costs the registrar nothing but gas: the wei returns. Balances are
176+ /// read before and after rather than assumed to start at zero, so a
177+ /// candidate that was sent wETH beforehand cannot skew the check.
178+ /// @param candidate The wETH being probed.
179+ function _probe(IWETH candidate) private {
180+ uint256 etherBefore = address(this).balance;
181+ uint256 wrappedBefore = candidate.balanceOf(address(this));
182+
183+ _probing = true;
184+
185+ candidate.deposit{value: PROBE}();
186+ uint256 wrapped = candidate.balanceOf(address(this));
187+ if (wrapped != wrappedBefore + PROBE) revert DepositMismatch(wrappedBefore + PROBE, wrapped);
188+
189+ candidate.withdraw(PROBE);
190+ uint256 unwrapped = candidate.balanceOf(address(this));
191+ if (unwrapped != wrappedBefore) revert WithdrawMismatch(wrappedBefore, unwrapped);
192+
193+ _probing = false;
194+
195+ if (address(this).balance != etherBefore) revert EtherNotReturned(etherBefore, address(this).balance);
196+ }
197+
198+ /// @notice Takes the ether a candidate returns mid-probe, and nothing else.
199+ /// @dev The registry is not a wallet. Outside a round trip this reverts,
200+ /// so ether cannot be stranded in a contract with no way to move it.
201+ receive() external payable {
202+ if (!_probing) revert NotProbing();
203+ }
204+}
new file mode 100644
@@ -0,0 +1,204 @@
1+// SPDX-License-Identifier: MIT
2+pragma solidity ^0.8.30;
3+
4+import {IWETH} from "./IWETH.sol";
5+
6+/// @title WETHRegistry
7+/// @author Julien Béranger
8+/// @notice Names the wETH that `Love` pegs to on this chain, and will only
9+/// accept an address whose code is one of a fixed set of reviewed wETH
10+/// implementations.
11+/// @dev The point is to take the wETH address out of `Love`'s creation code.
12+/// Anyone can call `register`, on any chain, without a factory, an owner
13+/// or a per-chain deployer — the contract decides for itself whether to
14+/// accept the candidate. Because it takes no constructor arguments, its
15+/// own creation code is identical everywhere, so CREATE2 puts it at one
16+/// address on every chain and `Love` can hardcode that address.
17+///
18+/// Two gates run at registration:
19+///
20+/// 1. `EXTCODEHASH` against the allowlist below. This is the real
21+/// defence: matching means the candidate is provably one of a handful
22+/// of implementations whose source has been read, not merely something
23+/// that behaves well today. It is what catches a hidden mint, an
24+/// upgrade hook or a backdoor, none of which a behavioural test can
25+/// see.
26+/// 2. A one-wei deposit/withdraw round trip, which catches the plain
27+/// mistake of an address that is not wETH at all, and proves the
28+/// wrapper actually works on this chain.
29+///
30+/// The registration is write-once. There is no setter, no owner and no
31+/// way to revoke an entry, so a `Love` deployed against this registry can
32+/// never have the token under its peg swapped.
33+///
34+/// Known limitation, and it is not a small one: the allowlist proves a
35+/// candidate *is* a reviewed wETH implementation, not that it is *the*
36+/// wETH the chain's ecosystem uses. Anyone can deploy their own copy of
37+/// WETH9 — identical code, identical codehash, no liquidity — and
38+/// register it first. The result is still fully backed and redeemable,
39+/// since it is real WETH9, but it is not the token anyone else holds, and
40+/// write-once means the mistake cannot be corrected on that chain. So
41+/// always check `weth()` against the chain's canonical wETH before
42+/// treating a `Love` instance as the real one; a squatted registry is
43+/// visible to anyone who looks, and the answer to it is a fresh salt.
44+contract WETHRegistry {
45+ /// @notice The wETH registered on this chain, or the zero address if none
46+ /// has been registered yet.
47+ /// @dev Written exactly once, by whoever calls `register` first with a
48+ /// candidate that passes both gates.
49+ IWETH public weth;
50+
51+ /// @notice The ether moved through the candidate to prove it wraps.
52+ uint256 public constant PROBE = 1 wei;
53+
54+ /// @dev Open only for the duration of the round trip, so the registry
55+ /// cannot be used as a place to park ether. Transient, so it costs
56+ /// almost nothing and cannot survive the call that set it.
57+ bool private transient _probing;
58+
59+ /// @notice Thrown when a wETH has already been registered on this chain.
60+ /// @param registered The wETH registered by the earlier call.
61+ error AlreadyRegistered(IWETH registered);
62+
63+ /// @notice Thrown when the candidate's code is not a reviewed wETH.
64+ /// @param candidate The rejected address.
65+ /// @param codeHash Its `EXTCODEHASH`, zero if there is no code there.
66+ error UnknownImplementation(IWETH candidate, bytes32 codeHash);
67+
68+ /// @notice Thrown when the call does not carry exactly `PROBE` wei.
69+ /// @param sent The value that came with the call.
70+ error ProbeValueRequired(uint256 sent);
71+
72+ /// @notice Thrown when wrapping `PROBE` wei did not mint `PROBE` wrapped.
73+ /// @param expected The balance a real wrapper would have produced.
74+ /// @param actual The balance the candidate produced.
75+ error DepositMismatch(uint256 expected, uint256 actual);
76+
77+ /// @notice Thrown when unwrapping did not burn the wrapped token.
78+ /// @param expected The balance a real wrapper would have left behind.
79+ /// @param actual The balance the candidate left behind.
80+ error WithdrawMismatch(uint256 expected, uint256 actual);
81+
82+ /// @notice Thrown when unwrapping did not return the ether.
83+ /// @param expected The ether balance the round trip should have restored.
84+ /// @param actual The ether balance it actually left.
85+ error EtherNotReturned(uint256 expected, uint256 actual);
86+
87+ /// @notice Thrown when ether is sent outside a round trip.
88+ error NotProbing();
89+
90+ /// @notice Thrown when the probe wei could not be sent back.
91+ error RefundFailed();
92+
93+ /// @notice Emitted once, when a chain's wETH is settled.
94+ /// @param weth The accepted wETH.
95+ /// @param registrar Whoever supplied and paid for it.
96+ /// @param codeHash The allowlisted hash its code matched.
97+ event Registered(IWETH indexed weth, address indexed registrar, bytes32 codeHash);
98+
99+ /// @notice Accept `candidate` as this chain's wETH, if its code is one of
100+ /// the reviewed implementations and it wraps ether correctly.
101+ /// @dev Send exactly `PROBE` wei; it makes the round trip and comes back.
102+ /// Reverts rather than degrading when the candidate is unrecognised —
103+ /// unreviewed bytecode cannot be shown safe by any test, static or
104+ /// behavioural, so a chain running its own wETH is a chain `Love`
105+ /// does not deploy on until that implementation is reviewed and
106+ /// added.
107+ /// @param candidate The wETH to register.
108+ /// @return The registered wETH, for the convenience of scripts.
109+ function register(IWETH candidate) external payable returns (IWETH) {
110+ if (address(weth) != address(0)) revert AlreadyRegistered(weth);
111+ if (msg.value != PROBE) revert ProbeValueRequired(msg.value);
112+
113+ bytes32 codeHash = address(candidate).codehash;
114+ if (!isKnownImplementation(codeHash)) revert UnknownImplementation(candidate, codeHash);
115+
116+ _probe(candidate);
117+
118+ weth = candidate;
119+ emit Registered(candidate, msg.sender, codeHash);
120+
121+ // After the state is settled, so a registrar that re-enters here finds
122+ // the registry already closed.
123+ (bool ok,) = msg.sender.call{value: PROBE}("");
124+ if (!ok) revert RefundFailed();
125+
126+ return candidate;
127+ }
128+
129+ /// @notice Whether `codeHash` is one of the reviewed wETH implementations.
130+ /// @dev Compile-time, so the list is ownerless and append-only by
131+ /// construction: extending it means publishing a new registry, which
132+ /// leaves every existing deployment exactly as it was. No key can
133+ /// revoke an entry and strand a live `Love`.
134+ ///
135+ /// These are exact `EXTCODEHASH` values, metadata included. Hashing
136+ /// the code with solc's trailing metadata stripped would fold each
137+ /// family into a single entry, but it would also accept a known
138+ /// implementation followed by arbitrary appended bytes, and the
139+ /// safety of that rests on control flow never reaching them — a
140+ /// property that holds for every family here and would have to keep
141+ /// holding for every family added later. Ten constants is the cheaper
142+ /// side of that trade.
143+ ///
144+ /// Grouped by implementation. Every value is reproducible from chain
145+ /// state with `script/weth-codehashes.sh`.
146+ /// @param codeHash The `EXTCODEHASH` to check.
147+ /// @return True if a candidate with this code may be registered.
148+ function isKnownImplementation(bytes32 codeHash) public pure returns (bool) {
149+ // OP Stack legacy WETH9, 2041 bytes, solc 0.5.17. One implementation,
150+ // four hashes: these chains run byte-identical code and differ only
151+ // inside solc's metadata blob, which never executes.
152+ if (codeHash == 0x779bbf2a738ef09d961c945116197e2ac764c1b39304b2b4418cd4e42668b173) return true; // optimism
153+ if (codeHash == 0x8a3a1f6a9f9dce633117adee5b458245835a8645a8c8726a26382a4622508b1c) return true; // base, mode, zora
154+ if (codeHash == 0x557c8e14d33f7cd67cad0141e1a49ebf3488a447fc3df7aa66b127778a0383d1) return true; // world-chain
155+ if (codeHash == 0xf35fe602ba2a3b96f2e27ff7c8b8010800a8d0d616a5fb1f902e087b590355f3) return true; // lisk
156+
157+ // Canonical WETH9, 3124 bytes, solc 0.4.19.
158+ if (codeHash == 0xd0a06b12ac47863b5c7be4185c2deaad1c61557033f56c7d4ea74429cbb25e23) return true; // ethereum
159+ if (codeHash == 0xa670ec6c272ddec6d328d6f3d5cad65a841a6ab45e8e5cf825150eb458be4f1f) return true; // linea
160+ if (codeHash == 0x032e9cab14331328530468e54f1b91777b4d5c9dbbb400884badb32bc4113585) return true; // polygon-zkevm
161+
162+ // OP Stack WETH, 2865 bytes, solc 0.8.15.
163+ if (codeHash == 0xd0f1614c5dacfbd34f1c6f500f397009e4c9a8bfd4e02db353edb2253d9a8012) return true; // unichain, soneium, ink
164+
165+ // Taiko, 3204 bytes.
166+ if (codeHash == 0x9f3d95086909fce850d997158aba31abe26c3aad6a413107ca0bf9d53a7c42e9) return true; // taiko
167+
168+ // Scroll, 5871 bytes.
169+ if (codeHash == 0xe8c4073351c26b9831c1e5af153b9be4713a4af9edfdf32b58077b735e120f14) return true; // scroll
170+
171+ return false;
172+ }
173+
174+ /// @notice Take `PROBE` wei through the candidate and back.
175+ /// @dev Costs the registrar nothing but gas: the wei returns. Balances are
176+ /// read before and after rather than assumed to start at zero, so a
177+ /// candidate that was sent wETH beforehand cannot skew the check.
178+ /// @param candidate The wETH being probed.
179+ function _probe(IWETH candidate) private {
180+ uint256 etherBefore = address(this).balance;
181+ uint256 wrappedBefore = candidate.balanceOf(address(this));
182+
183+ _probing = true;
184+
185+ candidate.deposit{value: PROBE}();
186+ uint256 wrapped = candidate.balanceOf(address(this));
187+ if (wrapped != wrappedBefore + PROBE) revert DepositMismatch(wrappedBefore + PROBE, wrapped);
188+
189+ candidate.withdraw(PROBE);
190+ uint256 unwrapped = candidate.balanceOf(address(this));
191+ if (unwrapped != wrappedBefore) revert WithdrawMismatch(wrappedBefore, unwrapped);
192+
193+ _probing = false;
194+
195+ if (address(this).balance != etherBefore) revert EtherNotReturned(etherBefore, address(this).balance);
196+ }
197+
198+ /// @notice Takes the ether a candidate returns mid-probe, and nothing else.
199+ /// @dev The registry is not a wallet. Outside a round trip this reverts,
200+ /// so ether cannot be stranded in a contract with no way to move it.
201+ receive() external payable {
202+ if (!_probing) revert NotProbing();
203+ }
204+}