diff --git a/packages/contracts-bedrock/invariant-docs/ETHLiquidity.md b/packages/contracts-bedrock/invariant-docs/ETHLiquidity.md new file mode 100644 index 0000000000000..bbf6eb0614763 --- /dev/null +++ b/packages/contracts-bedrock/invariant-docs/ETHLiquidity.md @@ -0,0 +1,5 @@ +# `ETHLiquidity` Invariants + +## Calls to mint/burn repeatedly should never cause the actor's balance to increase beyond the starting balance. +**Test:** [`ETHLiquidity.t.sol#L83`](../test/invariants/ETHLiquidity.t.sol#L83) + diff --git a/packages/contracts-bedrock/invariant-docs/README.md b/packages/contracts-bedrock/invariant-docs/README.md index eae292a89cc29..86f2cd4b38f84 100644 --- a/packages/contracts-bedrock/invariant-docs/README.md +++ b/packages/contracts-bedrock/invariant-docs/README.md @@ -10,6 +10,7 @@ This directory contains documentation for all defined invariant tests within `co - [Burn.Eth](./Burn.Eth.md) - [Burn.Gas](./Burn.Gas.md) - [CrossDomainMessenger](./CrossDomainMessenger.md) +- [ETHLiquidity](./ETHLiquidity.md) - [Encoding](./Encoding.md) - [FaultDisputeGame](./FaultDisputeGame.md) - [Hashing](./Hashing.md) @@ -19,6 +20,7 @@ This directory contains documentation for all defined invariant tests within `co - [OptimismPortal2](./OptimismPortal2.md) - [ResourceMetering](./ResourceMetering.md) - [SafeCall](./SafeCall.md) +- [SuperchainWETH](./SuperchainWETH.md) - [SystemConfig](./SystemConfig.md) diff --git a/packages/contracts-bedrock/invariant-docs/SuperchainWETH.md b/packages/contracts-bedrock/invariant-docs/SuperchainWETH.md new file mode 100644 index 0000000000000..aaed4710b5dca --- /dev/null +++ b/packages/contracts-bedrock/invariant-docs/SuperchainWETH.md @@ -0,0 +1,5 @@ +# `SuperchainWETH` Invariants + +## 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. +**Test:** [`SuperchainWETH.t.sol#L171`](../test/invariants/SuperchainWETH.t.sol#L171) + diff --git a/packages/contracts-bedrock/scripts/ForgeArtifacts.sol b/packages/contracts-bedrock/scripts/ForgeArtifacts.sol index 77d8229ac099c..30230a4878b3e 100644 --- a/packages/contracts-bedrock/scripts/ForgeArtifacts.sol +++ b/packages/contracts-bedrock/scripts/ForgeArtifacts.sol @@ -78,8 +78,8 @@ library ForgeArtifacts { string[] memory cmd = new string[](3); cmd[0] = Executables.bash; cmd[1] = "-c"; - cmd[2] = string.concat(Executables.jq, " '.methodIdentifiers | keys' < ", _getForgeArtifactPath(_name)); - bytes memory res = Process.run(cmd); + cmd[2] = string.concat(Executables.jq, " '.methodIdentifiers // {} | keys < ' ", _getForgeArtifactPath(_name)); + bytes memory res = Process.run(cmd, true); ids_ = stdJson.readStringArray(string(res), ""); } diff --git a/packages/contracts-bedrock/scripts/L2Genesis.s.sol b/packages/contracts-bedrock/scripts/L2Genesis.s.sol index e1e43efebc8c7..e6397fec3c33d 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 } } @@ -463,18 +465,31 @@ contract L2Genesis is Deployer { vm.resetNonce(address(eas)); } - /// @notice This predeploy is following the saftey invariant #2. + /// @notice This predeploy is following the safety invariant #2. /// This contract has no initializer. function setCrossL2Inbox() internal { _setImplementationCode(Predeploys.CROSS_L2_INBOX); } - /// @notice This predeploy is following the saftey invariant #2. + /// @notice This predeploy is following the safety invariant #2. /// This contract has no initializer. function setL2ToL2CrossDomainMessenger() internal { _setImplementationCode(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); } + /// @notice This predeploy is following the safety 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 safety 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/semver-lock.json b/packages/contracts-bedrock/semver-lock.json index 292eb473840ab..7c39fb867f6e1 100644 --- a/packages/contracts-bedrock/semver-lock.json +++ b/packages/contracts-bedrock/semver-lock.json @@ -67,6 +67,10 @@ "initCodeHash": "0x074af4b17cfdd1d1dafaaccb79d68ab4ceef50d35dc205aeeedc265e11ae2a92", "sourceCodeHash": "0x5b4355b060e8e5ab81047e5f3d093869c2be7bae14a48a0e5ddf6872a219faf2" }, + "src/L2/ETHLiquidity.sol": { + "initCodeHash": "0x98177562fca0de0dfea5313c9acefe2fdbd73dee5ce6c1232055601f208f0177", + "sourceCodeHash": "0x67a3c0c10e2bf5b8187b82ae6263ff8eeb2817bc2ba801c0b5f78776f9a49d52" + }, "src/L2/GasPriceOracle.sol": { "initCodeHash": "0xb16f1e370e58c7693fd113a21a1b1e7ccebc03d4f1e5a76786fc27847ef51ead", "sourceCodeHash": "0x5529ee28aae94904a1c08a8b188f51a39a0f51fbd3b43f1abd4fee7bba57998c" @@ -107,6 +111,10 @@ "initCodeHash": "0xb94145f571e92ee615c6fe903b6568e8aac5fe760b6b65148ffc45d2fb0f5433", "sourceCodeHash": "0x8f2a54104e5e7105ba03ba37e3ef9b6684a447245f0e0b787ba4cca12957b97c" }, + "src/L2/SuperchainWETH.sol": { + "initCodeHash": "0x52e302ac749e6a519829e0fb01075638e481e7f010a6438088486a7a4be4601b", + "sourceCodeHash": "0x01b9e78426a7e30af706134c78752edc86494beb8cd74eb3babaf51f3b7c639e" + }, "src/L2/WETH.sol": { "initCodeHash": "0xde72ae96910e95249623c2d695749847e4c4adeaf96a7a35033afd77318a528a", "sourceCodeHash": "0xbe200a6cb297a3ca1a7d174a9c886e3f17eb8edf617ad014a2ac4f6c2e2ac7f1" diff --git a/packages/contracts-bedrock/snapshots/abi/ETHLiquidity.json b/packages/contracts-bedrock/snapshots/abi/ETHLiquidity.json new file mode 100644 index 0000000000000..5fd386c52e5ae --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/ETHLiquidity.json @@ -0,0 +1,83 @@ +[ + { + "inputs": [], + "name": "burn", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "LiquidityBurned", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "LiquidityMinted", + "type": "event" + }, + { + "inputs": [], + "name": "NotCustomGasToken", + "type": "error" + }, + { + "inputs": [], + "name": "Unauthorized", + "type": "error" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/abi/SuperchainWETH.json b/packages/contracts-bedrock/snapshots/abi/SuperchainWETH.json new file mode 100644 index 0000000000000..42d3a73e7c56c --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/SuperchainWETH.json @@ -0,0 +1,404 @@ +[ + { + "stateMutability": "payable", + "type": "fallback" + }, + { + "stateMutability": "payable", + "type": "receive" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "guy", + "type": "address" + }, + { + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "deposit", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "dst", + "type": "address" + }, + { + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "relayERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "dst", + "type": "address" + }, + { + "internalType": "uint256", + "name": "wad", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + } + ], + "name": "sendERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "dst", + "type": "address" + }, + { + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "src", + "type": "address" + }, + { + "internalType": "address", + "name": "dst", + "type": "address" + }, + { + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "src", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "guy", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "dst", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "Deposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "RelayERC20", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "_chainId", + "type": "uint256" + } + ], + "name": "SendERC20", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "src", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "dst", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "src", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "Withdrawal", + "type": "event" + }, + { + "inputs": [], + "name": "NotCustomGasToken", + "type": "error" + }, + { + "inputs": [], + "name": "Unauthorized", + "type": "error" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/storageLayout/ETHLiquidity.json b/packages/contracts-bedrock/snapshots/storageLayout/ETHLiquidity.json new file mode 100644 index 0000000000000..0637a088a01e8 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/ETHLiquidity.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/storageLayout/SuperchainWETH.json b/packages/contracts-bedrock/snapshots/storageLayout/SuperchainWETH.json new file mode 100644 index 0000000000000..ac5f38a75a045 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/SuperchainWETH.json @@ -0,0 +1,16 @@ +[ + { + "bytes": "32", + "label": "balanceOf", + "offset": 0, + "slot": "0", + "type": "mapping(address => uint256)" + }, + { + "bytes": "32", + "label": "allowance", + "offset": 0, + "slot": "1", + "type": "mapping(address => mapping(address => uint256))" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/L2/ETHLiquidity.sol b/packages/contracts-bedrock/src/L2/ETHLiquidity.sol new file mode 100644 index 0000000000000..582a36616c7a3 --- /dev/null +++ b/packages/contracts-bedrock/src/L2/ETHLiquidity.sol @@ -0,0 +1,41 @@ +// 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-beta.1"; + + /// @notice Allows an address to lock ETH liquidity into this contract. + function burn() external payable { + if (msg.sender != Predeploys.SUPERCHAIN_WETH) revert Unauthorized(); + if (L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) revert 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 { + if (msg.sender != Predeploys.SUPERCHAIN_WETH) revert Unauthorized(); + if (L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) revert NotCustomGasToken(); + new SafeSend{ value: _amount }(payable(msg.sender)); + emit LiquidityMinted(msg.sender, _amount); + } +} diff --git a/packages/contracts-bedrock/src/L2/ISuperchainERC20.sol b/packages/contracts-bedrock/src/L2/ISuperchainERC20.sol new file mode 100644 index 0000000000000..32cb4bb6e5bde --- /dev/null +++ b/packages/contracts-bedrock/src/L2/ISuperchainERC20.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title ISuperchainERC20Extensions +/// @notice Interface for the extensions to the ERC20 standard that are used by SuperchainERC20. +/// exists in case developers are already importing the ERC20 interface separately and +/// importing the full SuperchainERC20 interface would cause conflicting imports. +interface ISuperchainERC20Extensions { + /// @notice Emitted when tokens are sent from one chain to another. + /// @param _from Address of the sender. + /// @param _to Address of the recipient. + /// @param _amount Number of tokens sent. + /// @param _chainId Chain ID of the recipient. + event SendERC20(address indexed _from, address indexed _to, uint256 _amount, uint256 _chainId); + + /// @notice Emitted when token sends are relayed to this chain. + /// @param _to Address of the recipient. + /// @param _amount Number of tokens sent. + event RelayERC20(address indexed _to, uint256 _amount); + + /// @notice Sends tokens to another chain. + /// @param _to Address of the recipient. + /// @param _amount Number of tokens to send. + /// @param _chainId Chain ID of the recipient. + function sendERC20(address _to, uint256 _amount, uint256 _chainId) external; + + /// @notice Relays a send of tokens to this chain. + /// @param _to Address of the recipient. + /// @param _amount Number of tokens sent. + function relayERC20(address _to, uint256 _amount) external; +} + +/// @title ISuperchainERC20 +/// @notice Combines the ERC20 interface with the SuperchainERC20Extensions interface. +interface ISuperchainERC20 is IERC20, ISuperchainERC20Extensions { } diff --git a/packages/contracts-bedrock/src/L2/SuperchainWETH.sol b/packages/contracts-bedrock/src/L2/SuperchainWETH.sol new file mode 100644 index 0000000000000..d02c30e674879 --- /dev/null +++ b/packages/contracts-bedrock/src/L2/SuperchainWETH.sol @@ -0,0 +1,91 @@ +// 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 { ISuperchainERC20Extensions } from "src/L2/ISuperchainERC20.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, ISuperchainERC20Extensions, ISemver { + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant version = "1.0.0-beta.1"; + + /// @inheritdoc WETH98 + function deposit() public payable override { + if (L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) revert NotCustomGasToken(); + super.deposit(); + } + + /// @inheritdoc WETH98 + function withdraw(uint256 wad) public override { + if (L1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()) revert NotCustomGasToken(); + super.withdraw(wad); + } + + /// @inheritdoc ISuperchainERC20Extensions + function sendERC20(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.relayERC20, (dst, wad)) + }); + + // Emit event. + emit SendERC20(msg.sender, dst, wad, chainId); + } + + /// @inheritdoc ISuperchainERC20Extensions + function relayERC20(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); + + // Emit event. + emit RelayERC20(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..4e99c6678d85f --- /dev/null +++ b/packages/contracts-bedrock/src/universal/SafeSend.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @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..07162f60aa701 --- /dev/null +++ b/packages/contracts-bedrock/test/L2/ETHLiquidity.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Testing utilities +import { CommonTest } from "test/setup/CommonTest.sol"; + +// Error imports +import "src/libraries/errors/CommonErrors.sol"; + +/// @title ETHLiquidity_Test +/// @notice Contract for testing the ETHLiquidity contract. +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 contract is set up with the correct starting balance. + function test_setup_succeeds() public view { + // Assert + assertEq(address(ethLiquidity).balance, STARTING_LIQUIDITY_BALANCE); + } + + /// @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/L2/SuperchainWETH.t.sol b/packages/contracts-bedrock/test/L2/SuperchainWETH.t.sol new file mode 100644 index 0000000000000..093f32135545a --- /dev/null +++ b/packages/contracts-bedrock/test/L2/SuperchainWETH.t.sol @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Testing utilities +import { CommonTest } from "test/setup/CommonTest.sol"; + +// Contract imports +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { IL2ToL2CrossDomainMessenger } from "src/L2/IL2ToL2CrossDomainMessenger.sol"; +import { ETHLiquidity } from "src/L2/ETHLiquidity.sol"; + +// Error imports +import "src/libraries/errors/CommonErrors.sol"; + +/// @title SuperchainWETH_Test +/// @notice Contract for testing the SuperchainWETH contract. +contract SuperchainWETH_Test is CommonTest { + /// @notice Emitted when a transfer is made. + event Transfer(address indexed src, address indexed dst, uint256 wad); + + /// @notice Emitted when a deposit is made. + event Deposit(address indexed dst, uint256 wad); + + /// @notice Emitted when a withdrawal is made. + event Withdrawal(address indexed src, uint256 wad); + + /// @notice Emitted when an ERC20 is sent. + event SendERC20(address indexed _from, address indexed _to, uint256 _amount, uint256 _chainId); + + /// @notice Emitted when an ERC20 send is relayed. + event RelayERC20(address indexed _to, uint256 _amount); + + /// @notice Test setup. + function setUp() public virtual override { + super.enableInterop(); + super.setUp(); + } + + /// @notice Tests that the deposit function can be called on a non-custom gas token chain. + function test_deposit_fromNonCustomGasTokenChain_succeeds() public { + // Arrange + uint256 amount = 1000; + vm.deal(alice, amount); + + // Act + vm.expectEmit(address(superchainWeth)); + emit Deposit(alice, amount); + vm.prank(alice); + superchainWeth.deposit{ value: amount }(); + + // Assert + assertEq(alice.balance, 0); + assertEq(superchainWeth.balanceOf(alice), amount); + } + + /// @notice Tests that the deposit function reverts when called on a custom gas token chain. + function test_deposit_fromCustomGasTokenChain_fails() public { + // Arrange + uint256 amount = 1000; + vm.deal(address(alice), amount); + vm.mockCall(address(l1Block), abi.encodeCall(l1Block.isCustomGasToken, ()), abi.encode(true)); + + // Act + vm.prank(alice); + vm.expectRevert(NotCustomGasToken.selector); + superchainWeth.deposit{ value: amount }(); + + // Assert + assertEq(alice.balance, amount); + assertEq(superchainWeth.balanceOf(alice), 0); + } + + /// @notice Tests that the withdraw function can be called on a non-custom gas token chain. + function test_withdraw_fromNonCustomGasTokenChain_succeeds() public { + // Arrange + uint256 amount = 1000; + vm.deal(alice, amount); + vm.prank(alice); + superchainWeth.deposit{ value: amount }(); + + // Act + vm.expectEmit(address(superchainWeth)); + emit Withdrawal(alice, amount); + vm.prank(alice); + superchainWeth.withdraw(amount); + + // Assert + assertEq(alice.balance, amount); + assertEq(superchainWeth.balanceOf(alice), 0); + } + + /// @notice Tests that the withdraw function reverts when called on a custom gas token chain. + function test_withdraw_fromCustomGasTokenChain_fails() public { + // Arrange + uint256 amount = 1000; + vm.deal(alice, amount); + vm.prank(alice); + superchainWeth.deposit{ value: amount }(); + vm.mockCall(address(l1Block), abi.encodeCall(l1Block.isCustomGasToken, ()), abi.encode(true)); + + // Act + vm.prank(alice); + vm.expectRevert(NotCustomGasToken.selector); + superchainWeth.withdraw(amount); + + // Assert + assertEq(alice.balance, 0); + assertEq(superchainWeth.balanceOf(alice), amount); + } + + /// @notice Tests that the sendERC20 function can be called with a sufficient balance on a + /// non-custom gas token chain. Also tests that the proper calls are made, ETH is + /// burned, and the proper events are emitted. + function test_sendERC20_sufficientBalance_succeeds() public { + // Arrange + uint256 amount = 1000; + uint256 chainId = 1; + vm.deal(alice, amount); + vm.prank(alice); + superchainWeth.deposit{ value: amount }(); + + // Act + vm.expectEmit(address(superchainWeth)); + emit Transfer(alice, address(0), amount); + vm.expectEmit(address(superchainWeth)); + emit SendERC20(alice, bob, amount, chainId); + vm.expectCall(Predeploys.ETH_LIQUIDITY, abi.encodeCall(ETHLiquidity.burn, ()), 1); + vm.expectCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall( + IL2ToL2CrossDomainMessenger.sendMessage, + (chainId, address(superchainWeth), abi.encodeCall(superchainWeth.relayERC20, (bob, amount))) + ), + 1 + ); + vm.prank(alice); + superchainWeth.sendERC20(bob, amount, chainId); + + // Assert + assertEq(alice.balance, 0); + assertEq(superchainWeth.balanceOf(alice), 0); + } + + /// @notice Tests that the sendERC20 function can be called with a sufficient balance on a + /// custom gas token chain. Also tests that the proper calls are made and the proper + /// events are emitted but ETH is not burned via the ETHLiquidity contract. + function test_sendERC20_sufficientFromCustomGasTokenChain_succeeds() public { + // Arrange + uint256 amount = 1000; + uint256 chainId = 1; + vm.deal(alice, amount); + vm.prank(alice); + superchainWeth.deposit{ value: amount }(); + vm.mockCall(address(l1Block), abi.encodeCall(l1Block.isCustomGasToken, ()), abi.encode(true)); + + // Act + vm.expectEmit(address(superchainWeth)); + emit Transfer(alice, address(0), amount); + vm.expectEmit(address(superchainWeth)); + emit SendERC20(alice, bob, amount, chainId); + vm.expectCall(Predeploys.ETH_LIQUIDITY, abi.encodeCall(ETHLiquidity.burn, ()), 0); + vm.expectCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall( + IL2ToL2CrossDomainMessenger.sendMessage, + (chainId, address(superchainWeth), abi.encodeCall(superchainWeth.relayERC20, (bob, amount))) + ), + 1 + ); + vm.prank(alice); + superchainWeth.sendERC20(bob, amount, chainId); + + // Assert + assertEq(alice.balance, 0); + assertEq(superchainWeth.balanceOf(alice), 0); + } + + /// @notice Tests that the sendERC20 function reverts when called with insufficient balance. + function test_sendERC20_insufficientBalance_fails() public { + // Arrange + uint256 amount = 1000; + uint256 chainId = 1; + vm.deal(alice, amount); + vm.prank(alice); + superchainWeth.deposit{ value: amount }(); + + // Act + vm.expectRevert(); + superchainWeth.sendERC20(bob, amount + 1, chainId); + + // Assert + assertEq(alice.balance, 0); + assertEq(superchainWeth.balanceOf(alice), amount); + } + + /// @notice Tests that the sendERC20 function always succeeds when called with a sufficient + /// balance no matter the sender, amount, recipient, or chain ID. + /// @param _amount The amount of WETH to send. + /// @param _caller The address of the caller. + /// @param _recipient The address of the recipient. + /// @param _chainId The chain ID to send the WETH to. + function testFuzz_sendERC20_sufficientBalance_succeeds( + uint256 _amount, + address _caller, + address _recipient, + uint256 _chainId + ) + public + { + // Assume + _amount = bound(_amount, 0, type(uint248).max); + + // Arrange + vm.deal(_caller, _amount); + vm.prank(_caller); + superchainWeth.deposit{ value: _amount }(); + + // Act + vm.expectEmit(address(superchainWeth)); + emit Transfer(_caller, address(0), _amount); + vm.expectEmit(address(superchainWeth)); + emit SendERC20(_caller, _recipient, _amount, _chainId); + vm.expectCall(Predeploys.ETH_LIQUIDITY, abi.encodeCall(ETHLiquidity.burn, ()), 1); + vm.expectCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall( + IL2ToL2CrossDomainMessenger.sendMessage, + (_chainId, address(superchainWeth), abi.encodeCall(superchainWeth.relayERC20, (_recipient, _amount))) + ), + 1 + ); + vm.prank(_caller); + superchainWeth.sendERC20(_recipient, _amount, _chainId); + + // Assert + assertEq(_caller.balance, 0); + assertEq(superchainWeth.balanceOf(_caller), 0); + } + + /// @notice Tests that the relayERC20 function can be called from the + /// L2ToL2CrossDomainMessenger as long as the crossDomainMessageSender is the + function test_relayERC20_fromMessenger_succeeds() public { + // Arrange + uint256 amount = 1000; + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSender, ()), + abi.encode(address(superchainWeth)) + ); + + // Act + vm.expectEmit(address(superchainWeth)); + emit RelayERC20(bob, amount); + vm.expectCall(Predeploys.ETH_LIQUIDITY, abi.encodeCall(ETHLiquidity.mint, (amount)), 1); + vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + superchainWeth.relayERC20(bob, amount); + + // Assert + assertEq(address(superchainWeth).balance, amount); + assertEq(superchainWeth.balanceOf(bob), amount); + } + + /// @notice Tests that the relayERC20 function can be called from the + /// L2ToL2CrossDomainMessenger as long as the crossDomainMessageSender is the + /// SuperchainWETH contract, even when the chain is a custom gas token chain. Shows + /// that ETH is not minted in this case but the SuperchainWETH balance is updated. + function test_relayERC20_fromMessengerCustomGasTokenChain_succeeds() public { + // Arrange + uint256 amount = 1000; + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSender, ()), + abi.encode(address(superchainWeth)) + ); + vm.mockCall(address(l1Block), abi.encodeCall(l1Block.isCustomGasToken, ()), abi.encode(true)); + + // Act + vm.expectEmit(address(superchainWeth)); + emit RelayERC20(bob, amount); + vm.expectCall(Predeploys.ETH_LIQUIDITY, abi.encodeCall(ETHLiquidity.mint, (amount)), 0); + vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + superchainWeth.relayERC20(bob, amount); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(superchainWeth.balanceOf(bob), amount); + } + + /// @notice Tests that the relayERC20 function reverts when not called from the + /// L2ToL2CrossDomainMessenger. + function test_relayERC20_notFromMessenger_fails() public { + // Arrange + uint256 amount = 1000; + + // Act + vm.expectRevert(Unauthorized.selector); + vm.prank(alice); + superchainWeth.relayERC20(bob, amount); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(superchainWeth.balanceOf(bob), 0); + } + + /// @notice Tests that the relayERC20 function reverts when called from the + /// L2ToL2CrossDomainMessenger but the crossDomainMessageSender is not the + /// SuperchainWETH contract. + function test_relayERC20_fromMessengerNotFromSuperchainWETH_fails() public { + // Arrange + uint256 amount = 1000; + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSender, ()), + abi.encode(address(alice)) + ); + + // Act + vm.expectRevert(Unauthorized.selector); + vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + superchainWeth.relayERC20(bob, amount); + + // Assert + assertEq(address(superchainWeth).balance, 0); + assertEq(superchainWeth.balanceOf(bob), 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/Safe/DeputyGuardianModule.t.sol b/packages/contracts-bedrock/test/Safe/DeputyGuardianModule.t.sol index 28fa47bb3138d..ba91387352a76 100644 --- a/packages/contracts-bedrock/test/Safe/DeputyGuardianModule.t.sol +++ b/packages/contracts-bedrock/test/Safe/DeputyGuardianModule.t.sol @@ -242,7 +242,7 @@ contract DeputyGuardianModule_NoPortalCollisions_Test is DeputyGuardianModule_Te string[] memory excludes = new string[](2); excludes[0] = "src/L1/OptimismPortal2.sol"; excludes[1] = "src/dispute/lib/*"; - Abi[] memory abis = ForgeArtifacts.getContractFunctionAbis("src/{L1,dispute,universal}/", excludes); + Abi[] memory abis = ForgeArtifacts.getContractFunctionAbis("src/{L1,dispute,universal}", excludes); for (uint256 i; i < abis.length; i++) { for (uint256 j; j < abis[i].entries.length; j++) { bytes4 sel = abis[i].entries[j].sel; diff --git a/packages/contracts-bedrock/test/invariants/ETHLiquidity.t.sol b/packages/contracts-bedrock/test/invariants/ETHLiquidity.t.sol new file mode 100644 index 0000000000000..a7da876adb3be --- /dev/null +++ b/packages/contracts-bedrock/test/invariants/ETHLiquidity.t.sol @@ -0,0 +1,86 @@ +// 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 { ETHLiquidity } from "src/L2/ETHLiquidity.sol"; + +import { CommonTest } from "test/setup/CommonTest.sol"; + +/// @title ETHLiquidity_User +/// @notice Actor contract that interacts with the ETHLiquidity contract. Always pretends to be the +/// SuperchainWETH contract since it's the only contract that can use ETHLiquidity. +contract ETHLiquidity_User is StdUtils { + /// @notice Flag to indicate if the test has failed. + bool public failed = false; + + /// @notice The Vm contract. + Vm internal vm; + + /// @notice The ETHLiquidity contract. + ETHLiquidity internal liquidity; + + /// @param _vm The Vm contract. + /// @param _liquidity The ETHLiquidity contract. + /// @param _balance The initial balance of the contract. + constructor(Vm _vm, ETHLiquidity _liquidity, uint256 _balance) { + vm = _vm; + liquidity = _liquidity; + vm.deal(Predeploys.SUPERCHAIN_WETH, _balance); + } + + /// @notice Mint ETH liquidity. + /// @param _amount The amount of ETH to mint. + function mint(uint256 _amount) public { + vm.prank(Predeploys.SUPERCHAIN_WETH); + liquidity.mint(_amount); + } + + /// @notice Burn ETH liquidity. + /// @param _amount The amount of ETH to burn. + function burn(uint256 _amount) public { + vm.prank(Predeploys.SUPERCHAIN_WETH); + liquidity.burn{ value: _amount }(); + } +} + +/// @title ETHLiquidity_MintBurn_Invariant +/// @notice Invariant that checks that minting/burning ETH liquidity does not cause the actor's +/// balance to magically increase beyond the starting balance. +contract ETHLiquidity_MintBurn_Invariant is CommonTest { + /// @notice Starting balance of the contract. + uint256 internal constant STARTING_BALANCE = type(uint248).max; + + /// @notice The ETHLiquidity_User actor. + ETHLiquidity_User internal actor; + + /// @notice Test setup. + function setUp() public override { + super.enableInterop(); + super.setUp(); + + // Create a new ETHLiquidity_User actor. + actor = new ETHLiquidity_User(vm, ethLiquidity, STARTING_BALANCE); + + // Set the target contract. + targetContract(address(actor)); + + // Set the target selectors. + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = actor.mint.selector; + selectors[1] = actor.burn.selector; + FuzzSelector memory selector = FuzzSelector({ addr: address(actor), selectors: selectors }); + targetSelector(selector); + } + + /// @notice Invariant that checks that repeatedly minting/burning does not cause the actor's + /// balance to increase beyond the starting balance. + /// @custom:invariant Calls to mint/burn repeatedly should never cause the actor's balance to + /// increase beyond the starting balance. + function invariant_mintburn_maintainsBalance() public view { + // Assert that the actor's balance has not somehow increased. + assertLe(address(actor).balance, type(uint248).max); + } +} 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..19318fbda6e13 --- /dev/null +++ b/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol @@ -0,0 +1,177 @@ +// 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"; + +/// @title SuperchainWETH_User +/// @notice Actor contract that interacts with the SuperchainWETH contract. +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. + /// @param _balance The initial balance of the contract. + constructor(Vm _vm, SuperchainWETH _weth, uint256 _balance) { + vm = _vm; + weth = _weth; + vm.deal(address(this), _balance); + } + + /// @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(address(this), _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 relayERC20 function. + // Balance will just go back to our own account. + vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + try weth.relayERC20(address(this), message.amount) { + // Success. + } catch { + failed = true; + } + + // Remove the message from the unrelayed list. + unrelayed.pop(); + } +} + +/// @title SuperchainWETH_SendSucceeds_Invariant +/// @notice Invariant test that checks that sending WETH always succeeds if the actor has a +/// sufficient balance to do so and that the actor's balance does not increase out of +/// nowhere. +contract SuperchainWETH_SendSucceeds_Invariant is CommonTest { + /// @notice Starting balance of the contract. + uint256 internal constant STARTING_BALANCE = type(uint248).max; + + /// @notice The SuperchainWETH_User actor. + 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, STARTING_BALANCE); + + // 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, STARTING_BALANCE); + } +} 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);