diff --git a/contracts/script/crosschain/Deploy.s.sol b/contracts/script/crosschain/Deploy.s.sol index 0a1763770..d2633b834 100644 --- a/contracts/script/crosschain/Deploy.s.sol +++ b/contracts/script/crosschain/Deploy.s.sol @@ -11,6 +11,7 @@ import {IStateBridge} from "../../src/crosschain/interfaces/IStateBridge.sol"; import {PermissionedGatewayAdapter} from "../../src/crosschain/adapters/PermissionedGatewayAdapter.sol"; import {EthereumMPTGatewayAdapter} from "../../src/crosschain/adapters/EthereumMPTGatewayAdapter.sol"; import {LightClientGatewayAdapter} from "../../src/crosschain/adapters/LightClientGatewayAdapter.sol"; +import {OpStackGatewayAdapter} from "../../src/crosschain/adapters/OpStackGatewayAdapter.sol"; import {Verifier} from "../../src/core/Verifier.sol"; /// @title BridgeDeployer @@ -71,6 +72,7 @@ contract Deploy is Script { uint256 internal _wcChainId; address internal _l1BridgeProxy; uint256 internal _l1ChainId; + address internal _l1GatewayAddr; address internal _broadcaster; string[] internal _deployFilter; @@ -307,6 +309,10 @@ contract Deploy is Script { if (_hasKey(_config, string.concat(np, ".zkGateway"))) { _deployLightClientGateway(deployments, network, owner); } + + if (_hasKey(_config, string.concat(np, ".opStackGateway"))) { + _deployOpStackGateway(deployments, network); + } } function _deployPermissionedGateway(string memory deployments, string memory network, address owner) internal { @@ -338,6 +344,7 @@ contract Deploy is Script { address existing = _tryLoadAddress(deployments, deployKey); if (existing != address(0)) { console2.log(" EthereumMPTGateway already deployed at", existing); + _l1GatewayAddr = existing; return; } @@ -356,6 +363,7 @@ contract Deploy is Script { address addr = _deployer.deploy(salt, initCode); console2.log(" EthereumMPTGateway:", addr); + _l1GatewayAddr = addr; _gwAddrs.push(addr); _gwTypes.push("ethereumMPTGateway"); } @@ -408,6 +416,48 @@ contract Deploy is Script { _gwTypes.push("lightClientGateway"); } + /// @dev OP Stack `L2CrossDomainMessenger` predeploy, identical across OP Stack chains. + address internal constant L2_CROSS_DOMAIN_MESSENGER = 0x4200000000000000000000000000000000000007; + + /// @dev Deploys the native OP Stack gateway on an L2. Receives state pushed from L1 via the + /// `EthereumMPTGatewayAdapter` (the trusted L1 cross-domain sender), so the L1 network must + /// be deployed first. + function _deployOpStackGateway(string memory deployments, string memory network) internal { + string memory deployKey = string.concat(".", network, ".gateways.opStackGateway"); + + address existing = _tryLoadAddress(deployments, deployKey); + if (existing != address(0)) { + console2.log(" OpStackGateway already deployed at", existing); + return; + } + + require(_l1BridgeProxy != address(0), "L1 bridge not deployed - deploy L1 network first"); + require(_l1GatewayAddr != address(0), "L1 MPT gateway not deployed - deploy L1 network first"); + + // The L2CrossDomainMessenger is a predeploy at a fixed address, but allow a config override. + string memory gp = string.concat(".", network, ".opStackGateway"); + address messenger = _tryLoadAddress(_config, string.concat(gp, ".messenger")); + if (messenger == address(0)) { + messenger = L2_CROSS_DOMAIN_MESSENGER; + } + + console2.log("--- Deploying OpStackGateway ---"); + console2.log(" Messenger:", messenger); + console2.log(" L1 sender:", _l1GatewayAddr); + + bytes32 salt = keccak256(abi.encodePacked(vm.parseJsonBytes32(_config, ".salts.opStackGateway"), network)); + bytes memory initCode = abi.encodePacked( + type(OpStackGatewayAdapter).creationCode, + abi.encode(messenger, _l1GatewayAddr, bridgeProxy, _l1BridgeProxy, _l1ChainId) + ); + + address addr = _deployer.deploy(salt, initCode); + console2.log(" OpStackGateway:", addr); + + _gwAddrs.push(addr); + _gwTypes.push("opStackGateway"); + } + //////////////////////////////////////////////////////////// // PERSISTENCE // //////////////////////////////////////////////////////////// diff --git a/contracts/script/crosschain/config/production.json b/contracts/script/crosschain/config/production.json index 8feea5ca0..e69770b0b 100644 --- a/contracts/script/crosschain/config/production.json +++ b/contracts/script/crosschain/config/production.json @@ -12,7 +12,8 @@ "ownedGateway": "0x0000000000000000000000000000000000000000000000000000000000002300", "l1Gateway": "0x0000000000000000000000000000000000000000000000000000000000002400", "zkGateway": "0x0000000000000000000000000000000000000000000000000000000000002500", - "verifier": "0x0000000000000000000000000000000000000000000000000000000000002600" + "verifier": "0x0000000000000000000000000000000000000000000000000000000000002600", + "opStackGateway": "0x0000000000000000000000000000000000000000000000000000000000002700" }, "worldchain": { diff --git a/contracts/script/crosschain/config/staging.json b/contracts/script/crosschain/config/staging.json index ee5562b97..3d6baa782 100644 --- a/contracts/script/crosschain/config/staging.json +++ b/contracts/script/crosschain/config/staging.json @@ -12,7 +12,8 @@ "ownedGateway": "0x0000000000000000000000000000000000000000000000000000000000002300", "l1Gateway": "0x0000000000000000000000000000000000000000000000000000000000002400", "zkGateway": "0x0000000000000000000000000000000000000000000000000000000000002500", - "verifier": "0x0000000000000000000000000000000000000000000000000000000000002600" + "verifier": "0x0000000000000000000000000000000000000000000000000000000000002600", + "opStackGateway": "0x0000000000000000000000000000000000000000000000000000000000002700" }, "worldchain": { @@ -23,7 +24,7 @@ "oprfRegistry": "0xb2C02253ee7bFEDF50F5D015658857099980E91F" }, - "networks": ["base"], + "networks": ["ethereum", "base"], "ethereum": { "chainId": 1, @@ -40,7 +41,8 @@ "chainId": 8453, "alchemySlug": "base-mainnet", "verifier": "0x0000000000000000000000000000000000000000", - "ownedGateway": {} + "ownedGateway": {}, + "opStackGateway": {} }, "arbitrum": { diff --git a/contracts/src/crosschain/Error.sol b/contracts/src/crosschain/Error.sol index 04bc98303..ffb22eba1 100644 --- a/contracts/src/crosschain/Error.sol +++ b/contracts/src/crosschain/Error.sol @@ -78,6 +78,10 @@ error StorageValueTooLarge(); /// @dev Thrown when the proven chain head does not match the expected keccak chain head. error InvalidChainHead(); +/// @dev Thrown when a native cross-domain message is not relayed by the configured messenger, +/// or its L1 cross-domain sender is not the trusted L1 gateway. +error InvalidCrossDomainSender(); + error InvalidContractName(); error InvalidContractVersion(); diff --git a/contracts/src/crosschain/adapters/EthereumMPTGatewayAdapter.sol b/contracts/src/crosschain/adapters/EthereumMPTGatewayAdapter.sol index 61b5a8966..c45182140 100644 --- a/contracts/src/crosschain/adapters/EthereumMPTGatewayAdapter.sol +++ b/contracts/src/crosschain/adapters/EthereumMPTGatewayAdapter.sol @@ -2,14 +2,20 @@ pragma solidity ^0.8.28; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC7786GatewaySource} from "@openzeppelin/contracts/interfaces/draft-IERC7786.sol"; import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {ICrossDomainMessenger} from "interfaces/universal/ICrossDomainMessenger.sol"; import {GameStatus, Claim, GameType} from "@optimism-bedrock/src/dispute/lib/Types.sol"; import {Lib} from "@world-id-bridge/lib/Lib.sol"; import "@world-id-bridge/lib/StateBridge.sol"; import {WorldIDGateway} from "@world-id-bridge/lib/Gateway.sol"; import "@world-id-bridge/Error.sol"; +/// @notice Emitted when verified World Chain state is forwarded to a native OP Stack L2 gateway +/// through the `L1CrossDomainMessenger`. +event ForwardedToL2(address indexed messenger, address indexed l2Adapter, bytes32 chainHead); + /// @title EthereumMPTGatewayAdapter /// @author World Contributors /// @notice Trustless L1 verification adapter that authenticates World Chain state via the @@ -83,6 +89,43 @@ contract EthereumMPTGatewayAdapter is WorldIDGateway, Ownable { Lib.proveStorageSlot(ANCHOR_BRIDGE, STATE_BRIDGE_STORAGE_SLOT, wcAccountProof, wcStorageProof, wcStateRoot); } + /// @notice Verifies World Chain state (dispute game + MPT), then forwards the proven chain + /// head to a native OP Stack L2 gateway through the `L1CrossDomainMessenger`. + /// @dev This adapter is the L1 cross-domain sender that the destination `OpStackGatewayAdapter` + /// trusts: the L2 message is delivered with this contract as `xDomainMessageSender`. The + /// proof is re-verified here so only authentic chain heads can ever be forwarded, making the + /// call permissionless like `sendMessage`. The same `payload` (commitment delta) is delivered + /// to L2; the destination satellite checks it hashes to `chainHead`. + /// @param messenger The `L1CrossDomainMessenger` for the destination rollup. + /// @param l2Adapter The `OpStackGatewayAdapter` address on the destination L2. + /// @param recipient ERC-7930 interoperable address of the destination `WorldIDSatellite`. + /// @param payload ABI-encoded `Lib.Commitment[]` delta to apply on L2. + /// @param attributes Gateway attributes carrying the MPT proof (`l1ProofAttributes`). + /// @param minGasLimit Minimum L2 gas for the relayed `sendMessage` call. + function forwardToL2( + address messenger, + address l2Adapter, + bytes calldata recipient, + bytes calldata payload, + bytes[] calldata attributes, + uint32 minGasLimit + ) external virtual { + if (messenger == address(0) || l2Adapter == address(0)) revert ZeroAddress(); + if (payload.length == 0) revert EmptyPayload(); + + bytes memory attributeData = validateAttributes(attributes); + bytes32 chainHead = _verifyAndExtract(payload, attributeData); + + bytes[] memory l2Attributes = new bytes[](1); + l2Attributes[0] = abi.encodePacked(bytes4(keccak256("chainHead(bytes32)")), abi.encode(chainHead)); + + bytes memory message = abi.encodeCall(IERC7786GatewaySource.sendMessage, (recipient, payload, l2Attributes)); + + ICrossDomainMessenger(messenger).sendMessage(l2Adapter, message, minGasLimit); + + emit ForwardedToL2(messenger, l2Adapter, chainHead); + } + //////////////////////////////////////////////////////////// // ADMIN FUNCTIONS // //////////////////////////////////////////////////////////// diff --git a/contracts/src/crosschain/adapters/OpStackGatewayAdapter.sol b/contracts/src/crosschain/adapters/OpStackGatewayAdapter.sol new file mode 100644 index 000000000..af6218a0c --- /dev/null +++ b/contracts/src/crosschain/adapters/OpStackGatewayAdapter.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ICrossDomainMessenger} from "interfaces/universal/ICrossDomainMessenger.sol"; +import {WorldIDGateway} from "@world-id-bridge/lib/Gateway.sol"; +import "@world-id-bridge/Error.sol"; + +/// @title OpStackGatewayAdapter +/// @author World Contributors +/// @notice Native OP Stack L1->L2 verification adapter. Receives the proven L1 `StateBridge` +/// chain head pushed natively from L1 through the OP Stack deposit path +/// (`L1CrossDomainMessenger` -> `OptimismPortal` -> `L2CrossDomainMessenger`). +/// +/// Trust derives entirely from the canonical rollup messaging: this adapter only accepts calls +/// relayed by the local `L2CrossDomainMessenger` whose L1 cross-domain sender is the configured +/// `L1_SENDER` (the `EthereumMPTGatewayAdapter`). No proof is verified locally — the L1 sender +/// has already verified World Chain state against the L1 dispute game before forwarding. +/// +/// This is a drop-in alternative to the `LightClientGatewayAdapter`: same role (anchor is the +/// L1 `StateBridge`, deployed on the destination L2, delivers to the local `WorldIDSatellite`), +/// but L1 state arrives via native rollup messaging instead of an SP1 light-client proof. +contract OpStackGatewayAdapter is WorldIDGateway { + /// @inheritdoc WorldIDGateway + bytes4 public constant override ATTRIBUTE = bytes4(keccak256("chainHead(bytes32)")); + + /// @notice The local `L2CrossDomainMessenger` predeploy + /// (`0x4200000000000000000000000000000000000007` on OP Stack chains). + ICrossDomainMessenger public immutable MESSENGER; + + /// @notice The trusted L1 sender (the `EthereumMPTGatewayAdapter`) authorized to push state. + address public immutable L1_SENDER; + + /// @param messenger_ The `L2CrossDomainMessenger` predeploy address on this chain. + /// @param l1Sender_ The trusted L1 gateway (`EthereumMPTGatewayAdapter`) address. + /// @param bridge_ The `WorldIDSatellite` contract on this chain. + /// @param l1Bridge_ The L1 `StateBridge` address (anchor / source of truth). + /// @param l1ChainId_ The L1 chain ID (e.g. 1 for mainnet). + constructor(address messenger_, address l1Sender_, address bridge_, address l1Bridge_, uint256 l1ChainId_) + WorldIDGateway(bridge_, l1Bridge_, l1ChainId_) + { + if (messenger_ == address(0)) revert ZeroAddress(); + if (l1Sender_ == address(0)) revert ZeroAddress(); + MESSENGER = ICrossDomainMessenger(messenger_); + L1_SENDER = l1Sender_; + } + + /// @dev Authenticates the native cross-domain message and extracts the proven chain head. + /// Reverts unless the call was relayed by `MESSENGER` and originated from `L1_SENDER` on L1. + /// Expects a single attribute: `chainHead(bytes32)`. + function _verifyAndExtract(bytes calldata, bytes memory proofData) + internal + virtual + override + returns (bytes32 chainHead) + { + if (msg.sender != address(MESSENGER)) revert InvalidCrossDomainSender(); + if (MESSENGER.xDomainMessageSender() != L1_SENDER) revert InvalidCrossDomainSender(); + + (chainHead) = abi.decode(proofData, (bytes32)); + } +} diff --git a/contracts/test/crosschain/OpStackGateway.t.sol b/contracts/test/crosschain/OpStackGateway.t.sol new file mode 100644 index 000000000..bdb936317 --- /dev/null +++ b/contracts/test/crosschain/OpStackGateway.t.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {InteroperableAddress} from "openzeppelin-contracts/contracts/utils/draft-InteroperableAddress.sol"; + +import {OpStackGatewayAdapter} from "../../src/crosschain/adapters/OpStackGatewayAdapter.sol"; +import {WorldIDSource} from "../../src/crosschain/WorldIDSource.sol"; +import {WorldIDSatellite} from "../../src/crosschain/WorldIDSatellite.sol"; +import {IStateBridge} from "../../src/crosschain/interfaces/IStateBridge.sol"; +import {Lib} from "../../src/crosschain/lib/Lib.sol"; +import {Verifier} from "../../src/core/Verifier.sol"; + +import { + MockRegistry, + MockIssuerRegistry, + MockOprfRegistry, + MockDisputeGameFactory, + MockCrossDomainMessenger, + TestableEthereumMPTAdapter +} from "./helpers/Mocks.sol"; + +/// @title OpStackGatewayTest +/// @notice End-to-end tests for the native OP Stack L1->L2 gateway path: +/// `EthereumMPTGatewayAdapter.forwardToL2` (L1 sender) -> `MockCrossDomainMessenger` (relay) -> +/// `OpStackGatewayAdapter` (L2 receiver) -> `WorldIDSatellite`. +contract OpStackGatewayTest is Test { + using InteroperableAddress for bytes; + + bytes4 constant UPDATE_ROOT_SELECTOR = bytes4(keccak256("updateRoot(uint256,uint256,bytes32)")); + bytes4 constant SET_ISSUER_PUBKEY_SELECTOR = bytes4(keccak256("setIssuerPubkey(uint64,uint256,uint256,bytes32)")); + bytes4 constant SET_OPRF_KEY_SELECTOR = bytes4(keccak256("setOprfKey(uint160,uint256,uint256,bytes32)")); + bytes4 constant CHAIN_HEAD_SELECTOR = bytes4(keccak256("chainHead(bytes32)")); + bytes4 constant L1_PROOF_SELECTOR = bytes4(keccak256("l1ProofAttributes(uint32,bytes,bytes32[4],bytes[],bytes[])")); + + uint256 constant WC_CHAIN_ID = 480; + uint256 constant L1_CHAIN_ID = 1; + uint256 constant ROOT_VALIDITY_WINDOW = 3600; + uint256 constant TREE_DEPTH = 30; + uint64 constant MIN_EXPIRATION = 7200; + uint64 constant ISSUER_SCHEMA_ID = 123; + uint160 constant OPRF_KEY_ID = 123; + uint32 constant MIN_GAS_LIMIT = 500_000; + + address owner = makeAddr("owner"); + address relayer = makeAddr("relayer"); + address l1Bridge = makeAddr("l1Bridge"); + + MockRegistry registry; + MockIssuerRegistry issuerRegistry; + MockOprfRegistry oprfRegistry; + + WorldIDSource source; + address sourceProxy; + + WorldIDSatellite satellite; + address satelliteProxy; + + Verifier verifier; + + MockCrossDomainMessenger messenger; + TestableEthereumMPTAdapter l1Adapter; + OpStackGatewayAdapter l2Adapter; + + function setUp() public { + registry = new MockRegistry(); + issuerRegistry = new MockIssuerRegistry(); + oprfRegistry = new MockOprfRegistry(); + + registry.setLatestRoot(12345); + issuerRegistry.setPubkey(ISSUER_SCHEMA_ID, 111, 222); + oprfRegistry.setKey(OPRF_KEY_ID, 333, 444); + + // WorldIDSource (impl + proxy) + source = new WorldIDSource(address(registry), address(issuerRegistry), address(oprfRegistry)); + address[] memory emptyGws = new address[](0); + IStateBridge.InitConfig memory srcCfg = IStateBridge.InitConfig({ + name: "World ID Source", version: "1", owner: owner, authorizedGateways: emptyGws + }); + sourceProxy = address(new ERC1967Proxy(address(source), abi.encodeCall(WorldIDSource.initialize, (srcCfg)))); + + // WorldIDSatellite on the destination L2 (impl + proxy) + verifier = new Verifier(); + satellite = new WorldIDSatellite(address(verifier), ROOT_VALIDITY_WINDOW, TREE_DEPTH, MIN_EXPIRATION); + IStateBridge.InitConfig memory dstCfg = IStateBridge.InitConfig({ + name: "World ID Bridge", version: "1", owner: owner, authorizedGateways: emptyGws + }); + satelliteProxy = + address(new ERC1967Proxy(address(satellite), abi.encodeCall(WorldIDSatellite.initialize, (dstCfg)))); + + // L1 sender: EthereumMPTGatewayAdapter (testable harness bypasses MPT verification). + MockDisputeGameFactory dgf = new MockDisputeGameFactory(); + l1Adapter = new TestableEthereumMPTAdapter(owner, address(dgf), false, l1Bridge, sourceProxy, WC_CHAIN_ID); + + // L2 receiver: native OP Stack gateway. Trusts the L2 messenger + the L1 adapter as sender. + messenger = new MockCrossDomainMessenger(); + l2Adapter = + new OpStackGatewayAdapter(address(messenger), address(l1Adapter), satelliteProxy, l1Bridge, L1_CHAIN_ID); + + // Authorize the L2 gateway on the satellite. + vm.prank(owner); + WorldIDSatellite(satelliteProxy).addGateway(address(l2Adapter)); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + function _propagateSource() internal returns (bytes32 chainHead) { + uint64[] memory issuerIds = new uint64[](1); + issuerIds[0] = ISSUER_SCHEMA_ID; + uint160[] memory oprfIds = new uint160[](1); + oprfIds[0] = OPRF_KEY_ID; + + WorldIDSource(sourceProxy).propagateState(issuerIds, oprfIds); + chainHead = WorldIDSource(sourceProxy).KECCAK_CHAIN().head; + } + + function _buildCommitPayload() internal view returns (bytes memory) { + bytes32 blockHash = blockhash(block.number - 1); + bytes32 proofId = bytes32(block.number); + + Lib.Commitment[] memory commits = new Lib.Commitment[](3); + commits[0] = Lib.Commitment({ + blockHash: blockHash, + data: abi.encodeWithSelector(UPDATE_ROOT_SELECTOR, registry.latestRoot(), block.timestamp, proofId) + }); + commits[1] = Lib.Commitment({ + blockHash: blockHash, + data: abi.encodeWithSelector( + SET_ISSUER_PUBKEY_SELECTOR, ISSUER_SCHEMA_ID, uint256(111), uint256(222), proofId + ) + }); + commits[2] = Lib.Commitment({ + blockHash: blockHash, + data: abi.encodeWithSelector(SET_OPRF_KEY_SELECTOR, OPRF_KEY_ID, uint256(333), uint256(444), proofId) + }); + return abi.encode(commits); + } + + function _recipientBytes() internal view returns (bytes memory) { + return InteroperableAddress.formatEvmV1(block.chainid, satelliteProxy); + } + + /// @dev Builds the `l1ProofAttributes` attribute (values are ignored by the test harness). + function _l1ProofAttributes() internal pure returns (bytes[] memory attributes) { + uint32 gameType = 0; + bytes memory extraData = hex""; + bytes32[4] memory preimage; + bytes[] memory accountProof = new bytes[](0); + bytes[] memory storageProof = new bytes[](0); + attributes = new bytes[](1); + attributes[0] = + abi.encodePacked(L1_PROOF_SELECTOR, abi.encode(gameType, extraData, preimage, accountProof, storageProof)); + } + + /// @dev Encodes the L2 `sendMessage` call delivered by the messenger (mirrors `forwardToL2`). + function _l2Message(bytes32 chainHead, bytes memory payload) internal view returns (bytes memory) { + bytes[] memory attrs = new bytes[](1); + attrs[0] = abi.encodePacked(CHAIN_HEAD_SELECTOR, abi.encode(chainHead)); + return abi.encodeWithSignature("sendMessage(bytes,bytes,bytes[])", _recipientBytes(), payload, attrs); + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + function test_opStackGateway_e2e() public { + bytes32 chainHead = _propagateSource(); + l1Adapter.setOverrideChainHead(chainHead); + + bytes memory payload = _buildCommitPayload(); + + // Permissionless: anyone can trigger the L1 forward; trust comes from the re-verified proof. + vm.prank(relayer); + l1Adapter.forwardToL2( + address(messenger), address(l2Adapter), _recipientBytes(), payload, _l1ProofAttributes(), MIN_GAS_LIMIT + ); + + // State landed on the destination satellite. + assertEq(WorldIDSatellite(satelliteProxy).LATEST_ROOT(), registry.latestRoot()); + + (bytes32 head, uint64 length) = _destChain(); + assertEq(head, chainHead, "destination head should match proven source head"); + assertEq(length, 3, "should have 3 commitments"); + assertTrue(WorldIDSatellite(satelliteProxy).isValidRoot(registry.latestRoot()), "root should be valid"); + } + + function test_opStackGateway_revert_notMessenger() public { + bytes32 chainHead = _propagateSource(); + bytes memory payload = _buildCommitPayload(); + + bytes[] memory attrs = new bytes[](1); + attrs[0] = abi.encodePacked(CHAIN_HEAD_SELECTOR, abi.encode(chainHead)); + + // Calling sendMessage directly (not via the messenger) must revert. + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSignature("InvalidCrossDomainSender()")); + l2Adapter.sendMessage(_recipientBytes(), payload, attrs); + } + + function test_opStackGateway_revert_wrongL1Sender() public { + bytes32 chainHead = _propagateSource(); + bytes memory payload = _buildCommitPayload(); + + // Relay through the messenger, but with an untrusted L1 sender. + vm.expectRevert(abi.encodeWithSignature("InvalidCrossDomainSender()")); + messenger.relayFrom(address(l2Adapter), makeAddr("attacker"), _l2Message(chainHead, payload)); + } + + function test_opStackGateway_revert_wrongChainHead() public { + _propagateSource(); + l1Adapter.setOverrideChainHead(bytes32(uint256(0xdead))); + + bytes memory payload = _buildCommitPayload(); + + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSignature("InvalidChainHead()")); + l1Adapter.forwardToL2( + address(messenger), address(l2Adapter), _recipientBytes(), payload, _l1ProofAttributes(), MIN_GAS_LIMIT + ); + } + + function test_opStackGateway_supportsAttribute() public view { + assertTrue(l2Adapter.supportsAttribute(CHAIN_HEAD_SELECTOR)); + assertFalse(l2Adapter.supportsAttribute(bytes4(0xdeadbeef))); + } + + function test_opStackGateway_immutables() public view { + assertEq(address(l2Adapter.MESSENGER()), address(messenger)); + assertEq(l2Adapter.L1_SENDER(), address(l1Adapter)); + assertEq(l2Adapter.STATE_BRIDGE(), satelliteProxy); + assertEq(l2Adapter.ANCHOR_BRIDGE(), l1Bridge); + assertEq(l2Adapter.ANCHOR_CHAIN_ID(), L1_CHAIN_ID); + } + + function _destChain() internal view returns (bytes32 head, uint64 length) { + Lib.Chain memory c = WorldIDSatellite(satelliteProxy).KECCAK_CHAIN(); + return (c.head, c.length); + } +} diff --git a/contracts/test/crosschain/helpers/Mocks.sol b/contracts/test/crosschain/helpers/Mocks.sol index d27e771dc..9c47c510e 100644 --- a/contracts/test/crosschain/helpers/Mocks.sol +++ b/contracts/test/crosschain/helpers/Mocks.sol @@ -88,6 +88,38 @@ contract MockDisputeGameFactory { } } +// ─── Mock OP Stack CrossDomainMessenger ────────────────────────────────────── + +/// @notice Minimal `CrossDomainMessenger` mock that relays a message in-process, simulating the +/// OP Stack L1->L2 deposit path within a single EVM. `sendMessage` immediately invokes the +/// target with `xDomainMessageSender` set to the caller (the L1 sender), mirroring how the +/// real `L2CrossDomainMessenger.relayMessage` exposes the L1 origin. +contract MockCrossDomainMessenger { + address public xDomainMessageSender; + + /// @dev Relays `message` to `target` with `xDomainMessageSender` set to the original caller. + function sendMessage(address target, bytes calldata message, uint32) external { + _relay(target, msg.sender, message); + } + + /// @dev Test-only: relays a message as an arbitrary L1 sender (to exercise auth failures). + function relayFrom(address target, address l1Sender, bytes calldata message) external { + _relay(target, l1Sender, message); + } + + function _relay(address target, address l1Sender, bytes calldata message) internal { + address prev = xDomainMessageSender; + xDomainMessageSender = l1Sender; + (bool ok, bytes memory ret) = target.call(message); + xDomainMessageSender = prev; + if (!ok) { + assembly ("memory-safe") { + revert(add(ret, 0x20), mload(ret)) + } + } + } +} + // ─── EthereumMPTGatewayAdapter test harness that bypasses MPT ──────────────── contract TestableEthereumMPTAdapter is EthereumMPTGatewayAdapter { diff --git a/services/relay/src/bindings.rs b/services/relay/src/bindings.rs index ec5876033..02646d769 100644 --- a/services/relay/src/bindings.rs +++ b/services/relay/src/bindings.rs @@ -129,6 +129,15 @@ sol! { function DISPUTE_GAME_FACTORY() external view returns (address); function requireFinalized() external view returns (bool); function setRequireFinalized(bool required) external; + + function forwardToL2( + address messenger, + address l2Adapter, + bytes calldata recipient, + bytes calldata payload, + bytes[] calldata attributes, + uint32 minGasLimit + ) external; } } diff --git a/services/relay/src/cli/mod.rs b/services/relay/src/cli/mod.rs index da751b749..b954de75b 100644 --- a/services/relay/src/cli/mod.rs +++ b/services/relay/src/cli/mod.rs @@ -21,7 +21,7 @@ use crate::{ log::CommitmentLog, metrics as relay_metrics, satellite::{ - EthereumMptSatellite, PermissionedSatellite, TempoSatellite, + EthereumMptSatellite, OpStackSatellite, PermissionedSatellite, TempoSatellite, permissioned::tempo::FEE_TOKEN as TEMPO_FEE_TOKEN, }, }; @@ -105,6 +105,18 @@ pub struct Cli { /// "game_type": 0, /// "require_finalized": false /// } +/// ], +/// "op_stack_gateways": [ +/// { +/// "name": "BASE", +/// "l1_name": "ETHEREUM", +/// "destination_chain_id": 8453, +/// "l1_adapter": "0x...", +/// "l1_messenger": "0x...", +/// "l2_adapter": "0x...", +/// "satellite": "0x...", +/// "dispute_game_factory": "0x..." +/// } /// ] /// } /// ``` @@ -120,6 +132,11 @@ pub struct RelayConfig { /// Ethereum MPT gateway satellites (OP Stack dispute game + MPT proofs). #[serde(default)] pub ethereum_mpt_gateways: Option>, + + /// Native OP Stack gateway satellites (L1 dispute game + MPT proofs, pushed L1->L2 + /// through the canonical `CrossDomainMessenger`). + #[serde(default)] + pub op_stack_gateways: Option>, } /// World Chain source configuration. @@ -241,6 +258,73 @@ pub struct EthereumMptGatewayConfig { pub require_finalized: bool, } +/// Configuration for a native OP Stack gateway satellite. +/// +/// Bridges World Chain state to an OP Stack L2 (Base, Optimism, …) by re-using the L1 MPT proof +/// path and then pushing the proven chain head from L1 to the L2 through the canonical +/// `CrossDomainMessenger`. The relay transaction (`forwardToL2`) is sent on **L1**. +/// +/// Two RPC endpoints are read from the environment: +/// - `{name}_RPC_URL` — the **L2** endpoint (for reading the destination chain head). +/// - `{l1_name}_RPC_URL` — the **L1** endpoint (for dispute games and sending `forwardToL2`). +#[derive(Debug, Clone, Deserialize)] +pub struct OpStackGatewayConfig { + /// Destination (L2) identifier, also used to derive the L2 RPC URL env var + /// `{name}_RPC_URL` (e.g. `BASE`). Use UPPER_CASE. + pub name: String, + + /// Identifier for the L1 chain, used to derive the L1 RPC URL env var + /// `{l1_name}_RPC_URL` (e.g. `ETHEREUM`). Use UPPER_CASE. + pub l1_name: String, + + /// The destination (L2) chain ID. + pub destination_chain_id: u64, + + /// The `EthereumMPTGatewayAdapter` on L1 (the trusted cross-domain sender). `forwardToL2` + /// is called on this contract. + pub l1_adapter: Address, + + /// The `L1CrossDomainMessenger` for the destination rollup (on L1). + pub l1_messenger: Address, + + /// The `OpStackGatewayAdapter` on the destination L2. + pub l2_adapter: Address, + + /// The `WorldIDSatellite` (bridge) proxy address on the destination L2. + pub satellite: Address, + + /// The `DisputeGameFactory` contract on L1. + pub dispute_game_factory: Address, + + /// The dispute game type (default: 0 = CANNON). + #[serde(default)] + pub game_type: u32, + + /// Whether to require dispute games to be finalized (DEFENDER_WINS). + #[serde(default)] + pub require_finalized: bool, + + /// Minimum L2 gas for the relayed `sendMessage` call (default: 500,000). + #[serde(default = "default_op_stack_min_gas_limit")] + pub min_gas_limit: u32, +} + +fn default_op_stack_min_gas_limit() -> u32 { + 500_000 +} + +impl OpStackGatewayConfig { + /// Returns the env var name that supplies the L2 (destination) RPC URL. + pub fn rpc_env_var(&self) -> String { + format!("{}_RPC_URL", self.name.to_uppercase()) + } + + /// Returns the env var name that supplies the L1 RPC URL. + pub fn l1_rpc_env_var(&self) -> String { + format!("{}_RPC_URL", self.l1_name.to_uppercase()) + } +} + // --------------------------------------------------------------------------- // Config loading // --------------------------------------------------------------------------- @@ -627,6 +711,42 @@ impl Cli { ); } + // Spawn native OP Stack gateway satellites (L1 proof + L1->L2 CrossDomainMessenger push). + for sat_config in config.op_stack_gateways.iter().flatten() { + // L1 provider: sends `forwardToL2` and queries dispute games. + let l1_rpc_url = rpc_url_from_env(&sat_config.l1_rpc_env_var())?; + let l1_provider = Arc::new(build_provider(&l1_rpc_url, &wallet).await?); + + // L2 provider: reads the destination chain head. + let l2_provider = Arc::new(satellite_provider(&sat_config.name, &wallet).await?); + + // The relay transaction is signed and sent on L1, so wallet metrics track L1. + log_wallet_status( + l1_provider.as_ref(), + wallet_address, + config.source.chain_id, + &sat_config.l1_name, + ) + .await?; + + let satellite = OpStackSatellite::from_config( + &wc_config, + sat_config, + wc_provider.clone(), + l1_provider, + l2_provider, + ); + engine.spawn_satellite(satellite); + satellite_count += 1; + + tracing::info!( + name = %sat_config.name, + adapter = "op_stack", + chain_id = sat_config.destination_chain_id, + "registered satellite" + ); + } + if satellite_count == 0 { tracing::warn!( "no satellite chains configured — relay will only track World Chain state" @@ -727,6 +847,41 @@ mod tests { assert_eq!(perm[1].chain_type, ChainType::Tempo); } + #[test] + fn parse_op_stack_config() { + let json = r#"{ + "source": { + "world_id_source": "0x1111111111111111111111111111111111111111", + "world_id_registry": "0x2222222222222222222222222222222222222222", + "oprf_key_registry": "0x3333333333333333333333333333333333333333", + "issuer_schema_registry": "0x4444444444444444444444444444444444444444" + }, + "op_stack_gateways": [ + { + "name": "BASE", + "l1_name": "ETHEREUM", + "destination_chain_id": 8453, + "l1_adapter": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "l1_messenger": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "l2_adapter": "0xcccccccccccccccccccccccccccccccccccccccc", + "satellite": "0xdddddddddddddddddddddddddddddddddddddddd", + "dispute_game_factory": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + ] + }"#; + + let config: RelayConfig = serde_json::from_str(json).unwrap(); + let ops = config.op_stack_gateways.as_ref().unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].name, "BASE"); + assert_eq!(ops[0].rpc_env_var(), "BASE_RPC_URL"); + assert_eq!(ops[0].l1_rpc_env_var(), "ETHEREUM_RPC_URL"); + assert_eq!(ops[0].destination_chain_id, 8453); + assert_eq!(ops[0].game_type, 0); + assert!(!ops[0].require_finalized); + assert_eq!(ops[0].min_gas_limit, 500_000); + } + #[test] fn defaults_applied() { let json = r#"{ @@ -743,5 +898,6 @@ mod tests { assert_eq!(config.source.bridge_interval_secs, 3600); assert!(config.permissioned_gateways.is_none()); assert!(config.ethereum_mpt_gateways.is_none()); + assert!(config.op_stack_gateways.is_none()); } } diff --git a/services/relay/src/relay.rs b/services/relay/src/relay.rs index e53dabaaa..99fede78b 100644 --- a/services/relay/src/relay.rs +++ b/services/relay/src/relay.rs @@ -7,7 +7,7 @@ use alloy::{ use eyre::Result; use tracing::info; -use crate::bindings::IGateway; +use crate::bindings::{IEthereumMPTGatewayAdapter, IGateway}; /// Sends a relay transaction to a gateway contract. /// @@ -57,6 +57,56 @@ pub async fn send_relay_tx( Ok(tx_hash) } +/// Pushes verified state to a native OP Stack L2 by calling `forwardToL2` on the L1 +/// `EthereumMPTGatewayAdapter`. +/// +/// The L1 adapter re-verifies the MPT proof (`attribute`) and, on success, relays the proven +/// chain head and `payload` to the destination `OpStackGatewayAdapter` through the +/// `L1CrossDomainMessenger`. The transaction is sent on L1. +#[allow(clippy::too_many_arguments)] +pub async fn send_forward_to_l2_tx( + l1_provider: &DynProvider, + l1_adapter: Address, + messenger: Address, + l2_adapter: Address, + l2_chain_id: u64, + l2_satellite: Address, + payload: Bytes, + attribute: Bytes, + min_gas_limit: u32, +) -> Result { + // The L2 message ultimately calls `sendMessage` on the L2 adapter, whose recipient must be the + // destination satellite. The chain ref in the ERC-7930 address is not validated on-chain. + let recipient = encode_evm_v1_address(l2_chain_id, l2_satellite); + + let call = IEthereumMPTGatewayAdapter::forwardToL2Call { + messenger, + l2Adapter: l2_adapter, + recipient: recipient.into(), + payload, + attributes: vec![attribute], + minGasLimit: min_gas_limit, + }; + + let tx = TransactionRequest::default() + .to(l1_adapter) + .input(call.abi_encode().into()); + + let pending = l1_provider.send_transaction(tx).await?; + let tx_hash = *pending.tx_hash(); + + info!(%tx_hash, %l1_adapter, %l2_adapter, "forwardToL2 transaction sent"); + + let receipt = pending.get_receipt().await?; + + if !receipt.status() { + eyre::bail!("forwardToL2 transaction reverted: {tx_hash}"); + } + + info!(%tx_hash, "forwardToL2 transaction confirmed"); + Ok(tx_hash) +} + /// Encodes an address as an ERC-7930 EVM v1 interoperable address. /// /// Format: `version(2) | chainType(2) | chainRefLen(1) | chainRef(var) | addrLen(1) | addr(20)` diff --git a/services/relay/src/satellite/mod.rs b/services/relay/src/satellite/mod.rs index ea93e7aa8..e6ba458dc 100644 --- a/services/relay/src/satellite/mod.rs +++ b/services/relay/src/satellite/mod.rs @@ -1,7 +1,9 @@ mod ethereum_mpt; +mod op_stack; pub mod permissioned; pub use ethereum_mpt::EthereumMptSatellite; +pub use op_stack::OpStackSatellite; pub use permissioned::{PermissionedSatellite, TempoSatellite}; use tracing::Instrument; diff --git a/services/relay/src/satellite/op_stack.rs b/services/relay/src/satellite/op_stack.rs new file mode 100644 index 000000000..aa6245a78 --- /dev/null +++ b/services/relay/src/satellite/op_stack.rs @@ -0,0 +1,154 @@ +use std::{future::Future, pin::Pin, sync::Arc, time::Duration}; + +use alloy::{ + primitives::{Address, B256, Bytes}, + providers::DynProvider, +}; +use eyre::Result; + +use crate::{ + bindings::IWorldIDSatellite::IWorldIDSatelliteInstance, + cli::{OpStackGatewayConfig, WorldChainConfig}, + primitives::ChainCommitment, + proof::ethereum_mpt::build_l1_proof_attributes, + relay::send_forward_to_l2_tx, +}; + +use super::Satellite; + +/// Default poll interval when waiting for a dispute game. +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Default timeout for dispute game polling. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(3600); + +/// A satellite that bridges to a native OP Stack L2 (Base, Optimism, …). +/// +/// State is verified on L1 with the same dispute-game + MPT proof path as [`EthereumMptSatellite`], +/// then pushed from L1 to the L2 over the canonical `CrossDomainMessenger` by calling +/// `forwardToL2` on the L1 `EthereumMPTGatewayAdapter`. The relay transaction is sent on **L1**; +/// the destination chain head is read from the **L2** satellite. +/// +/// [`EthereumMptSatellite`]: super::EthereumMptSatellite +pub struct OpStackSatellite { + /// Human-readable name for logging (e.g. "op-stack-base"). + name: String, + /// The destination (L2) chain ID. + chain_id: u64, + /// The `EthereumMPTGatewayAdapter` on L1 (`forwardToL2` is called here). + l1_adapter: Address, + /// The `L1CrossDomainMessenger` for the destination rollup. + l1_messenger: Address, + /// The `OpStackGatewayAdapter` on the destination L2. + l2_adapter: Address, + /// The `WorldIDSatellite` (bridge) on the destination L2. + satellite: IWorldIDSatelliteInstance>, + /// L1 provider — used for dispute games and sending `forwardToL2`. + l1_provider: Arc, + /// World Chain provider for fetching MPT proofs and block data. + source_provider: Arc, + /// WorldIDSource address on World Chain. + world_id_source: Address, + /// DisputeGameFactory address on L1. + dispute_game_factory: Address, + /// The dispute game type to look for (e.g. 0 = CANNON). + game_type: u32, + /// Whether to require games to be finalized (DEFENDER_WINS) before using them. + require_finalized: bool, + /// Minimum L2 gas for the relayed `sendMessage` call. + min_gas_limit: u32, + /// How often to poll for a suitable dispute game. + poll_interval: Duration, + /// Maximum time to wait for a suitable dispute game. + timeout: Duration, +} + +impl OpStackSatellite { + /// Creates a new OP Stack satellite from an [`OpStackGatewayConfig`] and providers. + /// + /// `l2_provider` reads the destination chain head; `l1_provider` sends `forwardToL2` and + /// queries dispute games; `wc_provider` builds MPT proofs against World Chain state. + pub fn from_config( + wc_config: &WorldChainConfig, + config: &OpStackGatewayConfig, + wc_provider: Arc, + l1_provider: Arc, + l2_provider: Arc, + ) -> Self { + Self { + name: format!("op-stack-{}", config.name.to_lowercase()), + chain_id: config.destination_chain_id, + l1_adapter: config.l1_adapter, + l1_messenger: config.l1_messenger, + l2_adapter: config.l2_adapter, + satellite: IWorldIDSatelliteInstance::new(config.satellite, l2_provider), + l1_provider, + source_provider: wc_provider, + world_id_source: wc_config.world_id_source, + dispute_game_factory: config.dispute_game_factory, + game_type: config.game_type, + require_finalized: config.require_finalized, + min_gas_limit: config.min_gas_limit, + poll_interval: DEFAULT_POLL_INTERVAL, + timeout: DEFAULT_TIMEOUT, + } + } +} + +impl Satellite for OpStackSatellite { + fn name(&self) -> &str { + &self.name + } + + fn chain_id(&self) -> u64 { + self.chain_id + } + + fn remote_chain_head<'a>(&'a self) -> Pin> + Send + 'a>> { + Box::pin(async move { + let result = self.satellite.KECCAK_CHAIN().call().await?; + Ok(result.head) + }) + } + + fn build_proof<'a>( + &'a self, + commitment: &'a ChainCommitment, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + build_l1_proof_attributes( + &self.source_provider, + &self.l1_provider, + self.world_id_source, + self.dispute_game_factory, + self.game_type, + self.require_finalized, + commitment, + self.poll_interval, + self.timeout, + ) + .await + }) + } + + fn relay<'a>( + &'a self, + commitment: &'a ChainCommitment, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let (attribute, payload) = self.build_proof(commitment).await?; + send_forward_to_l2_tx( + &self.l1_provider, + self.l1_adapter, + self.l1_messenger, + self.l2_adapter, + self.chain_id, + *self.satellite.address(), + payload, + attribute, + self.min_gas_limit, + ) + .await + }) + } +}