diff --git a/interfaces/L1/IOptimismPortal2.sol b/interfaces/L1/IOptimismPortal2.sol index 9a5d8462a..136f4131b 100644 --- a/interfaces/L1/IOptimismPortal2.sol +++ b/interfaces/L1/IOptimismPortal2.sol @@ -9,6 +9,7 @@ import { ISystemConfig } from "interfaces/L1/ISystemConfig.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { IAnchorStateRegistry } from "interfaces/L1/proofs/IAnchorStateRegistry.sol"; import { IProxyAdminOwnedBase } from "interfaces/L1/IProxyAdminOwnedBase.sol"; +import { ITEEProverRegistry } from "interfaces/L1/proofs/tee/ITEEProverRegistry.sol"; interface IOptimismPortal2 is IProxyAdminOwnedBase { error ContentLengthMismatch(); @@ -33,6 +34,11 @@ interface IOptimismPortal2 is IProxyAdminOwnedBase { error OptimismPortal_ProofNotOldEnough(); error OptimismPortal_Unproven(); error OptimismPortal_ImmediateFinalityNotEnabled(); + error OptimismPortal_AttestedWithdrawalAlreadyRedeemed(); + error OptimismPortal_InvalidAttestedWithdrawalSignature(); + error OptimismPortal_InvalidAttestedWithdrawalSigner(address signer); + error OptimismPortal_TEEProverRegistryAlreadySet(); + error OptimismPortal_AttestedWithdrawalCallFailed(); error OutOfGas(); error UnexpectedList(); error UnexpectedString(); @@ -42,10 +48,19 @@ interface IOptimismPortal2 is IProxyAdminOwnedBase { event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success); event WithdrawalProven(bytes32 indexed withdrawalHash, address indexed from, address indexed to); event WithdrawalProvenExtension1(bytes32 indexed withdrawalHash, address indexed proofSubmitter); + event AttestedWithdrawalRedeemed( + bytes32 indexed authHash, + address indexed recipient, + uint256 amount, + uint256 nonce, + address signer, + bytes data + ); receive() external payable; function anchorStateRegistry() external view returns (IAnchorStateRegistry); + function attestRedeemed(bytes32) external view returns (bool); function checkWithdrawal(bytes32 _withdrawalHash, address _proofSubmitter) external view; function depositTransaction( address _to, @@ -106,9 +121,18 @@ interface IOptimismPortal2 is IProxyAdminOwnedBase { external view returns (IDisputeGame disputeGameProxy, uint64 timestamp); + function redeemAttestedWithdrawal( + address _recipient, + uint256 _amount, + uint256 _nonce, + bytes calldata _data, + bytes calldata _sig + ) external; function respectedGameType() external view returns (GameType); function respectedGameTypeUpdatedAt() external view returns (uint64); + function setTEEProverRegistry(ITEEProverRegistry _teeProverRegistry) external; function systemConfig() external view returns (ISystemConfig); + function teeProverRegistry() external view returns (ITEEProverRegistry); function version() external pure returns (string memory); function __constructor__(uint256 _proofMaturityDelaySeconds) external; diff --git a/interfaces/L2/IL2ToL1MessagePasser.sol b/interfaces/L2/IL2ToL1MessagePasser.sol index 4629dbaba..21c2d14d7 100644 --- a/interfaces/L2/IL2ToL1MessagePasser.sol +++ b/interfaces/L2/IL2ToL1MessagePasser.sol @@ -12,10 +12,21 @@ interface IL2ToL1MessagePasser { bytes32 withdrawalHash ); event WithdrawerBalanceBurnt(uint256 indexed amount); + event AttestedWithdrawalInitiated( + bytes32 indexed authHash, + address indexed recipient, + address indexed token, + uint256 amount, + uint256 nonce, + bytes data + ); receive() external payable; function MESSAGE_VERSION() external view returns (uint16); + function attestNonce() external view returns (uint256); + function attestedWithdraw(address _recipient, bytes calldata _data) external payable; + function attestedWithdrawals(bytes32) external view returns (bool); function burn() external; function initiateWithdrawal(address _target, uint256 _gasLimit, bytes memory _data) external payable; function messageNonce() external view returns (uint256); diff --git a/scripts/deploy/SystemDeploy.s.sol b/scripts/deploy/SystemDeploy.s.sol index af7a7c5f9..a5e7058ae 100644 --- a/scripts/deploy/SystemDeploy.s.sol +++ b/scripts/deploy/SystemDeploy.s.sol @@ -654,6 +654,9 @@ contract SystemDeploy is Script { output_.zkVerifier = multiproof.zkVerifier; output_.nitroEnclaveVerifier = INitroEnclaveVerifier(_implementationsInput.nitroEnclaveVerifier); output_.sp1Verifier = _implementationsInput.sp1Verifier; + + vm.broadcast(msg.sender); + output_.optimismPortalProxy.setTEEProverRegistry(output_.teeProverRegistryProxy); } _transferOwnership(address(output_.disputeGameFactoryProxy), _input.roles.opChainProxyAdminOwner); @@ -783,7 +786,7 @@ contract SystemDeploy is Script { IDisputeGameFactory disputeGameFactory = IDisputeGameFactory(_systemConfigProxy.disputeGameFactory()); _upgradeTo(proxyAdmin, address(disputeGameFactory), _impls.disputeGameFactoryImpl); - _upgradeMultiproofContracts(_systemConfigProxy, disputeGameFactory, _impls); + _upgradeMultiproofContracts(_systemConfigProxy, optimismPortal, disputeGameFactory, _impls); ISystemConfig.Addresses memory opChainAddrs = _systemConfigProxy.getAddresses(); _upgradeTo(proxyAdmin, opChainAddrs.l1CrossDomainMessenger, _impls.l1CrossDomainMessengerImpl); @@ -802,6 +805,7 @@ contract SystemDeploy is Script { function _upgradeMultiproofContracts( ISystemConfig _systemConfigProxy, + IOptimismPortal _optimismPortal, IDisputeGameFactory _disputeGameFactory, Types.Implementations memory _impls ) @@ -810,10 +814,15 @@ contract SystemDeploy is Script { IDisputeGame currentGameImpl = _disputeGameFactory.gameImpls(GameTypes.AGGREGATE_VERIFIER); if (address(currentGameImpl) == address(0)) return; + AggregateVerifier currentAggregateVerifier = AggregateVerifier(address(currentGameImpl)); + TEEProverRegistry teeProverRegistry = + TEEVerifier(address(currentAggregateVerifier.TEE_VERIFIER())).TEE_PROVER_REGISTRY(); + if (address(_optimismPortal.teeProverRegistry()) == address(0)) { + vm.broadcast(msg.sender); + _optimismPortal.setTEEProverRegistry(ITEEProverRegistry(address(teeProverRegistry))); + } + if (_impls.teeProverRegistryImpl != address(0)) { - AggregateVerifier currentAggregateVerifier = AggregateVerifier(address(currentGameImpl)); - TEEProverRegistry teeProverRegistry = - TEEVerifier(address(currentAggregateVerifier.TEE_VERIFIER())).TEE_PROVER_REGISTRY(); _upgradeTo(_systemConfigProxy.proxyAdmin(), address(teeProverRegistry), _impls.teeProverRegistryImpl); } diff --git a/snapshots/abi/L2ToL1MessagePasser.json b/snapshots/abi/L2ToL1MessagePasser.json index 363828352..3215c89b8 100644 --- a/snapshots/abi/L2ToL1MessagePasser.json +++ b/snapshots/abi/L2ToL1MessagePasser.json @@ -16,6 +16,56 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "attestNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_recipient", + "type": "address" + }, + { + "internalType": "bytes", + "name": "_data", + "type": "bytes" + } + ], + "name": "attestedWithdraw", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "attestedWithdrawals", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "burn", @@ -91,6 +141,49 @@ "stateMutability": "pure", "type": "function" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "authHash", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "AttestedWithdrawalInitiated", + "type": "event" + }, { "anonymous": false, "inputs": [ diff --git a/snapshots/abi/OptimismPortal2.json b/snapshots/abi/OptimismPortal2.json index bab79a516..527453f28 100644 --- a/snapshots/abi/OptimismPortal2.json +++ b/snapshots/abi/OptimismPortal2.json @@ -27,6 +27,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "attestRedeemed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -655,6 +674,39 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_data", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "_sig", + "type": "bytes" + } + ], + "name": "redeemAttestedWithdrawal", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "respectedGameType", @@ -681,6 +733,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "contract ITEEProverRegistry", + "name": "_teeProverRegistry", + "type": "address" + } + ], + "name": "setTEEProverRegistry", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "superchainConfig", @@ -707,6 +772,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "teeProverRegistry", + "outputs": [ + { + "internalType": "contract ITEEProverRegistry", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "version", @@ -720,6 +798,49 @@ "stateMutability": "pure", "type": "function" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "authHash", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "AttestedWithdrawalRedeemed", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -852,6 +973,16 @@ "name": "OptimismPortal_AlreadyFinalized", "type": "error" }, + { + "inputs": [], + "name": "OptimismPortal_AttestedWithdrawalAlreadyRedeemed", + "type": "error" + }, + { + "inputs": [], + "name": "OptimismPortal_AttestedWithdrawalCallFailed", + "type": "error" + }, { "inputs": [], "name": "OptimismPortal_BadTarget", @@ -887,6 +1018,22 @@ "name": "OptimismPortal_ImproperDisputeGame", "type": "error" }, + { + "inputs": [], + "name": "OptimismPortal_InvalidAttestedWithdrawalSignature", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + } + ], + "name": "OptimismPortal_InvalidAttestedWithdrawalSigner", + "type": "error" + }, { "inputs": [], "name": "OptimismPortal_InvalidDisputeGame", @@ -927,6 +1074,11 @@ "name": "OptimismPortal_ProofNotOldEnough", "type": "error" }, + { + "inputs": [], + "name": "OptimismPortal_TEEProverRegistryAlreadySet", + "type": "error" + }, { "inputs": [], "name": "OptimismPortal_Unproven", diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index 4da0be7a0..fc3ae42ce 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -12,8 +12,8 @@ "sourceCodeHash": "0xd9f576a79e97bc541b3d7a2ee928f34223edaaecb074eeb9e6e2ecee857ce6a0" }, "src/L1/OptimismPortal2.sol:OptimismPortal2": { - "initCodeHash": "0x3223d48d63cc9e4a6796f2eb343c82c88c1add5289fa66ab72fa2fa4d83d0790", - "sourceCodeHash": "0x15cef97e2598ac2ed83fd8662c2f31e61a33e89d9f618b97fdeaa142cf6f9262" + "initCodeHash": "0x85abb3c5f8960f4ffa0f80d238510bfe00719cae397c121bd5775a002ee2a609", + "sourceCodeHash": "0x259c72decdc4a257d7ce12f0c71602d00e9ebdb82a2fd07a7ee515f95d30ebdb" }, "src/L1/ProtocolVersions.sol:ProtocolVersions": { "initCodeHash": "0xd762af325410baea14f927f4292ef9ee2bb13b52d72034d6d129eb1e518dfedd", @@ -96,8 +96,8 @@ "sourceCodeHash": "0xc53a2f1fc472b15aa2891d48845b078ef12fdae869a4b74bef19b19d9d20ed5f" }, "src/L2/L2ToL1MessagePasser.sol:L2ToL1MessagePasser": { - "initCodeHash": "0xe30675ea6623cd7390dd2cd1e9a523c92c66956dfab86d06e318eb410cd1989b", - "sourceCodeHash": "0xdc7bd63134eeab163a635950f2afd16b59f40f9cf1306f2ed33ad661cc7b4962" + "initCodeHash": "0x3f8897865d03a864c6abaadcada300dc073385a45e49687d7434d3ae538bdc46", + "sourceCodeHash": "0x7e5c271494427cb6cb81bcf2a01f06201da3d27fea6bb1af143654f21035743a" }, "src/L2/OperatorFeeVault.sol:OperatorFeeVault": { "initCodeHash": "0x2179f8438c980cbd14c354206c1253f5704b9100446cdf91fa537a15bfc094ff", diff --git a/snapshots/storageLayout/L2ToL1MessagePasser.json b/snapshots/storageLayout/L2ToL1MessagePasser.json index 09cc3b544..b9342839d 100644 --- a/snapshots/storageLayout/L2ToL1MessagePasser.json +++ b/snapshots/storageLayout/L2ToL1MessagePasser.json @@ -12,5 +12,19 @@ "offset": 0, "slot": "1", "type": "uint240" + }, + { + "bytes": "32", + "label": "attestedWithdrawals", + "offset": 0, + "slot": "2", + "type": "mapping(bytes32 => bool)" + }, + { + "bytes": "32", + "label": "attestNonce", + "offset": 0, + "slot": "3", + "type": "uint256" } ] \ No newline at end of file diff --git a/snapshots/storageLayout/OptimismPortal2.json b/snapshots/storageLayout/OptimismPortal2.json index 12b99bf49..ac2adea9f 100644 --- a/snapshots/storageLayout/OptimismPortal2.json +++ b/snapshots/storageLayout/OptimismPortal2.json @@ -145,5 +145,19 @@ "offset": 20, "slot": "63", "type": "bool" + }, + { + "bytes": "20", + "label": "teeProverRegistry", + "offset": 0, + "slot": "64", + "type": "contract ITEEProverRegistry" + }, + { + "bytes": "32", + "label": "attestRedeemed", + "offset": 0, + "slot": "65", + "type": "mapping(bytes32 => bool)" } ] \ No newline at end of file diff --git a/src/L1/OptimismPortal2.sol b/src/L1/OptimismPortal2.sol index 9d9df5909..cd23954a9 100644 --- a/src/L1/OptimismPortal2.sol +++ b/src/L1/OptimismPortal2.sol @@ -17,6 +17,7 @@ import { SecureMerkleTrie } from "src/libraries/trie/SecureMerkleTrie.sol"; import { AddressAliasHelper } from "src/vendor/AddressAliasHelper.sol"; import { GameStatus, GameType } from "src/libraries/bridge/Types.sol"; import { Features } from "src/libraries/Features.sol"; +import { ECDSA } from "lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; // Interfaces import { ISemver } from "interfaces/universal/ISemver.sol"; @@ -26,6 +27,7 @@ import { IDisputeGameFactory } from "interfaces/L1/proofs/IDisputeGameFactory.so import { IDisputeGame } from "interfaces/L1/proofs/IDisputeGame.sol"; import { IAnchorStateRegistry } from "interfaces/L1/proofs/IAnchorStateRegistry.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; +import { ITEEProverRegistry } from "interfaces/L1/proofs/tee/ITEEProverRegistry.sol"; /// @custom:proxied true /// @title OptimismPortal2 @@ -47,6 +49,9 @@ contract OptimismPortal2 is Initializable, ResourceMetering, ReinitializableBase /// @notice Version of the deposit event. uint256 internal constant DEPOSIT_VERSION = 0; + /// @notice Domain separator for attested withdrawal authorizations. + bytes32 internal constant ATTESTED_WITHDRAWAL_DOMAIN_TAG = keccak256("BASE_ATTESTED_WITHDRAWAL_V1"); + /// @notice The L2 gas limit set when eth is deposited using the receive() function. uint64 internal constant RECEIVE_DEFAULT_GAS_LIMIT = 100_000; @@ -128,6 +133,12 @@ contract OptimismPortal2 is Initializable, ResourceMetering, ReinitializableBase /// @custom:spacer superRootsActive bool private spacer_63_20_1; + /// @notice TEE prover registry authorized to attest withdrawals. + ITEEProverRegistry public teeProverRegistry; + + /// @notice Tracks attested withdrawals that have been redeemed. + mapping(bytes32 => bool) public attestRedeemed; + /// @notice Emitted when a transaction is deposited from L1 to L2. The parameters of this event /// are read by the rollup node and used to derive deposit transactions on L2. /// @param from Address that triggered the deposit transaction. @@ -154,6 +165,11 @@ contract OptimismPortal2 is Initializable, ResourceMetering, ReinitializableBase /// @param success Whether the withdrawal transaction was successful. event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success); + /// @notice Emitted when an attested withdrawal is redeemed. + event AttestedWithdrawalRedeemed( + bytes32 indexed authHash, address indexed recipient, uint256 amount, uint256 nonce, address signer, bytes data + ); + /// @notice Thrown when a withdrawal has already been finalized. error OptimismPortal_AlreadyFinalized(); @@ -206,10 +222,25 @@ contract OptimismPortal2 is Initializable, ResourceMetering, ReinitializableBase /// not been configured for immediate finality (PROOF_MATURITY_DELAY_SECONDS != 0). error OptimismPortal_ImmediateFinalityNotEnabled(); + /// @notice Thrown when an attested withdrawal has already been redeemed. + error OptimismPortal_AttestedWithdrawalAlreadyRedeemed(); + + /// @notice Thrown when an attested withdrawal signature is invalid. + error OptimismPortal_InvalidAttestedWithdrawalSignature(); + + /// @notice Thrown when an attested withdrawal signer is not registered. + error OptimismPortal_InvalidAttestedWithdrawalSigner(address signer); + + /// @notice Thrown when the TEE prover registry has already been configured. + error OptimismPortal_TEEProverRegistryAlreadySet(); + + /// @notice Thrown when an attested withdrawal payout fails. + error OptimismPortal_AttestedWithdrawalCallFailed(); + /// @notice Semantic version. - /// @custom:semver 5.2.0 + /// @custom:semver 5.3.0 function version() public pure virtual returns (string memory) { - return "5.2.0"; + return "5.3.0"; } /// @param _proofMaturityDelaySeconds The proof maturity delay in seconds. @@ -218,6 +249,42 @@ contract OptimismPortal2 is Initializable, ResourceMetering, ReinitializableBase _disableInitializers(); } + /// @notice Sets the registry authorized to attest withdrawals. + function setTEEProverRegistry(ITEEProverRegistry _teeProverRegistry) external { + _assertOnlyProxyAdminOwner(); + if (address(teeProverRegistry) != address(0)) revert OptimismPortal_TEEProverRegistryAlreadySet(); + teeProverRegistry = _teeProverRegistry; + } + + /// @notice Executes an ETH-plus-calldata call authorized by a registered enclave signer. + function redeemAttestedWithdrawal( + address _recipient, + uint256 _amount, + uint256 _nonce, + bytes calldata _data, + bytes calldata _sig + ) + external + { + _assertNotPaused(); + + bytes32 authHash = + keccak256(abi.encode(uint256(systemConfig.l2ChainId()), _recipient, address(0), _amount, _nonce, _data)); + if (attestRedeemed[authHash]) revert OptimismPortal_AttestedWithdrawalAlreadyRedeemed(); + + bytes32 journal = keccak256(abi.encodePacked(ATTESTED_WITHDRAWAL_DOMAIN_TAG, authHash)); + (address signer, ECDSA.RecoverError err) = ECDSA.tryRecover(journal, _sig); + if (err != ECDSA.RecoverError.NoError) revert OptimismPortal_InvalidAttestedWithdrawalSignature(); + if (!teeProverRegistry.isValidSigner(signer)) revert OptimismPortal_InvalidAttestedWithdrawalSigner(signer); + + attestRedeemed[authHash] = true; + if (!SafeCall.call(_recipient, gasleft(), _amount, _data)) { + revert OptimismPortal_AttestedWithdrawalCallFailed(); + } + + emit AttestedWithdrawalRedeemed(authHash, _recipient, _amount, _nonce, signer, _data); + } + /// @notice Initializer. /// @param _systemConfig Address of the SystemConfig. /// @param _anchorStateRegistry Address of the AnchorStateRegistry. diff --git a/src/L2/L2ToL1MessagePasser.sol b/src/L2/L2ToL1MessagePasser.sol index 682b5ac1b..ae4745b86 100644 --- a/src/L2/L2ToL1MessagePasser.sol +++ b/src/L2/L2ToL1MessagePasser.sol @@ -29,6 +29,12 @@ contract L2ToL1MessagePasser is ISemver { /// @notice A unique value hashed with each withdrawal. uint240 internal msgNonce; + /// @notice Records withdrawals authorized for TEE-attested L1 redemption. + mapping(bytes32 => bool) public attestedWithdrawals; + + /// @notice A unique value hashed with each attested withdrawal. + uint256 public attestNonce; + /// @notice Emitted any time a withdrawal is initiated. /// @param nonce Unique value corresponding to each withdrawal. /// @param sender The L2 account address which initiated the withdrawal. @@ -51,9 +57,19 @@ contract L2ToL1MessagePasser is ISemver { /// @param amount Amount of ETH that was burned. event WithdrawerBalanceBurnt(uint256 indexed amount); - /// @custom:semver 1.2.0 + /// @notice Emitted when an attested withdrawal is initiated. + event AttestedWithdrawalInitiated( + bytes32 indexed authHash, + address indexed recipient, + address indexed token, + uint256 amount, + uint256 nonce, + bytes data + ); + + /// @custom:semver 1.3.0 function version() public pure virtual returns (string memory) { - return "1.2.0"; + return "1.3.0"; } /// @notice Allows users to withdraw ETH by sending directly to this contract. @@ -96,6 +112,20 @@ contract L2ToL1MessagePasser is ISemver { } } + /// @notice Initiates an ETH withdrawal with an L1 call authorized for enclave attestation. + function attestedWithdraw(address _recipient, bytes calldata _data) external payable { + uint256 nonce = attestNonce; + bytes32 authHash = + keccak256(abi.encode(uint256(block.chainid), _recipient, address(0), msg.value, nonce, _data)); + + attestedWithdrawals[authHash] = true; + emit AttestedWithdrawalInitiated(authHash, _recipient, address(0), msg.value, nonce, _data); + + unchecked { + ++attestNonce; + } + } + /// @notice Retrieves the next message nonce. Message version will be added to the upper two /// bytes of the message nonce. Message version allows us to treat messages as having /// different structures. diff --git a/test/L1/OptimismPortal2.t.sol b/test/L1/OptimismPortal2.t.sol index b653d2746..3242fabd9 100644 --- a/test/L1/OptimismPortal2.t.sol +++ b/test/L1/OptimismPortal2.t.sol @@ -10,6 +10,7 @@ import { NextImpl } from "test/mocks/NextImpl.sol"; import { EIP1967Helper } from "test/mocks/EIP1967Helper.sol"; import { DisputeGameFactory_TestInit } from "test/L1/proofs/DisputeGameFactory.t.sol"; import { MockVerifier } from "test/mocks/MockVerifier.sol"; +import { CallRecorder, Reverter } from "test/mocks/Callers.sol"; // Scripts import { ForgeArtifacts, StorageSlot } from "scripts/libraries/ForgeArtifacts.sol"; @@ -21,6 +22,7 @@ import { Constants } from "src/libraries/Constants.sol"; import { AddressAliasHelper } from "src/vendor/AddressAliasHelper.sol"; import { Features } from "src/libraries/Features.sol"; import { AggregateVerifier } from "src/L1/proofs/AggregateVerifier.sol"; +import { ECDSA } from "lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; import "src/libraries/bridge/Types.sol"; import { Claim, Timestamp } from "src/libraries/bridge/LibUDT.sol"; @@ -33,6 +35,7 @@ import { IDisputeGame } from "interfaces/L1/proofs/IDisputeGame.sol"; import { IProxy } from "interfaces/universal/IProxy.sol"; import { IProxyAdminOwnedBase } from "interfaces/L1/IProxyAdminOwnedBase.sol"; import { IVerifier } from "interfaces/L1/proofs/IVerifier.sol"; +import { ITEEProverRegistry } from "interfaces/L1/proofs/tee/ITEEProverRegistry.sol"; abstract contract OptimismPortal2_TestInit is DisputeGameFactory_TestInit { address depositor; @@ -2104,3 +2107,157 @@ contract OptimismPortal2_Params_Test is CommonTest { assertEq(slot21Expected, slot21After); } } + +/// @title OptimismPortal2_RedeemAttestedWithdrawal_Test +/// @notice Tests for `redeemAttestedWithdrawal`. +contract OptimismPortal2_RedeemAttestedWithdrawal_Test is OptimismPortal2_TestInit { + uint256 internal constant SIGNER_PRIVATE_KEY = 0xA11CE; + bytes32 internal constant DOMAIN_TAG = keccak256("BASE_ATTESTED_WITHDRAWAL_V1"); + + event AttestedWithdrawalRedeemed( + bytes32 indexed authHash, address indexed recipient, uint256 amount, uint256 nonce, address signer, bytes data + ); + + function setUp() public override { + super.setUp(); + if (address(optimismPortal2.teeProverRegistry()) == address(0)) { + vm.prank(proxyAdminOwner); + optimismPortal2.setTEEProverRegistry(ITEEProverRegistry(address(teeProverRegistry))); + } + } + + function _authHash( + address _recipient, + uint256 _amount, + uint256 _nonce, + bytes memory _data + ) + internal + view + returns (bytes32) + { + return keccak256(abi.encode(deploy.cfg().l2ChainId(), _recipient, address(0), _amount, _nonce, _data)); + } + + function _sign( + address _recipient, + uint256 _amount, + uint256 _nonce, + bytes memory _data + ) + internal + view + returns (bytes32 authHash_, bytes memory signature_, address signer_) + { + authHash_ = _authHash(_recipient, _amount, _nonce, _data); + bytes32 journal = keccak256(abi.encodePacked(DOMAIN_TAG, authHash_)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PRIVATE_KEY, journal); + signature_ = abi.encodePacked(r, s, v); + signer_ = vm.addr(SIGNER_PRIVATE_KEY); + } + + function _mockValidSigner(address _signer) internal { + vm.mockCall( + address(teeProverRegistry), abi.encodeCall(teeProverRegistry.isValidSigner, (_signer)), abi.encode(true) + ); + } + + function test_redeemAttestedWithdrawal_succeeds() external { + CallRecorder recipient = new CallRecorder(); + uint256 amount = 1 gwei; + uint256 nonce = 7; + bytes memory data = abi.encodeCall(CallRecorder.record, ()); + (bytes32 authHash, bytes memory signature, address signer) = _sign(address(recipient), amount, nonce, data); + _mockValidSigner(signer); + + vm.expectEmit(true, true, false, true, address(optimismPortal2)); + emit AttestedWithdrawalRedeemed(authHash, address(recipient), amount, nonce, signer, data); + optimismPortal2.redeemAttestedWithdrawal(address(recipient), amount, nonce, data, signature); + + CallRecorder.CallInfo memory callInfo = recipient.getLastCall(); + assertEq(callInfo.sender, address(optimismPortal2)); + assertEq(callInfo.data, data); + assertGt(callInfo.gas, 0); + assertEq(callInfo.value, amount); + assertTrue(optimismPortal2.attestRedeemed(authHash)); + } + + function test_redeemAttestedWithdrawal_replay_reverts() external { + address recipient = makeAddr("attested recipient"); + uint256 amount = 1 gwei; + uint256 nonce = 7; + bytes memory data = hex"1234"; + (bytes32 authHash, bytes memory signature, address signer) = _sign(recipient, amount, nonce, data); + _mockValidSigner(signer); + + optimismPortal2.redeemAttestedWithdrawal(recipient, amount, nonce, data, signature); + + vm.expectRevert(IOptimismPortal.OptimismPortal_AttestedWithdrawalAlreadyRedeemed.selector); + optimismPortal2.redeemAttestedWithdrawal(recipient, amount, nonce, data, signature); + assertTrue(optimismPortal2.attestRedeemed(authHash)); + } + + function test_redeemAttestedWithdrawal_paused_reverts() external { + vm.prank(optimismPortal2.guardian()); + superchainConfig.pause(address(0)); + + vm.expectRevert(IOptimismPortal.OptimismPortal_CallPaused.selector); + optimismPortal2.redeemAttestedWithdrawal(makeAddr("attested recipient"), 1 gwei, 7, hex"", hex""); + } + + function test_redeemAttestedWithdrawal_invalidSigner_reverts() external { + address recipient = makeAddr("attested recipient"); + uint256 amount = 1 gwei; + uint256 nonce = 7; + bytes memory data = hex"1234"; + (, bytes memory signature, address signer) = _sign(recipient, amount, nonce, data); + vm.mockCall( + address(teeProverRegistry), abi.encodeCall(teeProverRegistry.isValidSigner, (signer)), abi.encode(false) + ); + + vm.expectRevert( + abi.encodeWithSelector(IOptimismPortal.OptimismPortal_InvalidAttestedWithdrawalSigner.selector, signer) + ); + optimismPortal2.redeemAttestedWithdrawal(recipient, amount, nonce, data, signature); + } + + function test_redeemAttestedWithdrawal_calldataTampered_reverts() external { + address recipient = makeAddr("attested recipient"); + uint256 amount = 1 gwei; + uint256 nonce = 7; + bytes memory signedData = hex"1234"; + bytes memory submittedData = hex"5678"; + (bytes32 signedAuthHash, bytes memory signature,) = _sign(recipient, amount, nonce, signedData); + bytes32 submittedAuthHash = _authHash(recipient, amount, nonce, submittedData); + address recoveredSigner = ECDSA.recover(keccak256(abi.encodePacked(DOMAIN_TAG, submittedAuthHash)), signature); + vm.mockCall( + address(teeProverRegistry), + abi.encodeCall(teeProverRegistry.isValidSigner, (recoveredSigner)), + abi.encode(false) + ); + + vm.expectRevert( + abi.encodeWithSelector( + IOptimismPortal.OptimismPortal_InvalidAttestedWithdrawalSigner.selector, recoveredSigner + ) + ); + optimismPortal2.redeemAttestedWithdrawal(recipient, amount, nonce, submittedData, signature); + + assertFalse(optimismPortal2.attestRedeemed(signedAuthHash)); + assertFalse(optimismPortal2.attestRedeemed(submittedAuthHash)); + } + + function test_redeemAttestedWithdrawal_targetReverts_reverts() external { + Reverter recipient = new Reverter(); + uint256 amount = 1 gwei; + uint256 nonce = 7; + bytes memory data = abi.encodeCall(Reverter.doRevert, ()); + (bytes32 authHash, bytes memory signature, address signer) = _sign(address(recipient), amount, nonce, data); + _mockValidSigner(signer); + + vm.expectRevert(IOptimismPortal.OptimismPortal_AttestedWithdrawalCallFailed.selector); + optimismPortal2.redeemAttestedWithdrawal(address(recipient), amount, nonce, data, signature); + + assertFalse(optimismPortal2.attestRedeemed(authHash)); + } +} diff --git a/test/L2/L2ToL1MessagePasser.t.sol b/test/L2/L2ToL1MessagePasser.t.sol index ab55fe710..9babd40c7 100644 --- a/test/L2/L2ToL1MessagePasser.t.sol +++ b/test/L2/L2ToL1MessagePasser.t.sol @@ -184,3 +184,45 @@ contract L2ToL1MessagePasser_MessageNonce_Test is L2ToL1MessagePasser_TestInit { assertEq(version, l2ToL1MessagePasser.MESSAGE_VERSION()); } } + +/// @title L2ToL1MessagePasser_AttestedWithdraw_Test +/// @notice Tests for `attestedWithdraw`. +contract L2ToL1MessagePasser_AttestedWithdraw_Test is L2ToL1MessagePasser_TestInit { + event AttestedWithdrawalInitiated( + bytes32 indexed authHash, + address indexed recipient, + address indexed token, + uint256 amount, + uint256 nonce, + bytes data + ); + + function test_attestedWithdraw_succeeds() external { + address recipient = makeAddr("recipient"); + uint256 amount = 1 ether; + uint256 nonce = l2ToL1MessagePasser.attestNonce(); + bytes memory data = hex"1234"; + bytes32 authHash = keccak256(abi.encode(block.chainid, recipient, address(0), amount, nonce, data)); + + vm.expectEmit(true, true, true, true, address(l2ToL1MessagePasser)); + emit AttestedWithdrawalInitiated(authHash, recipient, address(0), amount, nonce, data); + + vm.deal(address(this), amount); + l2ToL1MessagePasser.attestedWithdraw{ value: amount }(recipient, data); + + assertTrue(l2ToL1MessagePasser.attestedWithdrawals(authHash)); + assertEq(l2ToL1MessagePasser.attestNonce(), nonce + 1); + assertEq(address(l2ToL1MessagePasser).balance, amount); + } + + function test_attestedWithdraw_calldataChangesAuthorizationHash_succeeds() external { + address recipient = makeAddr("recipient"); + uint256 amount = 1 ether; + uint256 nonce = l2ToL1MessagePasser.attestNonce(); + + bytes32 firstHash = keccak256(abi.encode(block.chainid, recipient, address(0), amount, nonce, hex"1234")); + bytes32 secondHash = keccak256(abi.encode(block.chainid, recipient, address(0), amount, nonce, hex"5678")); + + assertNotEq(firstHash, secondHash); + } +} diff --git a/test/deploy/SystemDeploy.t.sol b/test/deploy/SystemDeploy.t.sol index 97c483146..64a3b10a8 100644 --- a/test/deploy/SystemDeploy.t.sol +++ b/test/deploy/SystemDeploy.t.sol @@ -193,6 +193,7 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { SystemDeploy.DeployInput memory input = _defaultDeployInput(); SystemDeploy.DeployOutput memory output = systemDeploy.deploy(input); Types.Implementations memory implementations = output.impls; + vm.store(address(output.opChain.optimismPortalProxy), bytes32(uint256(64)), bytes32(0)); ProtocolVersions protocolVersionsImpl = new ProtocolVersions(); implementations.protocolVersionsImpl = address(protocolVersionsImpl); @@ -208,6 +209,11 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { assertFalse(upgradeOutput.superchainConfigUpgraded, "superchain already current"); assertTrue(upgradeOutput.chainUpgraded, "chain upgraded"); + assertEq( + address(output.opChain.optimismPortalProxy.teeProverRegistry()), + address(output.opChain.teeProverRegistryProxy), + "portal tee registry" + ); assertEq( output.superchain.superchainProxyAdmin .getProxyImplementation(address(output.superchain.superchainConfigProxy)), diff --git a/test/mocks/Callers.sol b/test/mocks/Callers.sol index 0d1dbed68..839f08f6c 100644 --- a/test/mocks/Callers.sol +++ b/test/mocks/Callers.sol @@ -19,6 +19,10 @@ contract CallRecorder { lastCall.gas = gasleft(); lastCall.value = msg.value; } + + function getLastCall() external view returns (CallInfo memory) { + return lastCall; + } } /// @dev Any call will revert