diff --git a/.gitmodules b/.gitmodules index f2b84783c409..21ecaedbb77a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -26,3 +26,6 @@ [submodule "packages/contracts-bedrock/lib/automate"] path = packages/contracts-bedrock/lib/automate url = https://github.com/gelatodigital/automate +[submodule "packages/contracts-bedrock/lib/openzeppelin-contracts-v5"] + path = packages/contracts-bedrock/lib/openzeppelin-contracts-v5 + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/packages/contracts-bedrock/foundry.toml b/packages/contracts-bedrock/foundry.toml index e5d8513f254a..61a01fdaf7bd 100644 --- a/packages/contracts-bedrock/foundry.toml +++ b/packages/contracts-bedrock/foundry.toml @@ -13,6 +13,7 @@ optimizer_runs = 999999 remappings = [ '@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts', '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts', + '@openzeppelin/contracts-v5/=lib/openzeppelin-contracts-v5/contracts', '@rari-capital/solmate/=lib/solmate', '@lib-keccak/=lib/lib-keccak/contracts/lib', '@solady/=lib/solady/src', diff --git a/packages/contracts-bedrock/invariant-docs/OptimismSuperchainERC20.md b/packages/contracts-bedrock/invariant-docs/OptimismSuperchainERC20.md new file mode 100644 index 000000000000..0e3150624da5 --- /dev/null +++ b/packages/contracts-bedrock/invariant-docs/OptimismSuperchainERC20.md @@ -0,0 +1,10 @@ +# `OptimismSuperchainERC20` Invariants + +## Calls to sendERC20 should always succeed as long as the actor has enough balance. Actor's balance should also not increase out of nowhere but instead should decrease by the amount sent. +**Test:** [`OptimismSuperchainERC20.t.sol#L194`](../test/invariants/OptimismSuperchainERC20.t.sol#L194) + + + +## Calls to relayERC20 should always succeeds when a message is received from another chain. Actor's balance should only increase by the amount relayed. +**Test:** [`OptimismSuperchainERC20.t.sol#L212`](../test/invariants/OptimismSuperchainERC20.t.sol#L212) + diff --git a/packages/contracts-bedrock/invariant-docs/README.md b/packages/contracts-bedrock/invariant-docs/README.md index 86f2cd4b38f8..0233fb997762 100644 --- a/packages/contracts-bedrock/invariant-docs/README.md +++ b/packages/contracts-bedrock/invariant-docs/README.md @@ -18,6 +18,7 @@ This directory contains documentation for all defined invariant tests within `co - [L2OutputOracle](./L2OutputOracle.md) - [OptimismPortal](./OptimismPortal.md) - [OptimismPortal2](./OptimismPortal2.md) +- [OptimismSuperchainERC20](./OptimismSuperchainERC20.md) - [ResourceMetering](./ResourceMetering.md) - [SafeCall](./SafeCall.md) - [SuperchainWETH](./SuperchainWETH.md) diff --git a/packages/contracts-bedrock/invariant-docs/SuperchainWETH.md b/packages/contracts-bedrock/invariant-docs/SuperchainWETH.md index b9f569e30ef1..4b52a6146f14 100644 --- a/packages/contracts-bedrock/invariant-docs/SuperchainWETH.md +++ b/packages/contracts-bedrock/invariant-docs/SuperchainWETH.md @@ -1,5 +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#L174`](../test/invariants/SuperchainWETH.t.sol#L174) +**Test:** [`SuperchainWETH.t.sol#L181`](../test/invariants/SuperchainWETH.t.sol#L181) diff --git a/packages/contracts-bedrock/lib/openzeppelin-contracts-v5 b/packages/contracts-bedrock/lib/openzeppelin-contracts-v5 new file mode 160000 index 000000000000..dbb6104ce834 --- /dev/null +++ b/packages/contracts-bedrock/lib/openzeppelin-contracts-v5 @@ -0,0 +1 @@ +Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 diff --git a/packages/contracts-bedrock/semver-lock.json b/packages/contracts-bedrock/semver-lock.json index 3980893a8609..0bff80cb7b6c 100644 --- a/packages/contracts-bedrock/semver-lock.json +++ b/packages/contracts-bedrock/semver-lock.json @@ -111,13 +111,17 @@ "initCodeHash": "0xe390be1390edc38fd879d7620538560076d7fcf3ef9debce327a1877d96d3ff0", "sourceCodeHash": "0x20f77dc5a02869c6885b73347fa9e7d2bbc4eaf8a2313f7e7435e456001f7a75" }, + "src/L2/OptimismSuperchainERC20.sol": { + "initCodeHash": "0xd49214518ea1a30a43fac09f28b2cee9be570894a500cef342762c9820a070b0", + "sourceCodeHash": "0x6943d40010dcbd1d51dc3668d0a154fbb1568ea49ebcf3aa039d65ef6eab321b" + }, "src/L2/SequencerFeeVault.sol": { "initCodeHash": "0xb94145f571e92ee615c6fe903b6568e8aac5fe760b6b65148ffc45d2fb0f5433", "sourceCodeHash": "0x8f2a54104e5e7105ba03ba37e3ef9b6684a447245f0e0b787ba4cca12957b97c" }, "src/L2/SuperchainWETH.sol": { - "initCodeHash": "0x52e302ac749e6a519829e0fb01075638e481e7f010a6438088486a7a4be4601b", - "sourceCodeHash": "0x7c93752288f4414777e01c2962aee929a28aef2c1fccdfeba456f22df0f9aa39" + "initCodeHash": "0x599e948350c70d699f8a8be945abffd126097de97fade056d29767128320fe75", + "sourceCodeHash": "0x3df29ee1321418914d88ce303b521bf8267ef234b919870b26639d08d7f806bd" }, "src/L2/WETH.sol": { "initCodeHash": "0xde72ae96910e95249623c2d695749847e4c4adeaf96a7a35033afd77318a528a", diff --git a/packages/contracts-bedrock/snapshots/abi/OptimismSuperchainERC20.json b/packages/contracts-bedrock/snapshots/abi/OptimismSuperchainERC20.json new file mode 100644 index 000000000000..6eb57764a8cb --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/OptimismSuperchainERC20.json @@ -0,0 +1,637 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "result", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_remoteToken", + "type": "address" + }, + { + "internalType": "string", + "name": "_name", + "type": "string" + }, + { + "internalType": "string", + "name": "_symbol", + "type": "string" + }, + { + "internalType": "uint8", + "name": "_decimals", + "type": "uint8" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "relayERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "remoteToken", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_chainId", + "type": "uint256" + } + ], + "name": "sendERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "_interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "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" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Burn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Mint", + "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": "source", + "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": "destination", + "type": "uint256" + } + ], + "name": "SendERC20", + "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" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "AllowanceOverflow", + "type": "error" + }, + { + "inputs": [], + "name": "AllowanceUnderflow", + "type": "error" + }, + { + "inputs": [], + "name": "CallerNotL2ToL2CrossDomainMessenger", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientAllowance", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientBalance", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidCrossDomainSender", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPermit", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyBridge", + "type": "error" + }, + { + "inputs": [], + "name": "PermitExpired", + "type": "error" + }, + { + "inputs": [], + "name": "TotalSupplyOverflow", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "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 index 42d3a73e7c56..600e0e6b64f7 100644 --- a/packages/contracts-bedrock/snapshots/abi/SuperchainWETH.json +++ b/packages/contracts-bedrock/snapshots/abi/SuperchainWETH.json @@ -109,6 +109,11 @@ }, { "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, { "internalType": "address", "name": "dst", @@ -303,13 +308,25 @@ { "indexed": true, "internalType": "address", - "name": "_to", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", "type": "address" }, { "indexed": false, "internalType": "uint256", - "name": "_amount", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "source", "type": "uint256" } ], @@ -322,25 +339,25 @@ { "indexed": true, "internalType": "address", - "name": "_from", + "name": "from", "type": "address" }, { "indexed": true, "internalType": "address", - "name": "_to", + "name": "to", "type": "address" }, { "indexed": false, "internalType": "uint256", - "name": "_amount", + "name": "amount", "type": "uint256" }, { "indexed": false, "internalType": "uint256", - "name": "_chainId", + "name": "destination", "type": "uint256" } ], diff --git a/packages/contracts-bedrock/snapshots/storageLayout/OptimismSuperchainERC20.json b/packages/contracts-bedrock/snapshots/storageLayout/OptimismSuperchainERC20.json new file mode 100644 index 000000000000..0637a088a01e --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/OptimismSuperchainERC20.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/L2/IOptimismSuperchainERC20.sol b/packages/contracts-bedrock/src/L2/IOptimismSuperchainERC20.sol new file mode 100644 index 000000000000..9b9594e75d78 --- /dev/null +++ b/packages/contracts-bedrock/src/L2/IOptimismSuperchainERC20.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ISuperchainERC20Extensions } from "./ISuperchainERC20.sol"; + +/// @title IOptimismSuperchainERC20Extension +/// @notice This interface is available on the OptimismSuperchainERC20 contract. +/// We declare it as a separate interface so that it can be used in +/// custom implementations of SuperchainERC20. +interface IOptimismSuperchainERC20Extension is ISuperchainERC20Extensions { + /// @notice Emitted whenever tokens are minted for an account. + /// @param account Address of the account tokens are being minted for. + /// @param amount Amount of tokens minted. + event Mint(address indexed account, uint256 amount); + + /// @notice Emitted whenever tokens are burned from an account. + /// @param account Address of the account tokens are being burned from. + /// @param amount Amount of tokens burned. + event Burn(address indexed account, uint256 amount); + + /// @notice Allows the L2StandardBridge to mint tokens. + /// @param _to Address to mint tokens to. + /// @param _amount Amount of tokens to mint. + function mint(address _to, uint256 _amount) external; + + /// @notice Allows the L2StandardBridge to burn tokens. + /// @param _from Address to burn tokens from. + /// @param _amount Amount of tokens to burn. + function burn(address _from, uint256 _amount) external; + + /// @notice Returns the address of the corresponding version of this token on the remote chain. + function remoteToken() external view returns (address); +} + +/// @title IOptimismSuperchainERC20 +/// @notice Combines the ERC20 interface with the OptimismSuperchainERC20Extension interface. +interface IOptimismSuperchainERC20 is IERC20, IOptimismSuperchainERC20Extension { } diff --git a/packages/contracts-bedrock/src/L2/ISuperchainERC20.sol b/packages/contracts-bedrock/src/L2/ISuperchainERC20.sol index b104a08d928e..76488cdf32ea 100644 --- a/packages/contracts-bedrock/src/L2/ISuperchainERC20.sol +++ b/packages/contracts-bedrock/src/L2/ISuperchainERC20.sol @@ -9,27 +9,30 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /// 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); + /// @param from Address of the sender. + /// @param to Address of the recipient. + /// @param amount Number of tokens sent. + /// @param destination Chain ID of the destination chain. + event SendERC20(address indexed from, address indexed to, uint256 amount, uint256 destination); - /// @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 Emitted whenever tokens are successfully relayed on this chain. + /// @param from Address of the msg.sender of sendERC20 on the source chain. + /// @param to Address of the recipient. + /// @param amount Amount of tokens relayed. + /// @param source Chain ID of the source chain. + event RelayERC20(address indexed from, address indexed to, uint256 amount, uint256 source); - /// @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. + /// @notice Sends tokens to some target address on another chain. + /// @param _to Address to send tokens to. + /// @param _amount Amount of tokens to send. + /// @param _chainId Chain ID of the destination chain. 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; + /// @notice Relays tokens received from another chain. + /// @param _from Address of the msg.sender of sendERC20 on the source chain. + /// @param _to Address to relay tokens to. + /// @param _amount Amount of tokens to relay. + function relayERC20(address _from, address _to, uint256 _amount) external; } /// @title ISuperchainERC20 diff --git a/packages/contracts-bedrock/src/L2/OptimismSuperchainERC20.sol b/packages/contracts-bedrock/src/L2/OptimismSuperchainERC20.sol new file mode 100644 index 000000000000..9b0ba5cad8b0 --- /dev/null +++ b/packages/contracts-bedrock/src/L2/OptimismSuperchainERC20.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { IOptimismSuperchainERC20Extension } from "src/L2/IOptimismSuperchainERC20.sol"; +import { ERC20 } from "@solady/tokens/ERC20.sol"; +import { IL2ToL2CrossDomainMessenger } from "src/L2/IL2ToL2CrossDomainMessenger.sol"; +import { ISemver } from "src/universal/ISemver.sol"; +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { Initializable } from "@openzeppelin/contracts-v5/proxy/utils/Initializable.sol"; +import { ERC165 } from "@openzeppelin/contracts-v5/utils/introspection/ERC165.sol"; + +/// @notice Thrown when attempting to relay a message and the function caller (msg.sender) is not +/// L2ToL2CrossDomainMessenger. +error CallerNotL2ToL2CrossDomainMessenger(); + +/// @notice Thrown when attempting to relay a message and the cross domain message sender is not this +/// OptimismSuperchainERC20. +error InvalidCrossDomainSender(); + +/// @notice Thrown when attempting to mint or burn tokens and the function caller is not the StandardBridge. +error OnlyBridge(); + +/// @notice Thrown when attempting to mint or burn tokens and the account is the zero address. +error ZeroAddress(); + +/// @custom:proxied +/// @title OptimismSuperchainERC20 +/// @notice OptimismSuperchainERC20 is a standard extension of the base ERC20 token contract that unifies ERC20 token +/// bridging to make it fungible across the Superchain. This construction allows the L2StandardBridge to burn +/// and mint tokens. This makes it possible to convert a valid OptimismMintableERC20 token to a SuperchainERC20 +/// token, turning it fungible and interoperable across the superchain. Likewise, it also enables the inverse +/// conversion path. +/// Moreover, it builds on top of the L2ToL2CrossDomainMessenger for both replay protection and domain binding. +contract OptimismSuperchainERC20 is IOptimismSuperchainERC20Extension, ERC20, ISemver, Initializable, ERC165 { + /// @notice Address of the L2ToL2CrossDomainMessenger Predeploy. + address internal constant MESSENGER = Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER; + + /// @notice Address of the StandardBridge Predeploy. + address internal constant BRIDGE = Predeploys.L2_STANDARD_BRIDGE; + + /// @notice Storage slot that the OptimismSuperchainERC20Metadata struct is stored at. + /// keccak256(abi.encode(uint256(keccak256("optimismSuperchainERC20.metadata")) - 1)) & ~bytes32(uint256(0xff)); + bytes32 internal constant OPTIMISM_SUPERCHAIN_ERC20_METADATA_SLOT = + 0x07f04e84143df95a6373fcf376312ae41da81a193a3089073a54f47a74d8fb00; + + /// @notice Storage struct for the OptimismSuperchainERC20 metadata. + /// @custom:storage-location erc7201:optimismSuperchainERC20.metadata + struct OptimismSuperchainERC20Metadata { + /// @notice Address of the corresponding version of this token on the remote chain. + address remoteToken; + /// @notice Name of the token + string name; + /// @notice Symbol of the token + string symbol; + /// @notice Decimals of the token + uint8 decimals; + } + + /// @notice Returns the storage for the OptimismSuperchainERC20Metadata. + function _getMetadataStorage() private pure returns (OptimismSuperchainERC20Metadata storage _storage) { + assembly { + _storage.slot := OPTIMISM_SUPERCHAIN_ERC20_METADATA_SLOT + } + } + + /// @notice A modifier that only allows the bridge to call + modifier onlyBridge() { + if (msg.sender != BRIDGE) revert OnlyBridge(); + _; + } + + /// @notice Semantic version. + /// @custom:semver 1.0.0-beta.1 + string public constant version = "1.0.0-beta.1"; + + /// @notice Constructs the OptimismSuperchainERC20 contract. + constructor() { + _disableInitializers(); + } + + /// @notice Initializes the contract. + /// @param _remoteToken Address of the corresponding remote token. + /// @param _name ERC20 name. + /// @param _symbol ERC20 symbol. + /// @param _decimals ERC20 decimals. + function initialize( + address _remoteToken, + string memory _name, + string memory _symbol, + uint8 _decimals + ) + external + initializer + { + OptimismSuperchainERC20Metadata storage _storage = _getMetadataStorage(); + _storage.remoteToken = _remoteToken; + _storage.name = _name; + _storage.symbol = _symbol; + _storage.decimals = _decimals; + } + + /// @notice Allows the L2StandardBridge to mint tokens. + /// @param _to Address to mint tokens to. + /// @param _amount Amount of tokens to mint. + function mint(address _to, uint256 _amount) external virtual onlyBridge { + if (_to == address(0)) revert ZeroAddress(); + + _mint(_to, _amount); + + emit Mint(_to, _amount); + } + + /// @notice Allows the L2StandardBridge to burn tokens. + /// @param _from Address to burn tokens from. + /// @param _amount Amount of tokens to burn. + function burn(address _from, uint256 _amount) external virtual onlyBridge { + if (_from == address(0)) revert ZeroAddress(); + + _burn(_from, _amount); + + emit Burn(_from, _amount); + } + + /// @notice Sends tokens to some target address on another chain. + /// @param _to Address to send tokens to. + /// @param _amount Amount of tokens to send. + /// @param _chainId Chain ID of the destination chain. + function sendERC20(address _to, uint256 _amount, uint256 _chainId) external { + if (_to == address(0)) revert ZeroAddress(); + + _burn(msg.sender, _amount); + + bytes memory _message = abi.encodeCall(this.relayERC20, (msg.sender, _to, _amount)); + IL2ToL2CrossDomainMessenger(MESSENGER).sendMessage(_chainId, address(this), _message); + + emit SendERC20(msg.sender, _to, _amount, _chainId); + } + + /// @notice Relays tokens received from another chain. + /// @param _from Address of the msg.sender of sendERC20 on the source chain. + /// @param _to Address to relay tokens to. + /// @param _amount Amount of tokens to relay. + function relayERC20(address _from, address _to, uint256 _amount) external { + if (_to == address(0)) revert ZeroAddress(); + + if (msg.sender != MESSENGER) revert CallerNotL2ToL2CrossDomainMessenger(); + + if (IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageSender() != address(this)) { + revert InvalidCrossDomainSender(); + } + + uint256 source = IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageSource(); + + _mint(_to, _amount); + + emit RelayERC20(_from, _to, _amount, source); + } + + /// @notice Returns the address of the corresponding version of this token on the remote chain. + function remoteToken() public view override returns (address) { + return _getMetadataStorage().remoteToken; + } + + /// @notice Returns the name of the token. + function name() public view virtual override returns (string memory) { + return _getMetadataStorage().name; + } + + /// @notice Returns the symbol of the token. + function symbol() public view virtual override returns (string memory) { + return _getMetadataStorage().symbol; + } + + /// @notice Returns the number of decimals used to get its user representation. + /// For example, if `decimals` equals `2`, a balance of `505` tokens should + /// be displayed to a user as `5.05` (`505 / 10 ** 2`). + /// NOTE: This information is only used for _display_ purposes: it in + /// no way affects any of the arithmetic of the contract, including + /// {IERC20-balanceOf} and {IERC20-transfer}. + function decimals() public view override returns (uint8) { + return _getMetadataStorage().decimals; + } + + /// @notice ERC165 interface check function. + /// @param _interfaceId Interface ID to check. + /// @return Whether or not the interface is supported by this contract. + function supportsInterface(bytes4 _interfaceId) public view virtual override returns (bool) { + return + _interfaceId == type(IOptimismSuperchainERC20Extension).interfaceId || super.supportsInterface(_interfaceId); + } +} diff --git a/packages/contracts-bedrock/src/L2/SuperchainWETH.sol b/packages/contracts-bedrock/src/L2/SuperchainWETH.sol index 6e5a8c1fe4e1..7080460254fc 100644 --- a/packages/contracts-bedrock/src/L2/SuperchainWETH.sol +++ b/packages/contracts-bedrock/src/L2/SuperchainWETH.sol @@ -45,7 +45,7 @@ contract SuperchainWETH is WETH98, ISuperchainERC20Extensions, ISemver { IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER).sendMessage({ _destination: chainId, _target: address(this), - _message: abi.encodeCall(this.relayERC20, (dst, wad)) + _message: abi.encodeCall(this.relayERC20, (msg.sender, dst, wad)) }); // Emit event. @@ -53,7 +53,7 @@ contract SuperchainWETH is WETH98, ISuperchainERC20Extensions, ISemver { } /// @inheritdoc ISuperchainERC20Extensions - function relayERC20(address dst, uint256 wad) external { + function relayERC20(address from, 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(); @@ -64,11 +64,14 @@ contract SuperchainWETH is WETH98, ISuperchainERC20Extensions, ISemver { ETHLiquidity(Predeploys.ETH_LIQUIDITY).mint(wad); } + // Get source chain ID. + uint256 source = messenger.crossDomainMessageSource(); + // Mint to user's balance. _mint(dst, wad); // Emit event. - emit RelayERC20(dst, wad); + emit RelayERC20(from, dst, wad, source); } /// @notice Mints WETH to an address. diff --git a/packages/contracts-bedrock/test/L2/OptimismSuperchainERC20.t.sol b/packages/contracts-bedrock/test/L2/OptimismSuperchainERC20.t.sol new file mode 100644 index 000000000000..84580fdd8687 --- /dev/null +++ b/packages/contracts-bedrock/test/L2/OptimismSuperchainERC20.t.sol @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +// Testing utilities +import { Test } from "forge-std/Test.sol"; + +// Libraries +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { IERC20 } from "@openzeppelin/contracts-v5/token/ERC20/IERC20.sol"; +import { IL2ToL2CrossDomainMessenger } from "src/L2/IL2ToL2CrossDomainMessenger.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts-v5/proxy/ERC1967/ERC1967Proxy.sol"; +import { Initializable } from "@openzeppelin/contracts-v5/proxy/utils/Initializable.sol"; +import { IERC165 } from "@openzeppelin/contracts-v5/utils/introspection/IERC165.sol"; + +// Target contract +import { + OptimismSuperchainERC20, + IOptimismSuperchainERC20Extension, + CallerNotL2ToL2CrossDomainMessenger, + InvalidCrossDomainSender, + OnlyBridge, + ZeroAddress +} from "src/L2/OptimismSuperchainERC20.sol"; +import { ISuperchainERC20Extensions } from "src/L2/ISuperchainERC20.sol"; + +/// @title OptimismSuperchainERC20Test +/// @notice Contract for testing the OptimismSuperchainERC20 contract. +contract OptimismSuperchainERC20Test is Test { + address internal constant ZERO_ADDRESS = address(0); + address internal constant REMOTE_TOKEN = address(0x123); + string internal constant NAME = "OptimismSuperchainERC20"; + string internal constant SYMBOL = "SCE"; + uint8 internal constant DECIMALS = 18; + address internal constant BRIDGE = Predeploys.L2_STANDARD_BRIDGE; + address internal constant MESSENGER = Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER; + + OptimismSuperchainERC20 public superchainERC20Impl; + OptimismSuperchainERC20 public superchainERC20; + + /// @notice Sets up the test suite. + function setUp() public { + superchainERC20Impl = new OptimismSuperchainERC20(); + superchainERC20 = _deploySuperchainERC20Proxy(REMOTE_TOKEN, NAME, SYMBOL, DECIMALS); + } + + /// @notice Helper function to deploy a proxy of the OptimismSuperchainERC20 contract. + function _deploySuperchainERC20Proxy( + address _remoteToken, + string memory _name, + string memory _symbol, + uint8 _decimals + ) + internal + returns (OptimismSuperchainERC20) + { + return OptimismSuperchainERC20( + address( + // TODO: Use the SuperchainERC20 Beacon Proxy + new ERC1967Proxy( + address(superchainERC20Impl), + abi.encodeCall(OptimismSuperchainERC20.initialize, (_remoteToken, _name, _symbol, _decimals)) + ) + ) + ); + } + + /// @notice Helper function to setup a mock and expect a call to it. + function _mockAndExpect(address _receiver, bytes memory _calldata, bytes memory _returned) internal { + vm.mockCall(_receiver, _calldata, _returned); + vm.expectCall(_receiver, _calldata); + } + + /// @notice Test that the contract's `initializer` sets the correct values. + function test_initializer_succeeds() public view { + assertEq(superchainERC20.name(), NAME); + assertEq(superchainERC20.symbol(), SYMBOL); + assertEq(superchainERC20.decimals(), DECIMALS); + assertEq(superchainERC20.remoteToken(), REMOTE_TOKEN); + } + + /// @notice Tests the `initialize` function reverts when the contract is already initialized. + function testFuzz_initializer_reverts( + address _remoteToken, + string memory _name, + string memory _symbol, + uint8 _decimals + ) + public + { + // Expect the revert with `InvalidInitialization` selector + vm.expectRevert(Initializable.InvalidInitialization.selector); + + // Call the `initialize` function again + superchainERC20.initialize(_remoteToken, _name, _symbol, _decimals); + } + + /// @notice Tests the `mint` function reverts when the caller is not the bridge. + function testFuzz_mint_callerNotBridge_reverts(address _caller, address _to, uint256 _amount) public { + // Ensure the caller is not the bridge + vm.assume(_caller != BRIDGE); + + // Expect the revert with `OnlyBridge` selector + vm.expectRevert(OnlyBridge.selector); + + // Call the `mint` function with the non-bridge caller + vm.prank(_caller); + superchainERC20.mint(_to, _amount); + } + + /// @notice Tests the `mint` function reverts when the amount is zero. + function testFuzz_mint_zeroAddressTo_reverts(uint256 _amount) public { + // Expect the revert with `ZeroAddress` selector + vm.expectRevert(ZeroAddress.selector); + + // Call the `mint` function with the zero address + vm.prank(BRIDGE); + superchainERC20.mint({ _to: ZERO_ADDRESS, _amount: _amount }); + } + + /// @notice Tests the `mint` succeeds and emits the `Mint` event. + function testFuzz_mint_succeeds(address _to, uint256 _amount) public { + // Ensure `_to` is not the zero address + vm.assume(_to != ZERO_ADDRESS); + + // Get the total supply and balance of `_to` before the mint to compare later on the assertions + uint256 _totalSupplyBefore = superchainERC20.totalSupply(); + uint256 _toBalanceBefore = superchainERC20.balanceOf(_to); + + // Look for the emit of the `Transfer` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit IERC20.Transfer(ZERO_ADDRESS, _to, _amount); + + // Look for the emit of the `Mint` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit IOptimismSuperchainERC20Extension.Mint(_to, _amount); + + // Call the `mint` function with the bridge caller + vm.prank(BRIDGE); + superchainERC20.mint(_to, _amount); + + // Check the total supply and balance of `_to` after the mint were updated correctly + assertEq(superchainERC20.totalSupply(), _totalSupplyBefore + _amount); + assertEq(superchainERC20.balanceOf(_to), _toBalanceBefore + _amount); + } + + /// @notice Tests the `burn` function reverts when the caller is not the bridge. + function testFuzz_burn_callerNotBridge_reverts(address _caller, address _from, uint256 _amount) public { + // Ensure the caller is not the bridge + vm.assume(_caller != BRIDGE); + + // Expect the revert with `OnlyBridge` selector + vm.expectRevert(OnlyBridge.selector); + + // Call the `burn` function with the non-bridge caller + vm.prank(_caller); + superchainERC20.burn(_from, _amount); + } + + /// @notice Tests the `burn` function reverts when the amount is zero. + function testFuzz_burn_zeroAddressFrom_reverts(uint256 _amount) public { + // Expect the revert with `ZeroAddress` selector + vm.expectRevert(ZeroAddress.selector); + + // Call the `burn` function with the zero address + vm.prank(BRIDGE); + superchainERC20.burn({ _from: ZERO_ADDRESS, _amount: _amount }); + } + + /// @notice Tests the `burn` burns the amount and emits the `Burn` event. + function testFuzz_burn_succeeds(address _from, uint256 _amount) public { + // Ensure `_from` is not the zero address + vm.assume(_from != ZERO_ADDRESS); + + // Mint some tokens to `_from` so then they can be burned + vm.prank(BRIDGE); + superchainERC20.mint(_from, _amount); + + // Get the total supply and balance of `_from` before the burn to compare later on the assertions + uint256 _totalSupplyBefore = superchainERC20.totalSupply(); + uint256 _fromBalanceBefore = superchainERC20.balanceOf(_from); + + // Look for the emit of the `Transfer` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit IERC20.Transfer(_from, ZERO_ADDRESS, _amount); + + // Look for the emit of the `Burn` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit IOptimismSuperchainERC20Extension.Burn(_from, _amount); + + // Call the `burn` function with the bridge caller + vm.prank(BRIDGE); + superchainERC20.burn(_from, _amount); + + // Check the total supply and balance of `_from` after the burn were updated correctly + assertEq(superchainERC20.totalSupply(), _totalSupplyBefore - _amount); + assertEq(superchainERC20.balanceOf(_from), _fromBalanceBefore - _amount); + } + + /// @notice Tests the `sendERC20` function reverts when the `_to` address is the zero address. + function testFuzz_sendERC20_zeroAddressTo_reverts(uint256 _amount, uint256 _chainId) public { + // Expect the revert with `ZeroAddress` selector + vm.expectRevert(ZeroAddress.selector); + + // Call the `sendERC20` function with the zero address + vm.prank(BRIDGE); + superchainERC20.sendERC20({ _to: ZERO_ADDRESS, _amount: _amount, _chainId: _chainId }); + } + + /// @notice Tests the `sendERC20` function burns the sender tokens, sends the message, and emits the `SendERC20` + /// event. + function testFuzz_sendERC20_succeeds(address _sender, address _to, uint256 _amount, uint256 _chainId) external { + // Ensure `_sender` is not the zero address + vm.assume(_sender != ZERO_ADDRESS); + vm.assume(_to != ZERO_ADDRESS); + + // Mint some tokens to the sender so then they can be sent + vm.prank(BRIDGE); + superchainERC20.mint(_sender, _amount); + + // Get the total supply and balance of `_sender` before the send to compare later on the assertions + uint256 _totalSupplyBefore = superchainERC20.totalSupply(); + uint256 _senderBalanceBefore = superchainERC20.balanceOf(_sender); + + // Look for the emit of the `Transfer` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit IERC20.Transfer(_sender, ZERO_ADDRESS, _amount); + + // Look for the emit of the `SendERC20` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit ISuperchainERC20Extensions.SendERC20(_sender, _to, _amount, _chainId); + + // Mock the call over the `sendMessage` function and expect it to be called properly + bytes memory _message = abi.encodeCall(superchainERC20.relayERC20, (_sender, _to, _amount)); + _mockAndExpect( + MESSENGER, + abi.encodeWithSelector( + IL2ToL2CrossDomainMessenger.sendMessage.selector, _chainId, address(superchainERC20), _message + ), + abi.encode("") + ); + + // Call the `sendERC20` function + vm.prank(_sender); + superchainERC20.sendERC20(_to, _amount, _chainId); + + // Check the total supply and balance of `_sender` after the send were updated correctly + assertEq(superchainERC20.totalSupply(), _totalSupplyBefore - _amount); + assertEq(superchainERC20.balanceOf(_sender), _senderBalanceBefore - _amount); + } + + /// @notice Tests the `relayERC20` function reverts when the caller is not the L2ToL2CrossDomainMessenger. + function testFuzz_relayERC20_notMessenger_reverts(address _caller, address _to, uint256 _amount) public { + // Ensure the caller is not the messenger + vm.assume(_caller != MESSENGER); + vm.assume(_to != ZERO_ADDRESS); + + // Expect the revert with `CallerNotL2ToL2CrossDomainMessenger` selector + vm.expectRevert(CallerNotL2ToL2CrossDomainMessenger.selector); + + // Call the `relayERC20` function with the non-messenger caller + vm.prank(_caller); + superchainERC20.relayERC20(_caller, _to, _amount); + } + + /// @notice Tests the `relayERC20` function reverts when the `crossDomainMessageSender` that sent the message is not + /// the same SuperchainERC20 address. + function testFuzz_relayERC20_notCrossDomainSender_reverts( + address _crossDomainMessageSender, + address _to, + uint256 _amount + ) + public + { + vm.assume(_to != ZERO_ADDRESS); + vm.assume(_crossDomainMessageSender != address(superchainERC20)); + + // Mock the call over the `crossDomainMessageSender` function setting a wrong sender + vm.mockCall( + MESSENGER, + abi.encodeWithSelector(IL2ToL2CrossDomainMessenger.crossDomainMessageSender.selector), + abi.encode(_crossDomainMessageSender) + ); + + // Expect the revert with `InvalidCrossDomainSender` selector + vm.expectRevert(InvalidCrossDomainSender.selector); + + // Call the `relayERC20` function with the sender caller + vm.prank(MESSENGER); + superchainERC20.relayERC20(_crossDomainMessageSender, _to, _amount); + } + + /// @notice Tests the `relayERC20` function reverts when the `_to` address is the zero address. + function testFuzz_relayERC20_zeroAddressTo_reverts(uint256 _amount) public { + // Expect the revert with `ZeroAddress` selector + vm.expectRevert(ZeroAddress.selector); + + // Mock the call over the `crossDomainMessageSender` function setting the same address as value + vm.mockCall( + MESSENGER, + abi.encodeWithSelector(IL2ToL2CrossDomainMessenger.crossDomainMessageSender.selector), + abi.encode(address(superchainERC20)) + ); + + // Call the `relayERC20` function with the zero address + vm.prank(MESSENGER); + superchainERC20.relayERC20({ _from: ZERO_ADDRESS, _to: ZERO_ADDRESS, _amount: _amount }); + } + + /// @notice Tests the `relayERC20` mints the proper amount and emits the `RelayERC20` event. + function testFuzz_relayERC20_succeeds(address _from, address _to, uint256 _amount, uint256 _source) public { + vm.assume(_from != ZERO_ADDRESS); + vm.assume(_to != ZERO_ADDRESS); + + // Mock the call over the `crossDomainMessageSender` function setting the same address as value + _mockAndExpect( + MESSENGER, + abi.encodeWithSelector(IL2ToL2CrossDomainMessenger.crossDomainMessageSender.selector), + abi.encode(address(superchainERC20)) + ); + + // Mock the call over the `crossDomainMessageSource` function setting the source chain ID as value + _mockAndExpect( + MESSENGER, + abi.encodeWithSelector(IL2ToL2CrossDomainMessenger.crossDomainMessageSource.selector), + abi.encode(_source) + ); + + // Get the total supply and balance of `_to` before the relay to compare later on the assertions + uint256 _totalSupplyBefore = superchainERC20.totalSupply(); + uint256 _toBalanceBefore = superchainERC20.balanceOf(_to); + + // Look for the emit of the `Transfer` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit IERC20.Transfer(ZERO_ADDRESS, _to, _amount); + + // Look for the emit of the `RelayERC20` event + vm.expectEmit(true, true, true, true, address(superchainERC20)); + emit ISuperchainERC20Extensions.RelayERC20(_from, _to, _amount, _source); + + // Call the `relayERC20` function with the messenger caller + vm.prank(MESSENGER); + superchainERC20.relayERC20(_from, _to, _amount); + + // Check the total supply and balance of `_to` after the relay were updated correctly + assertEq(superchainERC20.totalSupply(), _totalSupplyBefore + _amount); + assertEq(superchainERC20.balanceOf(_to), _toBalanceBefore + _amount); + } + + /// @notice Tests the `decimals` function always returns the correct value. + function testFuzz_decimals_succeeds(uint8 _decimals) public { + OptimismSuperchainERC20 _newSuperchainERC20 = _deploySuperchainERC20Proxy(REMOTE_TOKEN, NAME, SYMBOL, _decimals); + assertEq(_newSuperchainERC20.decimals(), _decimals); + } + + /// @notice Tests the `REMOTE_TOKEN` function always returns the correct value. + function testFuzz_remoteToken_succeeds(address _remoteToken) public { + OptimismSuperchainERC20 _newSuperchainERC20 = _deploySuperchainERC20Proxy(_remoteToken, NAME, SYMBOL, DECIMALS); + assertEq(_newSuperchainERC20.remoteToken(), _remoteToken); + } + + /// @notice Tests the `name` function always returns the correct value. + function testFuzz_name_succeeds(string memory _name) public { + OptimismSuperchainERC20 _newSuperchainERC20 = _deploySuperchainERC20Proxy(REMOTE_TOKEN, _name, SYMBOL, DECIMALS); + assertEq(_newSuperchainERC20.name(), _name); + } + + /// @notice Tests the `symbol` function always returns the correct value. + function testFuzz_symbol_succeeds(string memory _symbol) public { + OptimismSuperchainERC20 _newSuperchainERC20 = _deploySuperchainERC20Proxy(REMOTE_TOKEN, NAME, _symbol, DECIMALS); + assertEq(_newSuperchainERC20.symbol(), _symbol); + } + + /// @notice Tests that the `supportsInterface` function returns true for the `IOptimismSuperchainERC20` interface. + function test_supportInterface_succeeds() public view { + assertTrue(superchainERC20.supportsInterface(type(IERC165).interfaceId)); + assertTrue(superchainERC20.supportsInterface(type(IOptimismSuperchainERC20Extension).interfaceId)); + } + + /// @notice Tests that the `supportsInterface` function returns false for any other interface than the + /// `IOptimismSuperchainERC20` one. + function testFuzz_supportInterface_returnFalse(bytes4 _interfaceId) public view { + vm.assume(_interfaceId != type(IERC165).interfaceId); + vm.assume(_interfaceId != type(IOptimismSuperchainERC20Extension).interfaceId); + assertFalse(superchainERC20.supportsInterface(_interfaceId)); + } +} diff --git a/packages/contracts-bedrock/test/L2/SuperchainWETH.t.sol b/packages/contracts-bedrock/test/L2/SuperchainWETH.t.sol index 889ff8e8e40f..3bee1fc74b7d 100644 --- a/packages/contracts-bedrock/test/L2/SuperchainWETH.t.sol +++ b/packages/contracts-bedrock/test/L2/SuperchainWETH.t.sol @@ -26,7 +26,7 @@ contract SuperchainWETH_Test is CommonTest { 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); + event RelayERC20(address indexed _from, address indexed _to, uint256 _amount, uint256 _source); /// @notice Test setup. function setUp() public virtual override { @@ -151,7 +151,11 @@ contract SuperchainWETH_Test is CommonTest { Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, abi.encodeCall( IL2ToL2CrossDomainMessenger.sendMessage, - (_chainId, address(superchainWeth), abi.encodeCall(superchainWeth.relayERC20, (_recipient, _amount))) + ( + _chainId, + address(superchainWeth), + abi.encodeCall(superchainWeth.relayERC20, (_caller, _recipient, _amount)) + ) ), 1 ); @@ -189,7 +193,7 @@ contract SuperchainWETH_Test is CommonTest { Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, abi.encodeCall( IL2ToL2CrossDomainMessenger.sendMessage, - (_chainId, address(superchainWeth), abi.encodeCall(superchainWeth.relayERC20, (bob, _amount))) + (_chainId, address(superchainWeth), abi.encodeCall(superchainWeth.relayERC20, (alice, bob, _amount))) ), 1 ); @@ -227,7 +231,7 @@ contract SuperchainWETH_Test is CommonTest { /// L2ToL2CrossDomainMessenger as long as the crossDomainMessageSender is the /// SuperchainWETH contract. /// @param _amount The amount of WETH to send. - function testFuzz_relayERC20_fromMessenger_succeeds(uint256 _amount) public { + function testFuzz_relayERC20_fromMessenger_succeeds(address _sender, uint256 _amount, uint256 _chainId) public { // Assume _amount = bound(_amount, 0, type(uint248).max - 1); @@ -237,13 +241,18 @@ contract SuperchainWETH_Test is CommonTest { abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSender, ()), abi.encode(address(superchainWeth)) ); + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSource, ()), + abi.encode(_chainId) + ); // Act vm.expectEmit(address(superchainWeth)); - emit RelayERC20(bob, _amount); + emit RelayERC20(_sender, bob, _amount, _chainId); vm.expectCall(Predeploys.ETH_LIQUIDITY, abi.encodeCall(ETHLiquidity.mint, (_amount)), 1); vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); - superchainWeth.relayERC20(bob, _amount); + superchainWeth.relayERC20(_sender, bob, _amount); // Assert assertEq(address(superchainWeth).balance, _amount); @@ -255,7 +264,13 @@ contract SuperchainWETH_Test is CommonTest { /// 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. /// @param _amount The amount of WETH to send. - function testFuzz_relayERC20_fromMessengerCustomGasTokenChain_succeeds(uint256 _amount) public { + function testFuzz_relayERC20_fromMessengerCustomGasTokenChain_succeeds( + address _sender, + uint256 _amount, + uint256 _chainId + ) + public + { // Assume _amount = bound(_amount, 0, type(uint248).max - 1); @@ -265,14 +280,19 @@ contract SuperchainWETH_Test is CommonTest { abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSender, ()), abi.encode(address(superchainWeth)) ); + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSource, ()), + abi.encode(_chainId) + ); vm.mockCall(address(l1Block), abi.encodeCall(l1Block.isCustomGasToken, ()), abi.encode(true)); // Act vm.expectEmit(address(superchainWeth)); - emit RelayERC20(bob, _amount); + emit RelayERC20(_sender, bob, _amount, _chainId); vm.expectCall(Predeploys.ETH_LIQUIDITY, abi.encodeCall(ETHLiquidity.mint, (_amount)), 0); vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); - superchainWeth.relayERC20(bob, _amount); + superchainWeth.relayERC20(_sender, bob, _amount); // Assert assertEq(address(superchainWeth).balance, 0); @@ -282,7 +302,7 @@ contract SuperchainWETH_Test is CommonTest { /// @notice Tests that the relayERC20 function reverts when not called from the /// L2ToL2CrossDomainMessenger. /// @param _amount The amount of WETH to send. - function testFuzz_relayERC20_notFromMessenger_fails(uint256 _amount) public { + function testFuzz_relayERC20_notFromMessenger_fails(address _sender, uint256 _amount) public { // Assume _amount = bound(_amount, 0, type(uint248).max - 1); @@ -292,7 +312,7 @@ contract SuperchainWETH_Test is CommonTest { // Act vm.expectRevert(Unauthorized.selector); vm.prank(alice); - superchainWeth.relayERC20(bob, _amount); + superchainWeth.relayERC20(_sender, bob, _amount); // Assert assertEq(address(superchainWeth).balance, 0); @@ -303,7 +323,7 @@ contract SuperchainWETH_Test is CommonTest { /// L2ToL2CrossDomainMessenger but the crossDomainMessageSender is not the /// SuperchainWETH contract. /// @param _amount The amount of WETH to send. - function testFuzz_relayERC20_fromMessengerNotFromSuperchainWETH_fails(uint256 _amount) public { + function testFuzz_relayERC20_fromMessengerNotFromSuperchainWETH_fails(address _sender, uint256 _amount) public { // Assume _amount = bound(_amount, 0, type(uint248).max - 1); @@ -317,7 +337,7 @@ contract SuperchainWETH_Test is CommonTest { // Act vm.expectRevert(Unauthorized.selector); vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); - superchainWeth.relayERC20(bob, _amount); + superchainWeth.relayERC20(_sender, bob, _amount); // Assert assertEq(address(superchainWeth).balance, 0); diff --git a/packages/contracts-bedrock/test/invariants/OptimismSuperchainERC20.t.sol b/packages/contracts-bedrock/test/invariants/OptimismSuperchainERC20.t.sol new file mode 100644 index 000000000000..028a0124e6ca --- /dev/null +++ b/packages/contracts-bedrock/test/invariants/OptimismSuperchainERC20.t.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +// Testing utilities +import { Test, StdUtils, Vm } from "forge-std/Test.sol"; +import { EIP1967Helper } from "test/mocks/EIP1967Helper.sol"; + +// Libraries +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { OptimismSuperchainERC20 } from "src/L2/OptimismSuperchainERC20.sol"; +import { IL2ToL2CrossDomainMessenger } from "src/L2/IL2ToL2CrossDomainMessenger.sol"; + +/// @title OptimismSuperchainERC20_User +/// @notice Actor contract that interacts with the OptimismSuperchainERC20 contract. +contract OptimismSuperchainERC20_User is StdUtils { + address public immutable receiver; + + /// @notice Cross domain message data. + struct MessageData { + bytes32 id; + uint256 amount; + } + + uint256 public totalAmountSent; + uint256 public totalAmountRelayed; + + /// @notice Flag to indicate if the test has failed. + bool public failed = false; + + /// @notice The Vm contract. + Vm internal vm; + + /// @notice The OptimismSuperchainERC20 contract. + OptimismSuperchainERC20 internal superchainERC20; + + /// @notice Mapping of sent messages. + mapping(bytes32 => bool) internal sent; + + /// @notice Array of unrelayed messages. + MessageData[] internal unrelayed; + + /// @param _vm The Vm contract. + /// @param _superchainERC20 The OptimismSuperchainERC20 contract. + /// @param _balance The initial balance of the contract. + constructor(Vm _vm, OptimismSuperchainERC20 _superchainERC20, uint256 _balance, address _receiver) { + vm = _vm; + superchainERC20 = _superchainERC20; + + // Mint balance to this actor. + vm.prank(Predeploys.L2_STANDARD_BRIDGE); + superchainERC20.mint(address(this), _balance); + receiver = _receiver; + } + + /// @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 ERC20 balance. + _amount = bound(_amount, 0, superchainERC20.balanceOf(address(this))); + + // Send the amount. + try superchainERC20.sendERC20(receiver, _amount, _chainId) { + // Success. + totalAmountSent += _amount; + } 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(uint256 _source) 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(superchainERC20)) + ); + + // Simulate the cross-domain message source to any chain. + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSource, ()), + abi.encode(_source) + ); + + // Prank the relayERC20 function. + // Balance will just go back to our own account. + vm.prank(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + try superchainERC20.relayERC20(address(this), receiver, message.amount) { + // Success. + totalAmountRelayed += message.amount; + } catch { + failed = true; + } + + // Remove the message from the unrelayed list. + unrelayed.pop(); + } +} + +/// @title OptimismSuperchainERC20_Invariant +/// @notice Invariant test that checks that sending OptimismSuperchainERC20 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 OptimismSuperchainERC20_Invariant is Test { + /// @notice Starting balance of the contract. + uint256 public constant STARTING_BALANCE = type(uint128).max; + + /// @notice The OptimismSuperchainERC20 contract implementation. + address internal optimismSuperchainERC20Impl; + + /// @notice The OptimismSuperchainERC20_User actor. + OptimismSuperchainERC20_User internal actor; + + /// @notice The OptimismSuperchainERC20 contract. + OptimismSuperchainERC20 internal optimismSuperchainERC20; + + /// @notice The address that will receive the tokens when relaying messages + address internal receiver = makeAddr("receiver"); + + /// @notice Test setup. + function setUp() public { + // Deploy the L2ToL2CrossDomainMessenger contract. + address _impl = _setImplementationCode(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER); + _setProxyCode(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, _impl); + + // Create a new OptimismSuperchainERC20 implementation. + optimismSuperchainERC20Impl = address(new OptimismSuperchainERC20()); + + // Deploy the OptimismSuperchainERC20 contract. + address _proxy = address(0x123456); + _setProxyCode(_proxy, optimismSuperchainERC20Impl); + optimismSuperchainERC20 = OptimismSuperchainERC20(_proxy); + + // Create a new OptimismSuperchainERC20_User actor. + actor = new OptimismSuperchainERC20_User(vm, optimismSuperchainERC20, STARTING_BALANCE, receiver); + + // Set the target contract. + targetContract(address(actor)); + + // Set the target selectors. + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = actor.sendERC20.selector; + selectors[1] = actor.relayMessage.selector; + FuzzSelector memory selector = FuzzSelector({ addr: address(actor), selectors: selectors }); + targetSelector(selector); + + // Setup assertions + assert(optimismSuperchainERC20.balanceOf(address(actor)) == STARTING_BALANCE); + assert(optimismSuperchainERC20.balanceOf(address(receiver)) == 0); + assert(optimismSuperchainERC20.totalSupply() == STARTING_BALANCE); + } + + /// @notice Sets the bytecode in the implementation address. + function _setImplementationCode(address _addr) internal returns (address) { + string memory cname = Predeploys.getName(_addr); + address impl = Predeploys.predeployToCodeNamespace(_addr); + vm.etch(impl, vm.getDeployedCode(string.concat(cname, ".sol:", cname))); + return impl; + } + + /// @notice Sets the bytecode in the proxy address. + function _setProxyCode(address _addr, address _impl) internal { + bytes memory code = vm.getDeployedCode("universal/Proxy.sol:Proxy"); + vm.etch(_addr, code); + EIP1967Helper.setAdmin(_addr, Predeploys.PROXY_ADMIN); + EIP1967Helper.setImplementation(_addr, _impl); + } + + /// @notice Invariant that checks that sending OptimismSuperchainERC20 always succeeds. + /// @custom:invariant Calls to sendERC20 should always succeed as long as the actor has enough balance. + /// Actor's balance should also not increase out of nowhere but instead should decrease by the + /// amount sent. + function invariant_sendERC20_succeeds() public view { + // Assert that the actor has not failed to send OptimismSuperchainERC20. + assertTrue(!actor.failed()); + + // Assert that the actor has sent more than or equal to the amount relayed. + assertTrue(actor.totalAmountSent() >= actor.totalAmountRelayed()); + + // Assert that the actor's balance has decreased by the amount sent. + assertEq(optimismSuperchainERC20.balanceOf(address(actor)), STARTING_BALANCE - actor.totalAmountSent()); + + // Assert that the total supply of the OptimismSuperchainERC20 contract has decreased by the amount unrelayed. + uint256 _unrelayedAmount = actor.totalAmountSent() - actor.totalAmountRelayed(); + assertEq(optimismSuperchainERC20.totalSupply(), STARTING_BALANCE - _unrelayedAmount); + } + + /// @notice Invariant that checks that relaying OptimismSuperchainERC20 always succeeds. + /// @custom:invariant Calls to relayERC20 should always succeeds when a message is received from another chain. + /// Actor's balance should only increase by the amount relayed. + function invariant_relayERC20_succeeds() public view { + // Assert that the actor has not failed to relay OptimismSuperchainERC20. + assertTrue(!actor.failed()); + + // Assert that the actor has sent more than or equal to the amount relayed. + assertTrue(actor.totalAmountSent() >= actor.totalAmountRelayed()); + + // Assert that the actor's balance has increased by the amount relayed. + assertEq(optimismSuperchainERC20.balanceOf(address(receiver)), actor.totalAmountRelayed()); + + // Assert that the total supply of the OptimismSuperchainERC20 contract has decreased by the amount unrelayed. + uint256 _unrelayedAmount = actor.totalAmountSent() - actor.totalAmountRelayed(); + assertEq(optimismSuperchainERC20.totalSupply(), STARTING_BALANCE - _unrelayedAmount); + } +} diff --git a/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol b/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol index 8d5475d15040..aeb250bcc36a 100644 --- a/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol +++ b/packages/contracts-bedrock/test/invariants/SuperchainWETH.t.sol @@ -103,7 +103,7 @@ contract SuperchainWETH_User is StdUtils { } /// @notice Relay a message from another chain. - function relayMessage() public { + function relayMessage(uint256 _source) public { // Make sure there are unrelayed messages. if (unrelayed.length == 0) { return; @@ -120,10 +120,17 @@ contract SuperchainWETH_User is StdUtils { abi.encode(address(weth)) ); + // Simulate the cross-domain message source to any chain. + vm.mockCall( + Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER, + abi.encodeCall(IL2ToL2CrossDomainMessenger.crossDomainMessageSource, ()), + abi.encode(_source) + ); + // 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) { + try weth.relayERC20(address(this), address(this), message.amount) { // Success. } catch { failed = true; diff --git a/packages/contracts-bedrock/test/vendor/Initializable.t.sol b/packages/contracts-bedrock/test/vendor/Initializable.t.sol index 05fff737bd6e..fa6af352ac99 100644 --- a/packages/contracts-bedrock/test/vendor/Initializable.t.sol +++ b/packages/contracts-bedrock/test/vendor/Initializable.t.sol @@ -352,7 +352,8 @@ contract Initializer_Test is Bridge_Initializer { // Ensure that all L1, L2 `Initializable` contracts are accounted for, in addition to // OptimismMintableERC20FactoryImpl, OptimismMintableERC20FactoryProxy, OptimismPortal2, // DisputeGameFactoryImpl, DisputeGameFactoryProxy, DelayedWETHImpl, DelayedWETHProxy. - assertEq(_getNumInitializable() + 1, contracts.length); + // Omitting OptimismSuperchainERC20 due to using OZ v5 Initializable. + assertEq(_getNumInitializable(), contracts.length); // Attempt to re-initialize all contracts within the `contracts` array. for (uint256 i; i < contracts.length; i++) { diff --git a/packages/contracts-bedrock/test/vendor/InitializableOZv5.t.sol b/packages/contracts-bedrock/test/vendor/InitializableOZv5.t.sol new file mode 100644 index 000000000000..0820f987414a --- /dev/null +++ b/packages/contracts-bedrock/test/vendor/InitializableOZv5.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { Test } from "forge-std/Test.sol"; +import { OptimismSuperchainERC20 } from "src/L2/OptimismSuperchainERC20.sol"; +import { Initializable } from "@openzeppelin/contracts-v5/proxy/utils/Initializable.sol"; + +/// @title InitializerOZv5_Test +/// @dev Ensures that the `initialize()` function on contracts cannot be called more than +/// once. Tests the contracts inheriting from `Initializable` from OpenZeppelin Contracts v5. +contract InitializerOZv5_Test is Test { + /// @notice The storage slot of the `initialized` flag in the `Initializable` contract from OZ v5. + /// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.Initializable")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant INITIALIZABLE_STORAGE = 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; + + /// @notice Contains the address of an `Initializable` contract and the calldata + /// used to initialize it. + struct InitializeableContract { + address target; + bytes initCalldata; + } + + /// @notice Contains the addresses of the contracts to test as well as the calldata + /// used to initialize them. + InitializeableContract[] contracts; + + function setUp() public { + // Initialize the `contracts` array with the addresses of the contracts to test and the + // calldata used to initialize them + + // OptimismSuperchainERC20 + contracts.push( + InitializeableContract({ + target: address(new OptimismSuperchainERC20()), + initCalldata: abi.encodeCall(OptimismSuperchainERC20.initialize, (address(0), "", "", 18)) + }) + ); + } + + /// @notice Tests that: + /// 1. The `initialized` flag of each contract is properly set to `type(uint64).max`, + /// signifying that the contracts are initialized. + /// 2. The `initialize()` function of each contract cannot be called more than once. + /// 3. Returns the correct error when attempting to re-initialize a contract. + function test_cannotReinitialize_succeeds() public { + // Attempt to re-initialize all contracts within the `contracts` array. + for (uint256 i; i < contracts.length; i++) { + InitializeableContract memory _contract = contracts[i]; + uint256 size; + address target = _contract.target; + assembly { + size := extcodesize(target) + } + + // Assert that the contract is already initialized. + bytes32 slotVal = vm.load(_contract.target, INITIALIZABLE_STORAGE); + uint64 initialized = uint64(uint256(slotVal)); + assertEq(initialized, type(uint64).max); + + // Then, attempt to re-initialize the contract. This should fail. + (bool success, bytes memory returnData) = _contract.target.call(_contract.initCalldata); + assertFalse(success); + assertEq(bytes4(returnData), Initializable.InvalidInitialization.selector); + } + } +}