diff --git a/packages/contracts-bedrock/scripts/L2Genesis.s.sol b/packages/contracts-bedrock/scripts/L2Genesis.s.sol index e1e43efebc8c7..9eed38747e319 100644 --- a/packages/contracts-bedrock/scripts/L2Genesis.s.sol +++ b/packages/contracts-bedrock/scripts/L2Genesis.s.sol @@ -245,6 +245,8 @@ contract L2Genesis is Deployer { if (cfg.useInterop()) { setCrossL2Inbox(); // 22 setL2ToL2CrossDomainMessenger(); // 23 + setSuperchainWETH(); // 24 + setETHLiquidity(); // 25 } } @@ -475,6 +477,19 @@ contract L2Genesis is Deployer { _setImplementationCode(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); } + /// @notice This predeploy is following the saftey invariant #1. + /// This contract has no initializer. + function setETHLiquidity() internal { + _setImplementationCode(Predeploys.ETH_LIQUIDITY); + vm.deal(Predeploys.ETH_LIQUIDITY, type(uint248).max); + } + + /// @notice This predeploy is following the saftey invariant #1. + /// This contract has no initializer. + function setSuperchainWETH() internal { + _setImplementationCode(Predeploys.SUPERCHAIN_WETH); + } + /// @notice Sets all the preinstalls. /// Warning: the creator-accounts of the preinstall contracts have 0 nonce values. /// When performing a regular user-initiated contract-creation of a preinstall, diff --git a/packages/contracts-bedrock/src/L2/ETHLiquidity.sol b/packages/contracts-bedrock/src/L2/ETHLiquidity.sol new file mode 100644 index 0000000000000..b26f3a8f0ccbe --- /dev/null +++ b/packages/contracts-bedrock/src/L2/ETHLiquidity.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { WETH98 } from "src/dispute/weth/WETH98.sol"; +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { L1Block } from "src/L2/L1Block.sol"; +import { SafeSend } from "src/universal/SafeSend.sol"; +import { ISemver } from "src/universal/ISemver.sol"; + +import "src/libraries/errors/CommonErrors.sol"; + +/// @title ETHLiquidity +/// @notice The ETHLiquidity contract allows other contracts to access ETH liquidity without +/// needing to modify the EVM to generate new ETH. +contract ETHLiquidity { + /// @notice Emitted when an address burns ETH liquidity. + event LiquidityBurned(address indexed caller, uint256 value); + + /// @notice Emitted when an address mints ETH liquidity. + event LiquidityMinted(address indexed caller, uint256 value); + + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + /// @notice Throws if the caller is not authorized. + modifier onlyAuthorized() { + // Only the SuperchainWETH contract is authorized to call this contract for now. Other + // contracts may be allowed to call this contract in the future so we're using a generic + // name for the modifier. + if (msg.sender != Predeploys.SUPERCHAIN_WETH) revert Unauthorized(); + _; + } + + /// @notice Throws this chain is using a custom gas token. + modifier notCustomGasToken() { + if (L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) revert NotCustomGasToken(); + _; + } + + /// @notice Allows an address to lock ETH liquidity into this contract. + function burn() external payable onlyAuthorized notCustomGasToken { + emit LiquidityBurned(msg.sender, msg.value); + } + + /// @notice Allows an address to unlock ETH liquidity from this contract. + /// @param _amount The amount of liquidity to unlock. + function mint(uint256 _amount) external onlyAuthorized notCustomGasToken { + new SafeSend{ value: _amount }(payable(msg.sender)); + emit LiquidityMinted(msg.sender, _amount); + } +} diff --git a/packages/contracts-bedrock/src/L2/SuperchainWETH.sol b/packages/contracts-bedrock/src/L2/SuperchainWETH.sol new file mode 100644 index 0000000000000..9767c1ee31de5 --- /dev/null +++ b/packages/contracts-bedrock/src/L2/SuperchainWETH.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { WETH98 } from "src/dispute/weth/WETH98.sol"; +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { L1Block } from "src/L2/L1Block.sol"; +import { IL2ToL2CrossDomainMessenger } from "src/L2/IL2ToL2CrossDomainMessenger.sol"; +import { ETHLiquidity } from "src/L2/ETHLiquidity.sol"; +import { ISemver } from "src/universal/ISemver.sol"; + +import "src/libraries/errors/CommonErrors.sol"; + +/// @title SuperchainWETH +/// @notice SuperchainWETH is a version of WETH that can be freely transfered between chains within +/// the superchain. SuperchainWETH can be converted into native ETH on chains that do not +// use a custom gas token. +contract SuperchainWETH is WETH98, ISemver { + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + /// @notice Throws this chain is using a custom gas token. + modifier notCustomGasToken() { + if (L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) revert OnlyCustomGasToken(); + _; + } + + /// @inheritdoc WETH98 + function deposit() public payable override notCustomGasToken { + super.deposit(); + } + + /// @inheritdoc WETH98 + function withdraw(uint256 wad) public override notCustomGasToken { + super.withdraw(wad); + } + + /// TODO: .inheritdoc ISuperchainERC20 + function sendERC20(uint256 wad, uint256 chainId) external { + sendERC20To(msg.sender, wad, chainId); + } + + /// TODO: .inheritdoc ISuperchainERC20 + function sendERC20To(address dst, uint256 wad, uint256 chainId) public { + // Burn from user's balance. + _burn(msg.sender, wad); + + // Burn to ETHLiquidity contract. + if (!L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) { + ETHLiquidity(Predeploys.ETH_LIQUIDITY).burn{ value: wad }(); + } + + // Send message to other chain. + IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER).sendMessage({ + _destination: chainId, + _target: address(this), + _message: abi.encodeCall(this.finalizeSendERC20, (dst, wad)) + }); + } + + /// TODO: .inheritdoc ISuperchainERC20 + function finalizeSendERC20(address dst, uint256 wad) external { + // Receive message from other chain. + IL2ToL2CrossDomainMessenger messenger = IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + if (msg.sender != address(messenger)) revert Unauthorized(); + if (messenger.crossDomainMessageSender() != address(this)) revert Unauthorized(); + + // Mint from ETHLiquidity contract. + if (!L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) { + ETHLiquidity(Predeploys.ETH_LIQUIDITY).mint(wad); + } + + // Mint to user's balance. + _mint(dst, wad); + } + + /// @notice Mints WETH to an address. + /// @param guy The address to mint WETH to. + /// @param wad The amount of WETH to mint. + function _mint(address guy, uint256 wad) internal { + balanceOf[guy] += wad; + emit Transfer(address(0), guy, wad); + } + + /// @notice Burns WETH from an address. + /// @param guy The address to burn WETH from. + /// @param wad The amount of WETH to burn. + function _burn(address guy, uint256 wad) internal { + require(balanceOf[guy] >= wad); + balanceOf[guy] -= wad; + emit Transfer(guy, address(0), wad); + } +} diff --git a/packages/contracts-bedrock/src/dispute/weth/WETH98.sol b/packages/contracts-bedrock/src/dispute/weth/WETH98.sol index f95699a3bf931..2b054c7048ebf 100644 --- a/packages/contracts-bedrock/src/dispute/weth/WETH98.sol +++ b/packages/contracts-bedrock/src/dispute/weth/WETH98.sol @@ -50,7 +50,7 @@ contract WETH98 is IWETH { } /// @inheritdoc IWETH - function deposit() public payable { + function deposit() public payable virtual { balanceOf[msg.sender] += msg.value; emit Deposit(msg.sender, msg.value); } diff --git a/packages/contracts-bedrock/src/libraries/Predeploys.sol b/packages/contracts-bedrock/src/libraries/Predeploys.sol index d37dbca6af111..5b2b9fc6baee5 100644 --- a/packages/contracts-bedrock/src/libraries/Predeploys.sol +++ b/packages/contracts-bedrock/src/libraries/Predeploys.sol @@ -89,6 +89,12 @@ library Predeploys { /// @notice Address of the L2ToL2CrossDomainMessenger predeploy. address internal constant L2_TO_L2_CROSS_DOMAIN_MESSENGER = 0x4200000000000000000000000000000000000023; + /// @notice Address of the SuperchainWETH predeploy. + address internal constant SUPERCHAIN_WETH = 0x4200000000000000000000000000000000000024; + + /// @notice Address of the ETHLiquidty predeploy. + address internal constant ETH_LIQUIDITY = 0x4200000000000000000000000000000000000025; + /// @notice Returns the name of the predeploy at the given address. function getName(address _addr) internal pure returns (string memory out_) { require(isPredeployNamespace(_addr), "Predeploys: address must be a predeploy"); @@ -115,6 +121,8 @@ library Predeploys { if (_addr == LEGACY_ERC20_ETH) return "LegacyERC20ETH"; if (_addr == CROSS_L2_INBOX) return "CrossL2Inbox"; if (_addr == L2_TO_L2_CROSS_DOMAIN_MESSENGER) return "L2ToL2CrossDomainMessenger"; + if (_addr == SUPERCHAIN_WETH) return "SuperchainWETH"; + if (_addr == ETH_LIQUIDITY) return "ETHLiquidity"; revert("Predeploys: unnamed predeploy"); } @@ -131,7 +139,8 @@ library Predeploys { || _addr == L2_ERC721_BRIDGE || _addr == L1_BLOCK_ATTRIBUTES || _addr == L2_TO_L1_MESSAGE_PASSER || _addr == OPTIMISM_MINTABLE_ERC721_FACTORY || _addr == PROXY_ADMIN || _addr == BASE_FEE_VAULT || _addr == L1_FEE_VAULT || _addr == SCHEMA_REGISTRY || _addr == EAS || _addr == GOVERNANCE_TOKEN - || (_useInterop && _addr == CROSS_L2_INBOX) || (_useInterop && _addr == L2_TO_L2_CROSS_DOMAIN_MESSENGER); + || (_useInterop && _addr == CROSS_L2_INBOX) || (_useInterop && _addr == L2_TO_L2_CROSS_DOMAIN_MESSENGER) + || (_useInterop && _addr == SUPERCHAIN_WETH) || (_useInterop && _addr == ETH_LIQUIDITY); } function isPredeployNamespace(address _addr) internal pure returns (bool) { diff --git a/packages/contracts-bedrock/src/libraries/errors/CommonErrors.sol b/packages/contracts-bedrock/src/libraries/errors/CommonErrors.sol new file mode 100644 index 0000000000000..eee6cc699489e --- /dev/null +++ b/packages/contracts-bedrock/src/libraries/errors/CommonErrors.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @notice Error for an unauthorized CALLER. +error Unauthorized(); + +/// @notice Error for when a method is called that only works when using a custom gas token. +error OnlyCustomGasToken(); + +/// @notice Error for when a method is called that only works when NOT using a custom gas token. +error NotCustomGasToken(); + +/// @notice Error for when a transfer via call fails. +error TransferFailed(); diff --git a/packages/contracts-bedrock/src/periphery/faucet/Faucet.sol b/packages/contracts-bedrock/src/periphery/faucet/Faucet.sol index 9266d00ed0f03..cce6a83b3e131 100644 --- a/packages/contracts-bedrock/src/periphery/faucet/Faucet.sol +++ b/packages/contracts-bedrock/src/periphery/faucet/Faucet.sol @@ -1,17 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; -import { IFaucetAuthModule } from "./authmodules/IFaucetAuthModule.sol"; -import { SafeCall } from "../../libraries/SafeCall.sol"; - -/// @title SafeSend -/// @notice Sends ETH to a recipient account without triggering any code. -contract SafeSend { - /// @param _recipient Account to send ETH to. - constructor(address payable _recipient) payable { - selfdestruct(_recipient); - } -} +import { IFaucetAuthModule } from "src/periphery/faucet/authmodules/IFaucetAuthModule.sol"; +import { SafeCall } from "src/libraries/SafeCall.sol"; +import { SafeSend } from "src/universal/SafeSend.sol"; /// @title Faucet /// @notice Faucet contract that drips ETH to users. diff --git a/packages/contracts-bedrock/src/universal/SafeSend.sol b/packages/contracts-bedrock/src/universal/SafeSend.sol new file mode 100644 index 0000000000000..37f41e862d8de --- /dev/null +++ b/packages/contracts-bedrock/src/universal/SafeSend.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +/// @title SafeSend +/// @notice Sends ETH to a recipient account without triggering any code. +contract SafeSend { + /// @param _recipient Account to send ETH to. + constructor(address payable _recipient) payable { + selfdestruct(_recipient); + } +} diff --git a/packages/contracts-bedrock/test/L2/ETHLiquidity.t.sol b/packages/contracts-bedrock/test/L2/ETHLiquidity.t.sol new file mode 100644 index 0000000000000..77dc179adb871 --- /dev/null +++ b/packages/contracts-bedrock/test/L2/ETHLiquidity.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Testing utilities +import { CommonTest } from "test/setup/CommonTest.sol"; + +// Target contract +import "src/libraries/errors/CommonErrors.sol"; + +contract ETHLiquidity_Test is CommonTest { + /// @notice Emitted when an address burns ETH liquidity. + event LiquidityBurned(address indexed caller, uint256 value); + + /// @notice Emitted when an address mints ETH liquidity. + event LiquidityMinted(address indexed caller, uint256 value); + + /// @notice The starting balance of the ETHLiquidity contract. + uint256 public constant STARTING_LIQUIDITY_BALANCE = type(uint248).max; + + /// @notice Test setup. + function setUp() public virtual override { + super.enableInterop(); + super.setUp(); + } + + /// @notice Tests that the burn function can be called by an authorized caller. + function test_burn_fromAuthorizedCaller_succeeds() public { + // Arrange + uint256 amount = 1000; + vm.deal(address(superchainWeth), amount); + + // Act + vm.expectEmit(address(ethLiquidity)); + emit LiquidityBurned(address(superchainWeth), amount); + vm.prank(address(superchainWeth)); + ethLiquidity.burn{ value: amount }(); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE + amount); + } + + /// @notice Tests that the burn function reverts when called by an unauthorized caller. + function test_burn_fromUnauthorizedCaller_fails() public { + // Arrange + uint256 amount = 1000; + vm.deal(address(superchainWeth), amount); + + // Act + vm.expectRevert(Unauthorized.selector); + ethLiquidity.burn{ value: amount }(); + + // Assert + assertEq(address(superchainWeth).balance, amount); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + } + + /// @notice Tests that the burn function reverts when called on a custom gas token chain. + function test_burn_fromCustomGasTokenChain_fails() public { + // Arrange + uint256 amount = 1000; + vm.deal(address(superchainWeth), amount); + vm.mockCall(address(l1Block), abi.encodeCall(l1Block.isCustomGasToken, ()), abi.encode(true)); + + // Act + vm.prank(address(superchainWeth)); + vm.expectRevert(NotCustomGasToken.selector); + ethLiquidity.burn{ value: amount }(); + + // Assert + assertEq(address(superchainWeth).balance, amount); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + } + + /// @notice Tests that the burn function can always be called by an authorized caller. + /// @param _amount Amount of ETH (in wei) to call the burn function with. + function testFuzz_burn_fromAuthorizedCaller_succeeds(uint256 _amount) public { + // Assume + vm.assume(_amount < type(uint248).max); + + // Arrange + vm.deal(address(superchainWeth), _amount); + + // Act + vm.expectEmit(address(ethLiquidity)); + emit LiquidityBurned(address(superchainWeth), _amount); + vm.prank(address(superchainWeth)); + ethLiquidity.burn{ value: _amount }(); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE + _amount); + } + + /// @notice Tests that the burn function always reverts when called by an unauthorized caller. + /// @param _amount Amount of ETH (in wei) to call the burn function with. + /// @param _caller Address of the caller to call the burn function with. + function testFuzz_burn_fromUnauthorizedCaller_fails(uint256 _amount, address _caller) public { + // Assume + vm.assume(_amount < type(uint248).max); + vm.assume(_caller != address(superchainWeth)); + + // Arrange + vm.deal(_caller, _amount); + + // Act + vm.prank(_caller); + vm.expectRevert(Unauthorized.selector); + ethLiquidity.burn{ value: _amount }(); + + // Assert + assertEq(_caller.balance, _amount); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + } + + /// @notice Tests that the mint function can be called by an authorized caller. + function test_mint_fromAuthorizedCaller_succeeds() public { + // Arrange + uint256 amount = 1000; + + // Act + vm.expectEmit(address(ethLiquidity)); + emit LiquidityMinted(address(superchainWeth), amount); + vm.prank(address(superchainWeth)); + ethLiquidity.mint(amount); + + // Assert + assertEq(address(superchainWeth).balance, amount); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE - amount); + assertEq(superchainWeth.balanceOf(address(ethLiquidity)), 0); + } + + /// @notice Tests that the mint function reverts when called by an unauthorized caller. + function test_mint_fromUnauthorizedCaller_fails() public { + // Arrange + uint256 amount = 1000; + + // Act + vm.expectRevert(Unauthorized.selector); + ethLiquidity.mint(amount); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + assertEq(superchainWeth.balanceOf(address(ethLiquidity)), 0); + } + + /// @notice Tests that the mint function reverts when called on a custom gas token chain. + function test_mint_fromCustomGasTokenChain_fails() public { + // Arrange + uint256 amount = 1000; + vm.mockCall(address(l1Block), abi.encodeCall(l1Block.isCustomGasToken, ()), abi.encode(true)); + + // Act + vm.prank(address(superchainWeth)); + vm.expectRevert(NotCustomGasToken.selector); + ethLiquidity.mint(amount); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + assertEq(superchainWeth.balanceOf(address(ethLiquidity)), 0); + } + + /// @notice Tests that the mint function fails when the amount requested is greater than the + /// available balance. In practice this should never happen because the starting + /// balance is expected to be uint248 wei, the total ETH supply is far less than that + /// amount, and the only contract that pulls from here is the SuperchainWETH contract + /// which will always burn ETH somewhere before minting it somewhere else. It needs to + /// be a system-wide invariant that this condition is never triggered in the first + /// place but it is the behavior we expect if it does happen. + function test_mint_moreThanAvailableBalance_fails() public { + // Arrange + uint256 amount = STARTING_LIQUIDITY_BALANCE + 1; + + // Act + vm.expectRevert(); + ethLiquidity.mint(amount); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + assertEq(superchainWeth.balanceOf(address(ethLiquidity)), 0); + } + + /// @notice Tests that the mint function can always be called by an authorized caller. + /// @param _amount Amount of ETH (in wei) to call the mint function with. + function testFuzz_mint_fromAuthorizedCaller_succeeds(uint256 _amount) public { + // Assume + vm.assume(_amount < type(uint248).max); + + // Arrange + // Nothing to arrange. + + // Act + vm.expectEmit(address(ethLiquidity)); + emit LiquidityMinted(address(superchainWeth), _amount); + vm.prank(address(superchainWeth)); + ethLiquidity.mint(_amount); + + // Assert + assertEq(address(superchainWeth).balance, _amount); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE - _amount); + assertEq(superchainWeth.balanceOf(address(ethLiquidity)), 0); + } + + /// @notice Tests that the mint function always reverts when called by an unauthorized caller. + /// @param _amount Amount of ETH (in wei) to call the mint function with. + /// @param _caller Address of the caller to call the mint function with. + function testFuzz_mint_fromUnauthorizedCaller_fails(uint256 _amount, address _caller) public { + // Assume + vm.assume(_amount < type(uint248).max); + vm.assume(_caller != address(superchainWeth)); + vm.assume(address(_caller).balance == 0); + + // Arrange + // Nothing to arrange. + + // Act + vm.prank(_caller); + vm.expectRevert(Unauthorized.selector); + ethLiquidity.mint(_amount); + + // Assert + assertEq(_caller.balance, 0); + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + assertEq(superchainWeth.balanceOf(address(ethLiquidity)), 0); + } +} diff --git a/packages/contracts-bedrock/test/L2Genesis.t.sol b/packages/contracts-bedrock/test/L2Genesis.t.sol index 80d6656ac1d4b..2bb969546a52f 100644 --- a/packages/contracts-bedrock/test/L2Genesis.t.sol +++ b/packages/contracts-bedrock/test/L2Genesis.t.sol @@ -150,8 +150,8 @@ contract L2GenesisTest is Test { // 2 predeploys do not have proxies assertEq(getCodeCount(_path, "Proxy.sol:Proxy"), Predeploys.PREDEPLOY_COUNT - 2); - // 19 proxies have the implementation set if useInterop is true and 17 if useInterop is false - assertEq(getPredeployCountWithSlotSet(_path, Constants.PROXY_IMPLEMENTATION_ADDRESS), _useInterop ? 19 : 17); + // 21 proxies have the implementation set if useInterop is true and 17 if useInterop is false + assertEq(getPredeployCountWithSlotSet(_path, Constants.PROXY_IMPLEMENTATION_ADDRESS), _useInterop ? 21 : 17); // All proxies except 2 have the proxy 1967 admin slot set to the proxy admin assertEq( diff --git a/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol b/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol new file mode 100644 index 0000000000000..8f2a31ad5416e --- /dev/null +++ b/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { StdUtils } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; + +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { SuperchainWETH } from "src/L2/SuperchainWETH.sol"; +import { IL2ToL2CrossDomainMessenger } from "src/L2/IL2ToL2CrossDomainMessenger.sol"; + +import { CommonTest } from "test/setup/CommonTest.sol"; + +contract SuperchainWETH_User is StdUtils { + /// @notice Cross domain message data. + struct MessageData { + bytes32 id; + uint256 amount; + } + + /// @notice Flag to indicate if the test has failed. + bool public failed = false; + + /// @notice The Vm contract. + Vm internal vm; + + /// @notice The SuperchainWETH contract. + SuperchainWETH internal weth; + + /// @notice Mapping of sent messages. + mapping(bytes32 => bool) internal sent; + + /// @notice Array of unrelayed messages. + MessageData[] internal unrelayed; + + /// @param _vm The Vm contract. + /// @param _weth The SuperchainWETH contract. + constructor(Vm _vm, SuperchainWETH _weth) { + vm = _vm; + weth = _weth; + + // Deal ourselves uint248 wei (much greater than total ETH supply). + vm.deal(address(this), type(uint248).max); + } + + /// @notice Allow the contract to receive ETH. + receive() external payable {} + + /// @notice Deposit ETH into the contract. + /// @param _amount The amount of ETH to deposit. + function deposit(uint256 _amount) public { + // Bound deposit amount to our ETH balance. + _amount = bound(_amount, 0, address(this).balance); + + // Deposit the amount. + try weth.deposit{ value: _amount }() { + // Success. + } catch { + failed = true; + } + } + + /// @notice Withdraw ETH from the contract. + /// @param _amount The amount of ETH to withdraw. + function withdraw(uint256 _amount) public { + // Bound withdraw amount to our WETH balance. + _amount = bound(_amount, 0, weth.balanceOf(address(this))); + + // Withdraw the amount. + try weth.withdraw(_amount) { + // Success. + } catch { + failed = true; + } + } + + /// @notice Send ERC20 tokens to another chain. + /// @param _amount The amount of ERC20 tokens to send. + /// @param _chainId The chain ID to send the tokens to. + /// @param _messageId The message ID. + function sendERC20(uint256 _amount, uint256 _chainId, bytes32 _messageId) public { + // Make sure we aren't reusing a message ID. + if (sent[_messageId]) { + return; + } + + // Bound send amount to our WETH balance. + _amount = bound(_amount, 0, weth.balanceOf(address(this))); + + // Send the amount. + try weth.sendERC20(_amount, _chainId) { + // Success. + } catch { + failed = true; + } + + // Mark message as sent. + sent[_messageId] = true; + unrelayed.push(MessageData({ id: _messageId, amount: _amount })); + } + + /// @notice Relay a message from another chain. + function relayMessage() public { + // Make sure there are unrelayed messages. + if (unrelayed.length == 0) { + return; + } + + // Grab the latest unrelayed message. + MessageData memory message = unrelayed[unrelayed.length - 1]; + + // Simulate the cross-domain message. + // Make sure the cross-domain message sender is set to this contract. + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSender, ()), + abi.encode(address(weth)) + ); + + // Prank the finalizeSendERC20 function. + // Balance will just go back to our own account. + vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + try weth.finalizeSendERC20(address(this), message.amount) { + // Success. + } catch { + failed = true; + } + + // Remove the message from the unrelayed list. + unrelayed.pop(); + } +} + +contract SuperchainWETH_SendSucceeds_Invariant is CommonTest { + SuperchainWETH_User internal actor; + + /// @notice Test setup. + function setUp() public override { + super.enableInterop(); + super.setUp(); + + // Create a new SuperchainWETH_User actor. + actor = new SuperchainWETH_User(vm, superchainWeth); + + // Set the target contract. + targetContract(address(actor)); + + // Set the target selectors. + bytes4[] memory selectors = new bytes4[](4); + selectors[0] = actor.deposit.selector; + selectors[1] = actor.withdraw.selector; + selectors[2] = actor.sendERC20.selector; + selectors[3] = actor.relayMessage.selector; + FuzzSelector memory selector = FuzzSelector({ addr: address(actor), selectors: selectors }); + targetSelector(selector); + } + + /// @notice Invariant that checks that sending WETH always succeeds. + /// @custom:invariant Calls to sendERC20 should always succeed as long as the actor has less + /// than uint248 wei which is much greater than the total ETH supply. Actor's + /// balance should also not increase out of nowhere. + function invariant_sendERC20_succeeds() public view { + // Assert that the actor has not failed to send WETH. + assertEq(actor.failed(), false); + + // Assert that the actor's balance has not somehow increased. + assertLe(address(actor).balance, type(uint248).max); + } +} diff --git a/packages/contracts-bedrock/test/setup/Setup.sol b/packages/contracts-bedrock/test/setup/Setup.sol index 7cf4d2a47d08f..537585d7105d7 100644 --- a/packages/contracts-bedrock/test/setup/Setup.sol +++ b/packages/contracts-bedrock/test/setup/Setup.sol @@ -41,6 +41,8 @@ import { Vm } from "forge-std/Vm.sol"; import { SuperchainConfig } from "src/L1/SuperchainConfig.sol"; import { DataAvailabilityChallenge } from "src/L1/DataAvailabilityChallenge.sol"; import { WETH } from "src/L2/WETH.sol"; +import { SuperchainWETH } from "src/L2/SuperchainWETH.sol"; +import { ETHLiquidity } from "src/L2/ETHLiquidity.sol"; /// @title Setup /// @dev This contact is responsible for setting up the contracts in state. It currently @@ -94,6 +96,8 @@ contract Setup { LegacyMessagePasser legacyMessagePasser = LegacyMessagePasser(Predeploys.LEGACY_MESSAGE_PASSER); GovernanceToken governanceToken = GovernanceToken(Predeploys.GOVERNANCE_TOKEN); WETH weth = WETH(payable(Predeploys.WETH)); + SuperchainWETH superchainWeth = SuperchainWETH(payable(Predeploys.SUPERCHAIN_WETH)); + ETHLiquidity ethLiquidity = ETHLiquidity(Predeploys.ETH_LIQUIDITY); /// @dev Deploys the Deploy contract without including its bytecode in the bytecode /// of this contract by fetching the bytecode dynamically using `vm.getCode()`. @@ -211,6 +215,8 @@ contract Setup { labelPredeploy(Predeploys.EAS); labelPredeploy(Predeploys.SCHEMA_REGISTRY); labelPredeploy(Predeploys.WETH); + labelPredeploy(Predeploys.SUPERCHAIN_WETH); + labelPredeploy(Predeploys.ETH_LIQUIDITY); // L2 Preinstalls labelPreinstall(Preinstalls.MultiCall3);