// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {IWETH} from "../src/IWETH.sol"; import {WETHRegistry} from "../src/WETHRegistry.sol"; import {Fixtures} from "./Fixtures.sol"; import {MockWETH} from "./mocks/MockWETH.sol"; import {Test} from "forge-std/Test.sol"; /// @notice A registrar with no payable fallback, which is what a deployer /// contract or a multisig looks like from the registry's side. contract PlainRegistrar { function register(WETHRegistry registry, IWETH weth) external payable { registry.register{value: msg.value}(weth); } } /// @title WETHRegistryTest /// @notice Covers the two gates the registry exists to run — is this code a /// reviewed wETH, and does it actually wrap — plus the write-once rule /// that stops the token under LOVE's peg from ever being swapped. contract WETHRegistryTest is Test, Fixtures { WETHRegistry registry; /// @dev Read once in `setUp`. Calling `registry.PROBE()` inline after /// `vm.expectRevert` would bind the expectation to that call instead /// of to `register`. uint256 probe; address constant WETH_AT = 0x4200000000000000000000000000000000000006; address constant OTHER_WETH_AT = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; event Registered(IWETH indexed weth, address indexed registrar, bytes32 codeHash); function setUp() public { registry = deployRegistry(); probe = registry.PROBE(); } /*////////////////////////////////////////////////////////////// THE ALLOWLIST //////////////////////////////////////////////////////////////*/ /// @dev The ten entries, reproducible from chain state with /// `script/weth-codehashes.sh`. Listing them here means a hash edited /// in the contract shows up as a failing test rather than as a silent /// change to what LOVE will accept. function test_KnownImplementations() public view { bytes32[10] memory allowed = [ // OP Stack legacy WETH9 bytes32(0x779bbf2a738ef09d961c945116197e2ac764c1b39304b2b4418cd4e42668b173), 0x8a3a1f6a9f9dce633117adee5b458245835a8645a8c8726a26382a4622508b1c, 0x557c8e14d33f7cd67cad0141e1a49ebf3488a447fc3df7aa66b127778a0383d1, 0xf35fe602ba2a3b96f2e27ff7c8b8010800a8d0d616a5fb1f902e087b590355f3, // Canonical WETH9 0xd0a06b12ac47863b5c7be4185c2deaad1c61557033f56c7d4ea74429cbb25e23, 0xa670ec6c272ddec6d328d6f3d5cad65a841a6ab45e8e5cf825150eb458be4f1f, 0x032e9cab14331328530468e54f1b91777b4d5c9dbbb400884badb32bc4113585, // OP Stack WETH, solc 0.8.15 0xd0f1614c5dacfbd34f1c6f500f397009e4c9a8bfd4e02db353edb2253d9a8012, // Taiko 0x9f3d95086909fce850d997158aba31abe26c3aad6a413107ca0bf9d53a7c42e9, // Scroll 0xe8c4073351c26b9831c1e5af153b9be4713a4af9edfdf32b58077b735e120f14 ]; for (uint256 i = 0; i < allowed.length; i++) { assertTrue(registry.isKnownImplementation(allowed[i]), "allowlisted hash rejected"); } } function test_EmptyCodeHashIsNotKnown() public view { assertFalse(registry.isKnownImplementation(bytes32(0))); assertFalse(registry.isKnownImplementation(keccak256(""))); } function testFuzz_UnknownHashesAreRejected(bytes32 codeHash) public view { vm.assume(!registry.isKnownImplementation(codeHash)); assertFalse(registry.isKnownImplementation(codeHash)); } /*////////////////////////////////////////////////////////////// REGISTERING //////////////////////////////////////////////////////////////*/ function test_RegistersOpStackWeth() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); register(registry, weth); assertEq(address(registry.weth()), WETH_AT); } function test_RegistersCanonicalWeth() public { IWETH weth = etchWeth(OTHER_WETH_AT, WETH9_CANONICAL); register(registry, weth); assertEq(address(registry.weth()), OTHER_WETH_AT); } function test_EmitsRegistered() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); deal(address(this), 1 ether); vm.expectEmit(true, true, true, true); emit Registered(weth, address(this), WETH_AT.codehash); registry.register{value: probe}(weth); } function test_NothingRegisteredInitially() public view { assertEq(address(registry.weth()), address(0)); } /// @dev The reason the probe wei is not refunded: paying it back means /// calling the registrar with value, which a contract without a /// payable fallback cannot accept. function test_RegistrarWithoutPayableFallbackCanRegister() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); PlainRegistrar registrar = new PlainRegistrar(); deal(address(this), 1 ether); registrar.register{value: probe}(registry, weth); assertEq(address(registry.weth()), WETH_AT); } function test_ProbeWeiStaysInTheRegistry() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); register(registry, weth); assertEq(address(registry).balance, probe); } /*////////////////////////////////////////////////////////////// THE BYTECODE GATE //////////////////////////////////////////////////////////////*/ /// @dev `MockWETH` wraps ether correctly and would sail through the /// behavioural probe. It is rejected on its code alone, which is the /// gate doing exactly what it is for: behaving well today says /// nothing about what else the code can do. function test_RevertWhen_CodeIsNotAReviewedImplementation() public { MockWETH mock = new MockWETH(); deal(address(this), 1 ether); vm.expectRevert( abi.encodeWithSelector( WETHRegistry.UnknownImplementation.selector, IWETH(address(mock)), address(mock).codehash ) ); registry.register{value: probe}(IWETH(address(mock))); } function test_RevertWhen_CandidateHasNoCode() public { address empty = makeAddr("empty"); deal(address(this), 1 ether); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.UnknownImplementation.selector, IWETH(empty), bytes32(0))); registry.register{value: probe}(IWETH(empty)); } /*////////////////////////////////////////////////////////////// THE BEHAVIOURAL PROBE //////////////////////////////////////////////////////////////*/ /// @dev Mocking `balanceOf` leaves the codehash untouched, so the /// candidate still passes the allowlist and the probe is what has to /// catch it. A wrapper that mints nothing on deposit fails here. function test_RevertWhen_DepositMintsNothing() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); deal(address(this), 1 ether); vm.mockCall(WETH_AT, abi.encodeCall(weth.balanceOf, (address(registry))), abi.encode(uint256(0))); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.DepositMismatch.selector, 1, 0)); registry.register{value: probe}(weth); } /// @dev Mints on deposit, but burns nothing on withdraw: the balance is /// read three times, and the third answer is the one that lies. function test_RevertWhen_WithdrawBurnsNothing() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); deal(address(this), 1 ether); bytes[] memory balances = new bytes[](3); balances[0] = abi.encode(uint256(0)); // before balances[1] = abi.encode(uint256(1)); // after deposit, correct balances[2] = abi.encode(uint256(1)); // after withdraw, should be 0 vm.mockCalls(WETH_AT, abi.encodeCall(weth.balanceOf, (address(registry))), balances); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.WithdrawMismatch.selector, 0, 1)); registry.register{value: probe}(weth); } /// @dev Books the withdrawal correctly but keeps the ether. The balance /// check after the round trip is the only thing that notices. function test_RevertWhen_WithdrawKeepsTheEther() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); deal(address(this), 1 ether); vm.mockCall(WETH_AT, abi.encodeCall(weth.withdraw, (1)), ""); bytes[] memory balances = new bytes[](3); balances[0] = abi.encode(uint256(0)); balances[1] = abi.encode(uint256(1)); balances[2] = abi.encode(uint256(0)); vm.mockCalls(WETH_AT, abi.encodeCall(weth.balanceOf, (address(registry))), balances); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.EtherNotReturned.selector, 1, 0)); registry.register{value: probe}(weth); } /*////////////////////////////////////////////////////////////// THE PROBE FEE //////////////////////////////////////////////////////////////*/ function test_RevertWhen_NoProbeValue() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.ProbeValueRequired.selector, 0)); registry.register(weth); } function testFuzz_RevertWhen_WrongProbeValue(uint96 value) public { vm.assume(value != probe); IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); deal(address(this), uint256(value) + 1 ether); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.ProbeValueRequired.selector, value)); registry.register{value: value}(weth); } /*////////////////////////////////////////////////////////////// WRITE-ONCE //////////////////////////////////////////////////////////////*/ function test_RevertWhen_AlreadyRegistered() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); register(registry, weth); deal(address(this), 1 ether); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.AlreadyRegistered.selector, weth)); registry.register{value: probe}(weth); } /// @dev Including with a second, equally valid wETH — the first answer is /// final, so nothing can move the token under a live peg. function test_RevertWhen_ReplacingWithAnotherValidWeth() public { IWETH first = etchWeth(WETH_AT, WETH9_OP_LEGACY); register(registry, first); IWETH second = etchWeth(OTHER_WETH_AT, WETH9_CANONICAL); deal(address(this), 1 ether); vm.expectRevert(abi.encodeWithSelector(WETHRegistry.AlreadyRegistered.selector, first)); registry.register{value: probe}(second); assertEq(address(registry.weth()), WETH_AT); } /*////////////////////////////////////////////////////////////// NOT A WALLET //////////////////////////////////////////////////////////////*/ /// @dev The registry has no owner and no way to move ether out, so it must /// not accept any outside a probe. function test_RevertWhen_EtherSentOutsideAProbe() public { deal(address(this), 1 ether); vm.expectRevert(WETHRegistry.NotProbing.selector); payable(address(registry)).transfer(1 ether); } function test_RevertWhen_EtherSentAfterRegistration() public { IWETH weth = etchWeth(WETH_AT, WETH9_OP_LEGACY); register(registry, weth); deal(address(this), 1 ether); vm.expectRevert(WETHRegistry.NotProbing.selector); payable(address(registry)).transfer(1 ether); } }