diff --git a/CLAUDE.md b/CLAUDE.md index 2e8c760d..47f24578 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ In this repo: home key and a provider id, the addresses of one provider's transceivers. - A send and its quote are the paired operations most likely to drift. - LayerZero, CCIP, and Hyperlane price a payload per byte, so a change to a payload's size is a - cost change on those three; Wormhole's Executor and OP Stack do not. + cost change on those three; Wormhole's Executor and the two OP Stack providers do not. - Lint with `forge lint`; for files under `src/`, also run it with `FOUNDRY_PROFILE=lint-src`, which is what CI enforces there. - Run `forge build --sizes src` after a change to a deployed contract: CI fails any over diff --git a/README.md b/README.md index 94a55c94..e58808b0 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ The rest of the bill, stated plainly: to it is write-once, so a fix moves that chain's accounts. - **Governance is slow on purpose.** A registry or treasury change crosses a bridge and then waits out a timelock. -- **OP Stack stays L1 to L2.** An L2 home cannot reach other L2s over it. +- **OP Stack is two providers.** `op-stack-l1-l2` connects Ethereum and each OP Stack chain; + `op-stack-l2-l2` connects OP Stack chains through Superchain interop, and is not deployable + until interop is live. ## Three transactions @@ -315,7 +317,7 @@ Then, per destination, add what the chain needs: ## Message providers -Five native bindings live under `src/protocols/`. Each is held to +Six native bindings live under `src/protocols/`. Each is held to [`docs/provider-spec.md`](docs/provider-spec.md). Each is one transceiver per chain, with zkSync and Tron variants where the provider reaches those chains. On a transceiver, the delivery's origin names a chain and the sender must be that chain's counterpart. Where the @@ -327,7 +329,8 @@ bindings differ is in who delivers a message to an account and what authenticate | CCIP | `ccipReceive`, from the router | `GATEWAY_ROLE` and the source transmitter | the off-ramp | | Hyperlane | `handle`, from the Mailbox | `GATEWAY_ROLE`, the Mailbox's ISM, and the source transmitter | the Mailbox | | Wormhole | `executeVAAv1`, from anyone | guardian signatures through Core, the emitter, and the VAA's `(targetChain, targetAddress)` prefix | the binding's own consumed-hash set | -| OP Stack | `receiveOpStackMessage`, from the messenger | `GATEWAY_ROLE` and `xDomainMessageSender()` read during the relay | the messenger | +| OP Stack, L1 ↔ L2 | `receiveOpStackMessage`, from the messenger that reaches the account's home | `GATEWAY_ROLE` and `xDomainMessageSender()` read during the relay | the messenger | +| OP Stack, L2 ↔ L2 | `receiveOpStackMessage`, from the `L2ToL2CrossDomainMessenger` | `GATEWAY_ROLE` and `crossDomainMessageSender()` read during the relay | the messenger | What an operator or integrator has to know: @@ -358,12 +361,16 @@ What an operator or integrator has to know: contracts has to keep them. - **Wormhole delivery is permissionless.** Anyone may submit a VAA, and liveness does not depend on the Executor quoter, which is an implementation immutable. -- **OP Stack sends carry no value and cost only gas.** The quote is zero, so a send spends +- **`op-stack-l1-l2` sends carry no value and cost only gas.** The quote is zero, so a send spends nothing from the account, and the binding refuses a nonzero `value` because the messenger - would bridge it rather than spend it. `OpStackTransceiver` is one contract per L1 and OP - Stack chain pair, the same on both sides, registered as its own message provider; it - refuses recipients on any chain but its pair. #41 replaces this with one transceiver per - chain serving every pair; not yet implemented. + would bridge it rather than spend it. `op-stack-l1-l2`'s transceiver holds, per chain, + the messenger that reaches it, write-once: on Ethereum one `L1CrossDomainMessenger` per OP + Stack chain, on each OP Stack chain the `L2CrossDomainMessenger` predeploy, which reaches + only Ethereum. A delivery's origin is the chain of the messenger that called. +- **OP Stack interop carries no value and no options, and needs a relayer.** + `op-stack-l2-l2`'s messenger takes no value and no gas limit, so the quote is zero and any + attribute is refused; the relay is a separate transaction whose sender picks the gas. A send + reaches only a routed chain, which should be one in the sender's dependency set. - **Vendored provider code has no update path.** SDK files are hand-copied into `lib/`, pinned per file to a commit by `contracts/evm/script/vendor/.sh`. An upstream security fix has to be noticed, re-vendored, and diffed by hand. It then reaches no @@ -440,11 +447,12 @@ What an operator or integrator has to know: The EVM side is built and tested: account creation, the approval map, cancellation, execution, per-destination commitment schemes, both message paths end to end in-process, -and native bindings for LayerZero, CCIP, Hyperlane, Wormhole, and OP Stack. +and native bindings for LayerZero, CCIP, Hyperlane, Wormhole, and OP Stack (L1 ↔ L2, and L2 ↔ L2 +over interop). ``` git submodule update --init # forge-std, OZ, OZ-upgradeable, from the crossecute forks -cd contracts/evm && forge test # 704 passing +cd contracts/evm && forge test # 754 passing ``` CI runs the same build and tests, plus `forge fmt --check` and `forge lint`, on every pull diff --git a/contracts/evm/deploy/README.md b/contracts/evm/deploy/README.md index 6d064179..032555c5 100644 --- a/contracts/evm/deploy/README.md +++ b/contracts/evm/deploy/README.md @@ -54,8 +54,11 @@ Before mainnet, from [todo §2](../../../docs/todo.md#2-chain-checks-before-main `chain_id` and `derivation`. - [`providers/.toml`](providers): one line per provider that reaches the chain, keyed by that name. Take each id from the provider's own registry; the header of each file names - it. Leave a provider out where it does not reach the chain. OP Stack has no id table and no - file. + it. Leave a provider out where it does not reach the chain. `op-stack-l1-l2`'s ids are + messenger addresses, which depend on the chain they are read from, so its file has its own + shape: add the chain's `L1CrossDomainMessenger` under `[l1_messengers]` if it is an OP Stack + chain. `op-stack-l2-l2` has no ids; add the chain to its `chains` list once interop is live + on it. Then run `forge test`. `test/deploy/ChainConfig.t.sol` reads both and refuses a duplicate chain id, an unknown derivation, a provider key that is not a configured chain, and an id that @@ -83,15 +86,20 @@ and each provider's script (its endpoint addresses). `GATEWAYS` is fixed here: a gateways cannot be added later. Provider ids come from this directory. Before deploying, the script refuses a chain missing from `chains.toml` or not parity, a -governor's home that is not `Predetermined` from it, and a registry whose grades disagree with -this configuration. Every deployment then checks the proxy's address and lock, the +governor's home that is not `Predetermined` from it, and a registry that has not registered +the home or whose grades disagree with this configuration. Every deployment then checks the proxy's address and lock, the transceiver's configuration, and the registry's record (R8.4). Wormhole's contracts link the `WormholeMessage` library (#29). `forge script` deploys it before them, through Arachnid's factory, so it has one address on every parity chain. zkSync and Tron have deploy functions, which the tests use, but no production script: their -bytecode comes from zksolc and TRON-solc. OP Stack deploys one pair per chain (#41). +bytecode comes from zksolc and TRON-solc. + +`op-stack-l2-l2` deploys only to a chain its configuration lists, with a governor's home on an +L2 it lists too, and only where the `L2ToL2CrossDomainMessenger` predeploy has code. Where the +chain's registry was seeded with another home, register the L2 home there first. It lists none +until interop is live ([todo §5](../../../docs/todo.md#5-post-launch-superchain-interop)). ## 5. Bring it into the protocol diff --git a/contracts/evm/deploy/providers/op-stack-l1-l2.toml b/contracts/evm/deploy/providers/op-stack-l1-l2.toml new file mode 100644 index 00000000..86e8957e --- /dev/null +++ b/contracts/evm/deploy/providers/op-stack-l1-l2.toml @@ -0,0 +1,18 @@ +# op-stack-l1-l2's provider id for a chain is the address of the messenger that reaches it, +# which depends on the chain it is read from, so this file is not keyed like the others. +# Read by `OpStackConfig` in script/deploy/OpStackDeploy.sol. +# +# Sources, 2026-10-06: the predeploy from optimism's Predeploys.sol (develop @ dfe4f947); each +# L1CrossDomainMessenger from the L2's own predeploy (`OTHER_MESSENGER()`), whose answer +# names 0x4200...0007 back on Ethereum; OP Mainnet's also matches its SystemConfig. + +# The chain every OP Stack chain here settles to. +l1 = "ethereum" + +# On every OP Stack chain, the L2CrossDomainMessenger predeploy, which reaches only the L1. +l2_messenger = "0x4200000000000000000000000000000000000007" + +# On the L1, each OP Stack chain's L1CrossDomainMessenger proxy, keyed by chains.toml name. +[l1_messengers] +base = "0x866E82a600A1414e583f7F13623F1aC5d58b0Afa" +optimism = "0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1" diff --git a/contracts/evm/deploy/providers/op-stack-l2-l2.toml b/contracts/evm/deploy/providers/op-stack-l2-l2.toml new file mode 100644 index 00000000..f6732e9a --- /dev/null +++ b/contracts/evm/deploy/providers/op-stack-l2-l2.toml @@ -0,0 +1,8 @@ +# op-stack-l2-l2's chains: the OP Stack chains running Superchain interop, keyed by chains.toml +# name. Interop names a chain by its chain id, so there are no provider ids. Read by +# `OpStackInteropConfig` in script/deploy/OpStackInteropDeploy.sol. +# +# Empty until interop is live: DeployOpStackInterop refuses a chain not listed and a governor's +# home not listed, and every deploy refuses a chain whose L2ToL2CrossDomainMessenger predeploy +# has no code. List a chain once interop is live on it and its dependency set is known. +chains = [] diff --git a/contracts/evm/foundry.toml b/contracts/evm/foundry.toml index e1d538d1..685269a3 100644 --- a/contracts/evm/foundry.toml +++ b/contracts/evm/foundry.toml @@ -51,6 +51,9 @@ remappings = [ # Hand-copied, not a submodule: one import-free interface from the optimism monorepo. # See contracts/evm/script/vendor/op-stack.sh. "@optimism/=lib/optimism/packages/contracts-bedrock/", + # The interop interface imports its sibling by contracts-bedrock's own root-relative + # path; scoped to lib/optimism/ so no other file sees an `interfaces/` remap. + "lib/optimism/:interfaces/=lib/optimism/packages/contracts-bedrock/interfaces/", ] # CI runs `forge fmt --check`. Forge's defaults, which most of the tree already followed. diff --git a/contracts/evm/lib/optimism/packages/contracts-bedrock/interfaces/L2/ICrossL2Inbox.sol b/contracts/evm/lib/optimism/packages/contracts-bedrock/interfaces/L2/ICrossL2Inbox.sol new file mode 100644 index 00000000..3c79ed6b --- /dev/null +++ b/contracts/evm/lib/optimism/packages/contracts-bedrock/interfaces/L2/ICrossL2Inbox.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +// Vendored, unmodified, from ethereum-optimism/optimism @ dfe4f947ca48f872ce207c3e1e36a9256beaa044 +// (packages/contracts-bedrock/interfaces/L2/ICrossL2Inbox.sol). +// MIT, like the rest of contracts-bedrock. Imports only ICrossL2Inbox, vendored +// beside it. See docs/provider-research.md#7-op-stack-as-a-native-binding. + +pragma solidity ^0.8.0; + +/// @notice Identifier of a cross chain message. + +struct Identifier { + address origin; + uint256 blockNumber; + uint256 logIndex; + uint256 timestamp; + uint256 chainId; +} + +interface ICrossL2Inbox { + error CrossL2Inbox_NoExecutingDeposits(); + error NotInAccessList(); + error BlockNumberTooHigh(); + error TimestampTooHigh(); + error LogIndexTooHigh(); + + event ExecutingMessage(bytes32 indexed msgHash, Identifier id); + + function version() external view returns (string memory); + + function validateMessage(Identifier calldata _id, bytes32 _msgHash) external; + + function calculateChecksum(Identifier memory _id, bytes32 _msgHash) external pure returns (bytes32 checksum_); +} diff --git a/contracts/evm/lib/optimism/packages/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol b/contracts/evm/lib/optimism/packages/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol new file mode 100644 index 00000000..02212640 --- /dev/null +++ b/contracts/evm/lib/optimism/packages/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT + +// Vendored, unmodified, from ethereum-optimism/optimism @ dfe4f947ca48f872ce207c3e1e36a9256beaa044 +// (packages/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol). +// MIT, like the rest of contracts-bedrock. Imports only ICrossL2Inbox, vendored +// beside it. See docs/provider-research.md#7-op-stack-as-a-native-binding. + +pragma solidity ^0.8.0; + +import { Identifier } from "interfaces/L2/ICrossL2Inbox.sol"; + +/// @title IL2ToL2CrossDomainMessenger +/// @notice Interface for the L2ToL2CrossDomainMessenger contract. +interface IL2ToL2CrossDomainMessenger { + /// @notice Thrown when a non-written slot in transient storage is attempted to be read from. + error NotEntered(); + + /// @notice Thrown when attempting to relay a message where payload origin is not L2ToL2CrossDomainMessenger. + error IdOriginNotL2ToL2CrossDomainMessenger(); + + /// @notice Thrown when the payload provided to the relay is not a SentMessage event. + error EventPayloadNotSentMessage(); + + /// @notice Thrown when attempting to send a message to the chain that the message is being sent from. + error MessageDestinationSameChain(); + + /// @notice Thrown when attempting to relay a message whose destination chain is not the chain relaying it. + error MessageDestinationNotRelayChain(); + + /// @notice Thrown when attempting to relay a message whose target is L2ToL2CrossDomainMessenger. + error MessageTargetL2ToL2CrossDomainMessenger(); + + /// @notice Thrown when attempting to relay a message that has already been relayed. + error MessageAlreadyRelayed(); + + /// @notice Thrown when a reentrant call is detected. + error ReentrantCall(); + + /// @notice Thrown when the provided message parameters do not match any hash of a previously sent message. + error InvalidMessage(); + + /// @notice Emitted whenever a message is sent to a destination + /// @param destination Chain ID of the destination chain. + /// @param target Target contract or wallet address. + /// @param messageNonce Nonce associated with the message sent + /// @param sender Address initiating this message call + /// @param message Message payload to call target with. + event SentMessage( + uint256 indexed destination, address indexed target, uint256 indexed messageNonce, address sender, bytes message + ); + + /// @notice Emitted whenever a message is successfully relayed on this chain. + /// @param source Chain ID of the source chain. + /// @param messageNonce Nonce associated with the message sent + /// @param messageHash Hash of the message that was relayed. + /// @param returnDataHash Hash of the return data from the message that was relayed. + event RelayedMessage( + uint256 indexed source, uint256 indexed messageNonce, bytes32 indexed messageHash, bytes32 returnDataHash + ); + + function version() external view returns (string memory); + + /// @notice Mapping of message hashes to boolean receipt values. Note that a message will only + /// be present in this mapping if it has successfully been relayed on this chain, and + /// can therefore not be relayed again. + /// @return Returns true if the message corresponding to the `_msgHash` was successfully relayed. + function successfulMessages(bytes32) external view returns (bool); + + /// @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. + /// @return Nonce of the next message to be sent, with added message version. + function messageNonce() external view returns (uint256); + + /// @notice Mapping of message nonces to message hashes. Note that a message will only be present in this + /// mapping if it has been sent from this chain to a destination chain. + function sentMessages(uint256) external view returns (bytes32); + + /// @notice Retrieves the sender of the current cross domain message. + /// @return sender_ Address of the sender of the current cross domain message. + function crossDomainMessageSender() external view returns (address sender_); + + /// @notice Retrieves the source of the current cross domain message. + /// @return source_ Chain ID of the source of the current cross domain message. + function crossDomainMessageSource() external view returns (uint256 source_); + + /// @notice Retrieves the context of the current cross domain message. If not entered, reverts. + /// @return sender_ Address of the sender of the current cross domain message. + /// @return source_ Chain ID of the source of the current cross domain message. + function crossDomainMessageContext() external view returns (address sender_, uint256 source_); + + /// @notice Sends a message to some target address on a destination chain. The destination chain + /// must differ from the current chain, and the target cannot be the + /// L2ToL2CrossDomainMessenger predeploy. This function is not payable, so no ETH can be + /// sent with the message. If the relayed call reverts on the destination chain, the relay + /// transaction reverts and the message can be relayed again later, so it is never stuck. + /// @param _destination Chain ID of the destination chain. + /// @param _target Target contract or wallet address. + /// @param _message Message to trigger the target address with. + /// @return messageHash_ The hash of the message being sent, used to track whether the message + /// has successfully been relayed. + function sendMessage( + uint256 _destination, + address _target, + bytes calldata _message + ) + external + returns (bytes32 messageHash_); + + /// @notice Re-emits a previously sent message event for old messages that haven't been + /// relayed yet, allowing offchain infrastructure to pick them up and relay them. + /// @dev Emitting a message that has already been relayed will have no effect, as it is only + /// relayed once on the destination chain. + /// @param _destination Chain ID of the destination chain. + /// @param _nonce Nonce of the message sent + /// @param _sender Address that sent the message + /// @param _target Target contract or wallet address. + /// @param _message Message payload to call target with. + /// @return messageHash_ The hash of the message being re-sent. + function resendMessage( + uint256 _destination, + uint256 _nonce, + address _sender, + address _target, + bytes calldata _message + ) + external + returns (bytes32 messageHash_); + + /// @notice Relays a message that was sent by the other CrossDomainMessenger contract. Can only + /// be executed via cross-chain call from the other messenger OR if the message was + /// already received once and is currently being replayed. + /// @dev If the target has no code when relayed (e.g. an EOA or a not-yet-deployed address), + /// the call is a no-op that still succeeds, permanently consuming the message; it cannot + /// be relayed again once the target is later deployed. + /// @param _id Identifier of the SentMessage event to be relayed + /// @param _sentMessage Message payload of the `SentMessage` event + /// @return returnData_ Return data from the target contract call. + function relayMessage( + Identifier calldata _id, + bytes calldata _sentMessage + ) + external + payable + returns (bytes memory returnData_); + + function messageVersion() external view returns (uint16); + + function __constructor__() external; +} diff --git a/contracts/evm/script/DeployOpStack.s.sol b/contracts/evm/script/DeployOpStack.s.sol index 83246b5d..bc1deef3 100644 --- a/contracts/evm/script/DeployOpStack.s.sol +++ b/contracts/evm/script/DeployOpStack.s.sol @@ -2,31 +2,40 @@ pragma solidity ^0.8.20; import {DeployProvider} from "script/DeployProvider.s.sol"; -import {OpStackDeploy} from "script/deploy/OpStackDeploy.sol"; +import {OpStackDeploy, OpStackConfig} from "script/deploy/OpStackDeploy.sol"; import {TransceiverDeployment} from "script/deploy/TransceiverDeploy.sol"; -import {ChainKey} from "src/addressing/ChainKey.sol"; +import {ChainConfig, ChainEntry} from "script/deploy/ChainConfig.sol"; +import {check} from "script/deploy/CrossProxyDeploy.sol"; -/// @notice `OP_STACK_MESSENGER`, and `OP_STACK_PAIRED_CHAIN_ID`: the chain at the other end of -/// that messenger. See `DeployProvider`. +/// @notice `op-stack-l1-l2`, between Ethereum and each OP Stack chain. No environment inputs of +/// its own: the messenger that reaches the governor's home is read from +/// `deploy/providers/op-stack-l1-l2.toml`. See `DeployProvider`. contract DeployOpStack is DeployProvider { function _providerName() internal pure override returns (string memory) { - return "op-stack"; + return "op-stack-l1-l2"; } function _implementations() internal override returns (address, address, address) { - address messenger = vm.envAddress("OP_STACK_MESSENGER"); return ( - OpStackDeploy.receiverImplementation(messenger), + OpStackDeploy.receiverImplementation(), OpStackDeploy.transmitterImplementation(), - OpStackDeploy.transceiverImplementation(messenger, _paired()) + OpStackDeploy.transceiverImplementation() ); } function _deploy(TransceiverDeployment memory d) internal override returns (address) { - return OpStackDeploy.transceiver(d, vm.envAddress("OP_STACK_MESSENGER"), _paired()); + return OpStackDeploy.transceiver(d, _homeMessenger()); } - function _paired() internal view returns (bytes32) { - return ChainKey.forEvm(vm.envUint("OP_STACK_PAIRED_CHAIN_ID")); + /// @notice The messenger on this chain that reaches the governor's home. Required unless + /// this chain is the home. + function _homeMessenger() internal view returns (address m) { + string memory dir = ChainConfig.defaultDir(); + ChainEntry[] memory cs = ChainConfig.chains(dir); + uint256 home = vm.envUint("GOVERNOR_HOME_CHAIN_ID"); + m = OpStackConfig.messenger( + dir, cs, ChainConfig.chainWithId(cs, block.chainid), ChainConfig.chainWithId(cs, home) + ); + if (home != block.chainid) check(m != address(0), "a messenger here reaches the governor's home"); } } diff --git a/contracts/evm/script/DeployOpStackInterop.s.sol b/contracts/evm/script/DeployOpStackInterop.s.sol new file mode 100644 index 00000000..76dc9372 --- /dev/null +++ b/contracts/evm/script/DeployOpStackInterop.s.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {DeployProvider} from "script/DeployProvider.s.sol"; +import {OpStackInteropDeploy, OpStackInteropConfig} from "script/deploy/OpStackInteropDeploy.sol"; +import {TransceiverDeployment} from "script/deploy/TransceiverDeploy.sol"; +import {ChainConfig, ChainEntry} from "script/deploy/ChainConfig.sol"; +import {check} from "script/deploy/CrossProxyDeploy.sol"; + +/// @notice `op-stack-l2-l2`, between OP Stack chains running Superchain interop. No environment +/// inputs of its own. Refuses a chain not in `deploy/providers/op-stack-l2-l2.toml`, and +/// a governor's home not in it either: a transceiver is born accepting only a bootstrap +/// from the home, which has to reach it over this provider. See `DeployProvider`. +contract DeployOpStackInterop is DeployProvider { + function _providerName() internal pure override returns (string memory) { + return "op-stack-l2-l2"; + } + + function _implementations() internal override returns (address, address, address) { + return ( + OpStackInteropDeploy.receiverImplementation(), + OpStackInteropDeploy.transmitterImplementation(), + OpStackInteropDeploy.transceiverImplementation() + ); + } + + function _deploy(TransceiverDeployment memory d) internal override returns (address) { + string memory dir = ChainConfig.defaultDir(); + ChainEntry[] memory cs = ChainConfig.chains(dir); + ChainEntry memory local = ChainConfig.chainWithId(cs, block.chainid); + check(OpStackInteropConfig.isMember(dir, cs, local), "this chain runs op-stack-l2-l2"); + uint256 home = vm.envUint("GOVERNOR_HOME_CHAIN_ID"); + if (home != block.chainid) { + check( + OpStackInteropConfig.isMember(dir, cs, ChainConfig.chainWithId(cs, home)), + "the governor's home reaches this chain over op-stack-l2-l2" + ); + } + return OpStackInteropDeploy.transceiver(d); + } +} diff --git a/contracts/evm/script/DeployProvider.s.sol b/contracts/evm/script/DeployProvider.s.sol index 502a39ec..2161d81a 100644 --- a/contracts/evm/script/DeployProvider.s.sol +++ b/contracts/evm/script/DeployProvider.s.sol @@ -6,6 +6,7 @@ import {TransceiverConfig} from "src/messaging/transceiver/TransceiverBase.sol"; import {IChainRegistryRefs, ProviderDeployment} from "src/registry/IChainRegistryRefs.sol"; import {Provenance} from "src/registry/Provenance.sol"; import {Erc7930} from "src/addressing/Erc7930.sol"; +import {ChainKey} from "src/addressing/ChainKey.sol"; import {check} from "script/deploy/CrossProxyDeploy.sol"; import {TransceiverDeployment} from "script/deploy/TransceiverDeploy.sol"; import {ChainConfig, ChainEntry, Derivation} from "script/deploy/ChainConfig.sol"; @@ -80,13 +81,18 @@ abstract contract DeployProvider is Script { /// @notice This chain and the governor's home are configured, this chain is a parity chain /// (zkSync and Tron are built by other compilers), the home is `Predetermined` from - /// here as birth requires, and the registry's grades agree with `deploy/`. + /// here as birth requires, the registry has the home, and its grades agree with + /// `deploy/`. function _checkChains(ChainRegistry registry) internal view { ChainEntry[] memory cs = ChainConfig.chains(ChainConfig.defaultDir()); ChainEntry memory local = ChainConfig.chainWithId(cs, block.chainid); check(local.derivation == Derivation.Parity, "this chain is a parity chain"); ChainEntry memory home = ChainConfig.chainWithId(cs, vm.envUint("GOVERNOR_HOME_CHAIN_ID")); check(ChainConfig.gradeOf(local, home) == Provenance.Predetermined, "the governor's home is Predetermined"); + // The initializer would revert `UnknownChainKey` without saying which chain is missing. + if (home.chainId != block.chainid) { + check(registry.hasChainKey(ChainKey.forEvm(home.chainId)), "the registry has the governor's home"); + } ChainConfig.checkRegistryGrades(registry, cs, local); } diff --git a/contracts/evm/script/deploy/ChainConfig.sol b/contracts/evm/script/deploy/ChainConfig.sol index 03ca6d55..6f8f2c9b 100644 --- a/contracts/evm/script/deploy/ChainConfig.sol +++ b/contracts/evm/script/deploy/ChainConfig.sol @@ -30,7 +30,7 @@ library ChainConfig { /// @notice Every configured chain, with unique nonzero chain ids and a known derivation. function chains(string memory dir) internal view returns (ChainEntry[] memory cs) { - string memory toml = _read(string.concat(dir, "/chains.toml")); + string memory toml = read(string.concat(dir, "/chains.toml")); string[] memory names = VM.parseTomlKeys(toml, ".chains"); cs = new ChainEntry[](names.length); for (uint256 i; i < names.length; ++i) { @@ -62,13 +62,13 @@ library ChainConfig { view returns (uint256 id) { - string memory toml = _read(string.concat(dir, "/providers/", provider, ".toml")); + string memory toml = read(string.concat(dir, "/providers/", provider, ".toml")); uint256 bits = VM.parseTomlUint(toml, ".id_bits"); check(bits != 0 && bits <= 256, "id_bits is a width"); string[] memory names = VM.parseTomlKeys(toml, ".ids"); uint256[] memory ids = new uint256[](names.length); for (uint256 i; i < names.length; ++i) { - check(_hasName(cs, names[i]), "provider ids are keyed by chains.toml names"); + check(hasChainNamed(cs, names[i]), "provider ids are keyed by chains.toml names"); ids[i] = VM.parseTomlUint(toml, string.concat(".ids.", names[i])); check(ids[i] != 0, "provider id is nonzero"); check(bits == 256 || ids[i] >> bits == 0, "provider id fits id_bits"); @@ -106,7 +106,7 @@ library ChainConfig { } /// @dev `fs_permissions` in foundry.toml limits reads to `deploy/` and the test configs. - function _read(string memory path) private view returns (string memory) { + function read(string memory path) internal view returns (string memory) { // forge-lint: disable-next-line(unsafe-cheatcode) reads the configuration, nothing else return VM.readFile(path); } @@ -120,7 +120,7 @@ library ChainConfig { revert DeployCheck("derivation is parity, zksync, tron, or other"); } - function _hasName(ChainEntry[] memory cs, string memory name) private pure returns (bool) { + function hasChainNamed(ChainEntry[] memory cs, string memory name) internal pure returns (bool) { for (uint256 i; i < cs.length; ++i) { if (keccak256(bytes(cs[i].name)) == keccak256(bytes(name))) return true; } diff --git a/contracts/evm/script/deploy/OpStackDeploy.sol b/contracts/evm/script/deploy/OpStackDeploy.sol index 64f35d24..3af92a78 100644 --- a/contracts/evm/script/deploy/OpStackDeploy.sol +++ b/contracts/evm/script/deploy/OpStackDeploy.sol @@ -4,32 +4,72 @@ pragma solidity ^0.8.20; import {OpStackTransceiver} from "src/protocols/op-stack/OpStackTransceiver.sol"; import {OpStackReceiver} from "src/protocols/op-stack/OpStackReceiver.sol"; import {OpStackTransmitter} from "src/protocols/op-stack/OpStackTransmitter.sol"; -import {IOpStackMessengerSource} from "src/protocols/op-stack/OpStackTransmitter.sol"; -import {check} from "script/deploy/CrossProxyDeploy.sol"; -import {TransceiverDeployment, deployTransceiverProxy} from "script/deploy/TransceiverDeploy.sol"; +import {TransceiverDeployment, deployTransceiverProxy, checkGovernorHomeId} from "script/deploy/TransceiverDeploy.sol"; +import {VM, check} from "script/deploy/CrossProxyDeploy.sol"; +import {ChainConfig, ChainEntry} from "script/deploy/ChainConfig.sol"; -/// @notice OP Stack's contracts. The messenger is the gateway, and the chain it pairs with is -/// an implementation immutable, so a transceiver serves exactly one pair. +/// @notice `op-stack-l1-l2`'s contracts. Each chain's messenger is its provider id, so nothing +/// provider-specific is an implementation immutable and no gateway role is granted: +/// the transceiver's table is the gate. library OpStackDeploy { - function receiverImplementation(address messenger) internal returns (address) { - return address(new OpStackReceiver(messenger)); + function receiverImplementation() internal returns (address) { + return address(new OpStackReceiver()); } - /// @dev Reads its messenger from the transceiver that creates it. function transmitterImplementation() internal returns (address) { return address(new OpStackTransmitter()); } - function transceiverImplementation(address messenger, bytes32 pairedChainKey) internal returns (address) { - return address(new OpStackTransceiver(messenger, pairedChainKey)); + function transceiverImplementation() internal returns (address) { + return address(new OpStackTransceiver()); } - function transceiver(TransceiverDeployment memory d, address messenger, bytes32 pairedChainKey) + /// @param homeMessenger The messenger on this chain that reaches the governor's home. + function transceiver(TransceiverDeployment memory d, address homeMessenger) internal returns (address t) { + t = deployTransceiverProxy( + d, abi.encodeCall(OpStackTransceiver.initialize, (d.config, homeMessenger)), address(0) + ); + checkGovernorHomeId(t, d.config, uint160(homeMessenger)); + } +} + +/// @notice Reads `deploy/providers/op-stack-l1-l2.toml`, checking it as it goes: the L1 and every +/// key name a configured chain, and every messenger is nonzero and distinct. +library OpStackConfig { + /// @notice The messenger on `local` that reaches `remote`, or zero where none does: the L1 + /// reaches each listed OP Stack chain through its own messenger, and each reaches + /// only the L1, through the predeploy. + function messenger(string memory dir, ChainEntry[] memory cs, ChainEntry memory local, ChainEntry memory remote) internal - returns (address t) + view + returns (address) { - t = deployTransceiverProxy(d, abi.encodeCall(OpStackTransceiver.initialize, (d.config)), messenger); - check(IOpStackMessengerSource(t).messenger() == messenger, "messenger"); - check(IOpStackMessengerSource(t).messengerChainKey() == pairedChainKey, "paired chain"); + string memory toml = ChainConfig.read(string.concat(dir, "/providers/op-stack-l1-l2.toml")); + string memory l1 = VM.parseTomlString(toml, ".l1"); + check(ChainConfig.hasChainNamed(cs, l1), "the OP Stack L1 is a configured chain"); + address l2Messenger = VM.parseTomlAddress(toml, ".l2_messenger"); + check(l2Messenger != address(0), "the L2 messenger is nonzero"); + + string[] memory l2s = VM.parseTomlKeys(toml, ".l1_messengers"); + address[] memory l1Messengers = new address[](l2s.length); + address found; + bool localIsL2; + for (uint256 i; i < l2s.length; ++i) { + check(ChainConfig.hasChainNamed(cs, l2s[i]), "OP Stack chains are keyed by chains.toml names"); + check(!_eq(l2s[i], l1), "the L1 is not an OP Stack chain of itself"); + l1Messengers[i] = VM.parseTomlAddress(toml, string.concat(".l1_messengers.", l2s[i])); + check(l1Messengers[i] != address(0) && l1Messengers[i] != l2Messenger, "an L1 messenger is its own"); + for (uint256 j; j < i; ++j) { + check(l1Messengers[j] != l1Messengers[i], "each OP Stack chain has its own L1 messenger"); + } + if (_eq(local.name, l1) && _eq(remote.name, l2s[i])) found = l1Messengers[i]; + if (_eq(local.name, l2s[i])) localIsL2 = true; + } + if (localIsL2 && _eq(remote.name, l1)) found = l2Messenger; + return found; + } + + function _eq(string memory a, string memory b) private pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); } } diff --git a/contracts/evm/script/deploy/OpStackInteropDeploy.sol b/contracts/evm/script/deploy/OpStackInteropDeploy.sol new file mode 100644 index 00000000..358a8282 --- /dev/null +++ b/contracts/evm/script/deploy/OpStackInteropDeploy.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {OpStackInteropTransceiver} from "src/protocols/op-stack/OpStackInteropTransceiver.sol"; +import {OpStackInteropReceiver} from "src/protocols/op-stack/OpStackInteropReceiver.sol"; +import {OpStackInteropTransmitter} from "src/protocols/op-stack/OpStackInteropTransmitter.sol"; +import {OpStackInteropMessage} from "src/protocols/op-stack/OpStackInteropMessage.sol"; +import {VM, check} from "script/deploy/CrossProxyDeploy.sol"; +import {ChainConfig, ChainEntry, Derivation} from "script/deploy/ChainConfig.sol"; +import {TransceiverDeployment, deployTransceiverProxy} from "script/deploy/TransceiverDeploy.sol"; + +/// @notice `op-stack-l2-l2`'s contracts. The messenger is a predeploy, so nothing provider-specific +/// is an argument, and it is the gateway. +library OpStackInteropDeploy { + function receiverImplementation() internal returns (address) { + return address(new OpStackInteropReceiver()); + } + + function transmitterImplementation() internal returns (address) { + return address(new OpStackInteropTransmitter()); + } + + function transceiverImplementation() internal returns (address) { + return address(new OpStackInteropTransceiver()); + } + + /// @dev Refused where interop is not live: the transceiver's only gateway would be an + /// empty address, and its receivers' too. + function transceiver(TransceiverDeployment memory d) internal returns (address) { + check(OpStackInteropMessage.MESSENGER.code.length != 0, "the L2ToL2CrossDomainMessenger is live here"); + return deployTransceiverProxy( + d, abi.encodeCall(OpStackInteropTransceiver.initialize, (d.config)), OpStackInteropMessage.MESSENGER + ); + } +} + +/// @notice Reads `deploy/providers/op-stack-l2-l2.toml`, checking that every listed chain is a +/// configured parity chain. +library OpStackInteropConfig { + function isMember(string memory dir, ChainEntry[] memory cs, ChainEntry memory chain) + internal + view + returns (bool member) + { + string memory toml = ChainConfig.read(string.concat(dir, "/providers/op-stack-l2-l2.toml")); + string[] memory names = VM.parseTomlStringArray(toml, ".chains"); + for (uint256 i; i < names.length; ++i) { + check(ChainConfig.hasChainNamed(cs, names[i]), "interop chains are keyed by chains.toml names"); + check(_derivation(cs, names[i]) == Derivation.Parity, "an interop chain is a parity chain"); + if (keccak256(bytes(names[i])) == keccak256(bytes(chain.name))) member = true; + } + } + + function _derivation(ChainEntry[] memory cs, string memory name) private pure returns (Derivation) { + for (uint256 i; i < cs.length; ++i) { + if (keccak256(bytes(cs[i].name)) == keccak256(bytes(name))) return cs[i].derivation; + } + return Derivation.Other; + } +} diff --git a/contracts/evm/script/vendor/op-stack.sh b/contracts/evm/script/vendor/op-stack.sh index f83f2512..562350da 100755 --- a/contracts/evm/script/vendor/op-stack.sh +++ b/contracts/evm/script/vendor/op-stack.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Re-vendors the OP Stack messenger interface this binding depends on. +# Re-vendors the OP Stack messenger interfaces the two OP Stack bindings depend on: +# `CrossDomainMessenger` (op-stack-l1-l2) and `L2ToL2CrossDomainMessenger` (op-stack-l2-l2). # # Usage: contracts/evm/script/vendor/op-stack.sh # @@ -16,3 +17,11 @@ NOTE=$'MIT, like the rest of contracts-bedrock. No imports. See\n// docs/provide P="packages/contracts-bedrock/interfaces/universal/ICrossDomainMessenger.sol" vendor_file "$REPO" "$COMMIT" "$P" "contracts/evm/lib/optimism/$P" "$NOTE" + +# Superchain interop, read later than the messenger above, so pinned separately. +INTEROP_COMMIT="dfe4f947ca48f872ce207c3e1e36a9256beaa044" +INTEROP_NOTE=$'MIT, like the rest of contracts-bedrock. Imports only ICrossL2Inbox, vendored\n// beside it. See docs/provider-research.md#7-op-stack-as-a-native-binding.' +for P in packages/contracts-bedrock/interfaces/L2/ICrossL2Inbox.sol \ + packages/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol; do + vendor_file "$REPO" "$INTEROP_COMMIT" "$P" "contracts/evm/lib/optimism/$P" "$INTEROP_NOTE" +done diff --git a/contracts/evm/src/addressing/Erc7930.sol b/contracts/evm/src/addressing/Erc7930.sol index 370deb4b..b8a0e78a 100644 --- a/contracts/evm/src/addressing/Erc7930.sol +++ b/contracts/evm/src/addressing/Erc7930.sol @@ -132,6 +132,15 @@ library Erc7930 { } } + /// @notice The EVM chain id an `eip155` address names; `encodeEvmChain`'s inverse. + /// @dev `parseStrict` has already bounded the reference to a minimal 1 to 32 bytes. + function evmChainId(Interop memory io) internal pure returns (uint256 id) { + if (io.chainType != CT_EIP155 || io.chainRef.length == 0) revert NotEvm(); + for (uint256 i; i < io.chainRef.length; ++i) { + id = (id << 8) | uint8(io.chainRef[i]); + } + } + /// @notice The Chain Identifier form of an interop address: same chain, no account. /// @dev This is what makes `chainKey` stable across every address on a chain. function toChainIdentifier(bytes memory raw) internal pure returns (bytes memory) { diff --git a/contracts/evm/src/protocols/ProviderAttribute.sol b/contracts/evm/src/protocols/ProviderAttribute.sol index ff2a18a1..a9dfacaf 100644 --- a/contracts/evm/src/protocols/ProviderAttribute.sol +++ b/contracts/evm/src/protocols/ProviderAttribute.sol @@ -51,6 +51,11 @@ library ProviderAttribute { if (attributes.length > 1) revert UnsupportedAttribute(attributes[1]); } + /// @notice For a binding that supports no attribute: the first given is refused. + function none(bytes[] memory attributes) internal pure { + if (attributes.length != 0) revert UnsupportedAttribute(attributes[0]); + } + /// @dev Callers check `attribute.length >= 4` first. function _selectorOf(bytes memory attribute) private pure returns (bytes4 selector) { assembly { diff --git a/contracts/evm/src/protocols/ProviderChainId.sol b/contracts/evm/src/protocols/ProviderChainId.sol index 3fc3022c..eb5ac63e 100644 --- a/contracts/evm/src/protocols/ProviderChainId.sol +++ b/contracts/evm/src/protocols/ProviderChainId.sol @@ -24,8 +24,8 @@ function providerIdOf(address transceiver, bytes memory recipient) view returns /// the chain by the recipient (see `OutboundBase`'s note on the route slot). Only the /// native bindings under `protocols/` use it. /// -/// @dev On every binding transceiver whose provider names chains by its own id. OP Stack's -/// does not: each messenger reaches one chain. +/// @dev On every binding transceiver whose provider names chains by its own id. For +/// `op-stack-l1-l2` the id is the address of the messenger that reaches the chain. /// /// @dev Storage is `uint256` so one mapping backs every provider's narrower id type; each /// binding's typed setter (`setEid`, `setSelector`, `setDomain`, `setWormholeChain`) diff --git a/contracts/evm/src/protocols/op-stack/OpStackInteropMessage.sol b/contracts/evm/src/protocols/op-stack/OpStackInteropMessage.sol new file mode 100644 index 00000000..f409c9ae --- /dev/null +++ b/contracts/evm/src/protocols/op-stack/OpStackInteropMessage.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IL2ToL2CrossDomainMessenger} from "@optimism/interfaces/L2/IL2ToL2CrossDomainMessenger.sol"; +import {Erc7930} from "src/addressing/Erc7930.sol"; +import {IAccountTransceiver} from "src/messaging/outbound/TransmitterBase.sol"; +import {ProviderAttribute} from "src/protocols/ProviderAttribute.sol"; +import {ProviderAddress} from "src/protocols/ProviderAddress.sol"; +import {OpStackMessage, IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; + +/// @notice Send and inbound-context logic shared by every `op-stack-l2-l2` contract, over the +/// Superchain interop `L2ToL2CrossDomainMessenger`. Its contracts take messages at +/// `op-stack-l1-l2`'s entry point, `IOpStackRecipient`, and refuse value with its error. +/// +/// @dev No fee and no options: `sendMessage` is not payable and takes no gas limit, since the +/// relay is a separate transaction whose sender chooses the gas. Delivery therefore depends +/// on a relayer. The messenger relays a message once, and a reverted relay can be retried. +/// A target with no code consumes a message as a no-op, which every target here avoids: +/// a send goes to a transceiver or to a receiver that already exists. +library OpStackInteropMessage { + /// @notice The predeploy, at one address on every OP Stack chain that runs interop. + address internal constant MESSENGER = 0x4200000000000000000000000000000000000023; + + error InteropToThisChain(); + + /// @param routes The transceiver, whose `routeTo` reverts for a chain it has no route to: + /// with no id table, the route is what limits a send to the configured chains. + /// @return Zero, ERC-7786's "sent" (see `ProviderSendSpec`); the messenger's message hash + /// is in its `SentMessage` event. + function send( + address routes, + bytes memory recipient, + bytes memory payload, + bytes[] memory attributes, + uint256 value + ) internal returns (bytes32) { + (uint256 destination, address target) = _check(routes, recipient, attributes, value); + bytes memory message = abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (payload)); + // forge-lint: disable-next-line(unused-return) the hash is in the messenger's event; sends return 0 + IL2ToL2CrossDomainMessenger(MESSENGER).sendMessage(destination, target, message); + return bytes32(0); + } + + /// @dev Zero, after the same checks `send` applies, so it reverts wherever the send would. + function quote(address routes, bytes memory recipient, bytes[] memory attributes) internal view returns (uint256) { + _check(routes, recipient, attributes, 0); + return 0; + } + + /// @notice The origin-chain sender and chain of the message being relayed. + /// @dev The only authenticated origin: `sendMessage` is permissionless, so every byte of + /// the delivered calldata was chosen by whoever sent it. Read from the caller, which + /// the entry point has already required to hold `GATEWAY_ROLE`. + function context() internal view returns (address sender, uint256 source) { + return IL2ToL2CrossDomainMessenger(msg.sender).crossDomainMessageContext(); + } + + function _check(address routes, bytes memory recipient, bytes[] memory attributes, uint256 value) + private + view + returns (uint256 destination, address target) + { + if (value != 0) revert OpStackMessage.OpStackValueNotSupported(value); + ProviderAttribute.none(attributes); + // forge-lint: disable-next-line(unused-return) called for its revert on an unrouted chain + IAccountTransceiver(routes).routeTo(Erc7930.chainKey(recipient)); + destination = Erc7930.evmChainId(Erc7930.parseStrict(recipient)); + if (destination == block.chainid) revert InteropToThisChain(); + target = ProviderAddress.evmRecipient(recipient); + } +} diff --git a/contracts/evm/src/protocols/op-stack/OpStackInteropReceiver.sol b/contracts/evm/src/protocols/op-stack/OpStackInteropReceiver.sol new file mode 100644 index 00000000..1f3d4c04 --- /dev/null +++ b/contracts/evm/src/protocols/op-stack/OpStackInteropReceiver.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Call} from "src/messaging/Call.sol"; +import {ReceiverBase} from "src/messaging/inbound/ReceiverBase.sol"; +import {OpStackInteropMessage} from "src/protocols/op-stack/OpStackInteropMessage.sol"; +import {IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; + +/// @notice Per-user account on a non-home chain, for `op-stack-l2-l2`. +/// @dev No origin-chain check, as on every receiver here: the sender must be the account's +/// own transmitter, whose address on any other chain in the dependency set is this +/// account's receiver, driven by the same owner. +contract OpStackInteropReceiver is ReceiverBase, IOpStackRecipient { + /// @dev `grantRole` is `onlyInitializing`, so this initializer is the only window the + /// messenger's gateway grant ever gets. + function initialize(address sourceTransmitter_, Call[] calldata calls) external override initializer { + grantRole(GATEWAY_ROLE, OpStackInteropMessage.MESSENGER); + __ReceiverBase_init(sourceTransmitter_, calls); + } + + /// @dev The sender is `crossDomainMessageSender()`, never anything in `payload`. No R3.3 + /// exception; `_onMessageFrom` is the only sender check. + function receiveOpStackMessage(bytes calldata payload) external override onlyRole(GATEWAY_ROLE) { + (address sender,) = OpStackInteropMessage.context(); + _onMessageFrom(sender, payload); + } +} diff --git a/contracts/evm/src/protocols/op-stack/OpStackInteropTransceiver.sol b/contracts/evm/src/protocols/op-stack/OpStackInteropTransceiver.sol new file mode 100644 index 00000000..eb87fdea --- /dev/null +++ b/contracts/evm/src/protocols/op-stack/OpStackInteropTransceiver.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {TransceiverBase, TransceiverConfig} from "src/messaging/transceiver/TransceiverBase.sol"; +import {ChainKey} from "src/addressing/ChainKey.sol"; +import {OpStackInteropMessage} from "src/protocols/op-stack/OpStackInteropMessage.sol"; +import {IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; + +/// @notice Superchain interop on `TransceiverBase`: the `op-stack-l2-l2` provider, between OP +/// Stack chains. Ethereum to an OP Stack chain is `op-stack-l1-l2`. +/// +/// @dev One messenger, the predeploy, reaches every chain in this chain's dependency set and +/// names a destination by its chain id, which the recipient carries, so there is no +/// provider id table. A route is configured only for a chain in the dependency set, and +/// every send and quote, here and on the transmitters, reverts `NoRouteFor` anywhere +/// else (`OpStackInteropMessage`). +/// +/// @dev A delivery's origin is the source chain the messenger reports, never anything in the +/// message, and it must be routed here. +/// +/// @dev No zkSync or Tron variant: an OP Stack chain uses Ethereum's CREATE2 formula. +contract OpStackInteropTransceiver is TransceiverBase, IOpStackRecipient { + /// @dev Grants `GATEWAY_ROLE` to the messenger: `receiveOpStackMessage` is gated on it. + function initialize(TransceiverConfig memory c) external initializer { + grantRole(GATEWAY_ROLE, OpStackInteropMessage.MESSENGER); + __TransceiverBase_init(c); + } + + /* ===================================== sending ===================================== */ + + function _sendMessage(bytes memory recipient, bytes memory payload, bytes[] memory attributes, uint256 value) + internal + override + returns (bytes32) + { + return OpStackInteropMessage.send(address(this), recipient, payload, attributes, value); + } + + /// @dev Zero: see `OpStackInteropMessage`. + function _quoteMessage(bytes memory recipient, bytes memory, bytes[] memory attributes) + internal + view + override + returns (uint256 nativeFee) + { + return OpStackInteropMessage.quote(address(this), recipient, attributes); + } + + /* ==================================== receiving ==================================== */ + + function receiveOpStackMessage(bytes calldata payload) external override onlyRole(GATEWAY_ROLE) { + (address sender, uint256 source) = OpStackInteropMessage.context(); + _onInbound(routeFor(ChainKey.forEvm(source)), abi.encodePacked(sender), payload); + } +} diff --git a/contracts/evm/src/protocols/op-stack/OpStackInteropTransmitter.sol b/contracts/evm/src/protocols/op-stack/OpStackInteropTransmitter.sol new file mode 100644 index 00000000..8c3b4f95 --- /dev/null +++ b/contracts/evm/src/protocols/op-stack/OpStackInteropTransmitter.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {OwnableTransmitter} from "src/messaging/outbound/OwnableTransmitter.sol"; +import {OpStackInteropMessage} from "src/protocols/op-stack/OpStackInteropMessage.sol"; + +/// @notice Per-user transmitter for `op-stack-l2-l2`, created by +/// `TransceiverBase.createTransmitter`. +/// @dev Sender-only: no `receiveOpStackMessage`, so R3.1 is answered by absence rather than a +/// guard. +contract OpStackInteropTransmitter is OwnableTransmitter { + function _sendMessage(bytes memory recipient, bytes memory payload, bytes[] memory attributes, uint256 value) + internal + override + returns (bytes32 sendId) + { + return OpStackInteropMessage.send(transceiver, recipient, payload, attributes, value); + } + + /// @dev Zero: see `OpStackInteropMessage`. + function _quoteMessage(bytes memory recipient, bytes memory, bytes[] memory attributes) + internal + view + override + returns (uint256 nativeFee) + { + return OpStackInteropMessage.quote(transceiver, recipient, attributes); + } + + /// @dev None: the relay's sender chooses its gas. + function supportsAttribute(bytes4) external pure override returns (bool) { + return false; + } +} diff --git a/contracts/evm/src/protocols/op-stack/OpStackMessage.sol b/contracts/evm/src/protocols/op-stack/OpStackMessage.sol index 5c6d5342..37c96d9c 100644 --- a/contracts/evm/src/protocols/op-stack/OpStackMessage.sol +++ b/contracts/evm/src/protocols/op-stack/OpStackMessage.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.0; import {ICrossDomainMessenger} from "@optimism/interfaces/universal/ICrossDomainMessenger.sol"; -import {Erc7930} from "src/addressing/Erc7930.sol"; import {ProviderAttribute} from "src/protocols/ProviderAttribute.sol"; import {ProviderAddress} from "src/protocols/ProviderAddress.sol"; @@ -12,7 +11,8 @@ interface IOpStackRecipient { function receiveOpStackMessage(bytes calldata payload) external; } -/// @notice Send and inbound-sender logic shared by every OP Stack binding contract. +/// @notice Send and inbound-sender logic shared by every `op-stack-l1-l2` contract, over +/// `CrossDomainMessenger`. /// /// @dev No native fee: an L1->L2 deposit pays for its L2 gas by burning L1 gas in the sending /// transaction (`ResourceMetering`), and an L2->L1 message pays nothing at the source. @@ -28,22 +28,19 @@ library OpStackMessage { uint32 internal constant DEFAULT_MIN_GAS_LIMIT = 200_000; error OpStackValueNotSupported(uint256 value); - error NotThisMessengersChain(bytes32 chainKey, bytes32 messengerChainKey); - /// @param messengerChainKey The one chain `messenger` reaches. The destination is which - /// messenger is called, not an argument to it, so a recipient on any other chain - /// would otherwise be delivered to the same address on this one. + /// @param messenger The messenger for the recipient's chain, from the transceiver's table: + /// the destination is which messenger is called, not an argument to it. /// @return Zero, ERC-7786's "sent" (see `ProviderSendSpec`); the messenger's nonce is in /// its `SentMessage` event. function send( address messenger, - bytes32 messengerChainKey, bytes memory recipient, bytes memory payload, bytes[] memory attributes, uint256 value ) internal returns (bytes32) { - address target = _check(messengerChainKey, recipient, value); + address target = _check(recipient, value); ICrossDomainMessenger(messenger) .sendMessage( target, abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (payload)), minGasLimitFrom(attributes) @@ -51,13 +48,10 @@ library OpStackMessage { return bytes32(0); } - /// @dev Zero, after the same checks `send` applies, so it reverts wherever the send would. - function quote(bytes32 messengerChainKey, bytes memory recipient, bytes[] memory attributes) - internal - pure - returns (uint256) - { - _check(messengerChainKey, recipient, 0); + /// @dev Zero, after the same checks `send` applies. The caller has already looked up the + /// messenger, which reverts for a chain the table does not map. + function quote(bytes memory recipient, bytes[] memory attributes) internal pure returns (uint256) { + _check(recipient, 0); minGasLimitFrom(attributes); return 0; } @@ -70,10 +64,8 @@ library OpStackMessage { return ICrossDomainMessenger(msg.sender).xDomainMessageSender(); } - function _check(bytes32 messengerChainKey, bytes memory recipient, uint256 value) private pure returns (address) { + function _check(bytes memory recipient, uint256 value) private pure returns (address) { if (value != 0) revert OpStackValueNotSupported(value); - bytes32 chainKey = Erc7930.chainKey(recipient); - if (chainKey != messengerChainKey) revert NotThisMessengersChain(chainKey, messengerChainKey); return ProviderAddress.evmRecipient(recipient); } diff --git a/contracts/evm/src/protocols/op-stack/OpStackReceiver.sol b/contracts/evm/src/protocols/op-stack/OpStackReceiver.sol index 2a1d1cf7..cd822735 100644 --- a/contracts/evm/src/protocols/op-stack/OpStackReceiver.sol +++ b/contracts/evm/src/protocols/op-stack/OpStackReceiver.sol @@ -6,22 +6,31 @@ import {ReceiverBase} from "src/messaging/inbound/ReceiverBase.sol"; import {OpStackMessage, IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; import {ProviderAddress} from "src/protocols/ProviderAddress.sol"; -/// @notice Per-user account on a non-home chain. -/// @dev No origin-chain check: an OP Stack messenger relays only from its one paired chain. -contract OpStackReceiver is ReceiverBase, IOpStackRecipient { - /// @notice This chain's `CrossDomainMessenger`. Set on the implementation; safe because - /// the implementation address lives in the proxy's ERC-1967 slot, not its initcode, - /// so this never moves a derived account address. - address public immutable messenger; +/// @notice The initializer an `op-stack-l1-l2` receiver is armed with: its home's messenger +/// on this chain, which a transceiver on Ethereum holds one of per OP Stack chain. +interface IOpStackReceiverInit { + function initialize(address sourceTransmitter, Call[] calldata calls, address messenger) external; +} + +/// @notice Per-user account on a non-home chain, for `op-stack-l1-l2`. +/// @dev No origin-chain check: the one messenger granted `GATEWAY_ROLE` relays only from the +/// account's home. +contract OpStackReceiver is ReceiverBase, IOpStackRecipient, IOpStackReceiverInit { + /// @dev Refused, not just unused: it would arm the account with no gateway. + error UseOpStackInitializer(); - constructor(address messenger_) { - if (messenger_ == address(0)) revert ProviderAddress.ZeroEndpoint(); - messenger = messenger_; + function initialize(address, Call[] calldata) external pure override { + revert UseOpStackInitializer(); } /// @dev `grantRole` is `onlyInitializing`, so this initializer is the only window the /// messenger's gateway grant ever gets. - function initialize(address sourceTransmitter_, Call[] calldata calls) external override initializer { + function initialize(address sourceTransmitter_, Call[] calldata calls, address messenger) + external + override + initializer + { + if (messenger == address(0)) revert ProviderAddress.ZeroEndpoint(); grantRole(GATEWAY_ROLE, messenger); __ReceiverBase_init(sourceTransmitter_, calls); } diff --git a/contracts/evm/src/protocols/op-stack/OpStackTransceiver.sol b/contracts/evm/src/protocols/op-stack/OpStackTransceiver.sol index e34caa38..0ad20faf 100644 --- a/contracts/evm/src/protocols/op-stack/OpStackTransceiver.sol +++ b/contracts/evm/src/protocols/op-stack/OpStackTransceiver.sol @@ -2,52 +2,58 @@ pragma solidity ^0.8.0; import {TransceiverBase, TransceiverConfig} from "src/messaging/transceiver/TransceiverBase.sol"; +import {Call} from "src/messaging/Call.sol"; +import {ProviderTransceiver} from "src/protocols/ProviderTransceiver.sol"; import {OpStackMessage, IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; -import {ProviderAddress} from "src/protocols/ProviderAddress.sol"; +import {IOpStackReceiverInit} from "src/protocols/op-stack/OpStackReceiver.sol"; -/// @notice OP Stack on `TransceiverBase`, for one chain pair: an OP Stack chain and its L1. -/// The same contract runs on both sides, so an account homed on either reaches the -/// other. -/// @dev `sendMessage` names no destination chain: each side's messenger reaches only its -/// paired chain, so the destination is which messenger is called, and `ProviderChainId` -/// does not apply. Reaching a second OP Stack chain means a second pair, registered as its -/// own message provider so its transceivers get their own address. +/// @notice OP Stack's `CrossDomainMessenger` on `TransceiverBase`: the `op-stack-l1-l2` provider, +/// between Ethereum and each OP Stack chain. L2 to L2 is `op-stack-l2-l2`. /// -/// @dev One pair per provider is also a trust-domain choice. `messageProvider` and -/// `minCounterpartProvenance` describe one trust level for everything an instance -/// reaches; Optimism's and Base's canonical bridges run the same software but fail -/// independently. See `provider-research.md` §2. No zkSync or Tron variant: an OP Stack -/// chain uses Ethereum's CREATE2 formula. -contract OpStackTransceiver is TransceiverBase, IOpStackRecipient { +/// @dev Each pair has its own messenger on each side: on an OP Stack chain the +/// `L2CrossDomainMessenger` predeploy, which reaches only Ethereum, and on Ethereum one +/// `L1CrossDomainMessenger` per OP Stack chain. So the provider id for a chain is the +/// address of the messenger that reaches it, held in `ProviderChainId`'s write-once, +/// injective table: one entry on an OP Stack chain, one per chain on Ethereum. The same +/// contract runs on both sides. +/// +/// @dev A delivery's origin is the chain of the messenger that called, never anything the +/// message says, so a compromised bridge to one chain can deliver only as that chain. +/// The table is the gate, so no messenger holds `GATEWAY_ROLE`, and a messenger the +/// table does not map is refused before it is asked for the sender. +/// +/// @dev No zkSync or Tron variant: an OP Stack chain uses Ethereum's CREATE2 formula. +contract OpStackTransceiver is ProviderTransceiver, IOpStackRecipient { bytes4 public constant OP_STACK_MIN_GAS_LIMIT_ATTRIBUTE = OpStackMessage.MIN_GAS_LIMIT_ATTRIBUTE; - /// @notice This chain's messenger for the pair, and the chainKey of the chain it reaches. - /// On the implementation, so neither reaches a derived account address. - address public immutable messenger; - bytes32 public immutable messengerChainKey; + /// @param governorHomeMessenger The messenger on this chain that reaches the governor's + /// home; see `ProviderTransceiver._initGovernorHomeId`. + function initialize(TransceiverConfig memory c, address governorHomeMessenger) external initializer { + _initGovernorHomeId(c.governorHome, uint160(governorHomeMessenger)); + __TransceiverBase_init(c); + } - constructor(address messenger_, bytes32 messengerChainKey_) { - if (messenger_ == address(0)) revert ProviderAddress.ZeroEndpoint(); - messenger = messenger_; - messengerChainKey = messengerChainKey_; + /// @notice Name the messenger on this chain that reaches `chainKey`. + /// @dev Write-once-if-unset (`ProviderChainId`'s shape). + function setMessenger(bytes32 chainKey, address messenger) external onlyOwner { + _setProviderId(chainKey, uint160(messenger)); } - /// @dev Grants `GATEWAY_ROLE` to `messenger`: `receiveOpStackMessage` is gated on it. - function initialize(TransceiverConfig memory c) external initializer { - grantRole(GATEWAY_ROLE, messenger); - __TransceiverBase_init(c); + /// @notice The messenger on this chain that reaches `chainKey`. Reverts when unset. + function messengerFor(bytes32 chainKey) public view returns (address) { + // forge-lint: disable-next-line(unsafe-typecast) set through an address setter + return address(uint160(_providerIdFor(chainKey))); } /* ===================================== sending ===================================== */ - /// @dev `OpStackMessage` refuses a recipient on any chain but `messengerChainKey`, so a - /// bootstrap elsewhere reverts at its quote. function _sendMessage(bytes memory recipient, bytes memory payload, bytes[] memory attributes, uint256 value) internal override returns (bytes32) { - return OpStackMessage.send(messenger, messengerChainKey, recipient, payload, attributes, value); + // forge-lint: disable-next-line(unsafe-typecast) set through an address setter + return OpStackMessage.send(address(uint160(_providerIdOf(recipient))), recipient, payload, attributes, value); } /// @dev Zero: see `OpStackMessage`. @@ -57,15 +63,31 @@ contract OpStackTransceiver is TransceiverBase, IOpStackRecipient { override returns (uint256 nativeFee) { - return OpStackMessage.quote(messengerChainKey, recipient, attributes); + _providerIdOf(recipient); + return OpStackMessage.quote(recipient, attributes); } /* ==================================== receiving ==================================== */ - /// @dev The messenger relays only from `messengerChainKey`, so that is the route. The - /// sender is `xDomainMessageSender()` (see `OpStackMessage.sender`), and the base's - /// `_authenticateOrigin` is the only sender check. - function receiveOpStackMessage(bytes calldata payload) external override onlyRole(GATEWAY_ROLE) { - _onInbound(routeFor(messengerChainKey), abi.encodePacked(OpStackMessage.sender()), payload); + /// @dev The caller is mapped to its chain first, so an unmapped caller reverts + /// `UnknownProviderId` before `xDomainMessageSender()` is read from it. + function receiveOpStackMessage(bytes calldata payload) external override { + bytes32 origin = _chainKeyOfProvider(uint160(msg.sender)); + _onInbound(routeFor(origin), abi.encodePacked(OpStackMessage.sender()), payload); + } + + /// @inheritdoc TransceiverBase + /// @dev A receiver's gateway is the messenger here that reaches its account's home. + function _accountInitializer( + address owner, + bytes32 salt, + bytes32 homeChainKey, + address sourceTransmitter, + Call[] memory calls + ) internal view virtual override returns (bytes memory) { + if (homeChainKey == localChainKey) { + return super._accountInitializer(owner, salt, homeChainKey, sourceTransmitter, calls); + } + return abi.encodeCall(IOpStackReceiverInit.initialize, (sourceTransmitter, calls, messengerFor(homeChainKey))); } } diff --git a/contracts/evm/src/protocols/op-stack/OpStackTransmitter.sol b/contracts/evm/src/protocols/op-stack/OpStackTransmitter.sol index 8c21e77b..0f98f9cb 100644 --- a/contracts/evm/src/protocols/op-stack/OpStackTransmitter.sol +++ b/contracts/evm/src/protocols/op-stack/OpStackTransmitter.sol @@ -3,15 +3,11 @@ pragma solidity ^0.8.0; import {OwnableTransmitter} from "src/messaging/outbound/OwnableTransmitter.sol"; import {OpStackMessage} from "src/protocols/op-stack/OpStackMessage.sol"; +import {providerIdOf} from "src/protocols/ProviderChainId.sol"; -/// @dev What a transmitter reads from the OP Stack transceiver that created it: the one -/// messenger it sends through, and the one chain that messenger reaches. -interface IOpStackMessengerSource { - function messenger() external view returns (address); - function messengerChainKey() external view returns (bytes32); -} - -/// @notice Per-user transmitter, created by `TransceiverBase.createTransmitter`. +/// @notice Per-user transmitter for `op-stack-l1-l2`, created by +/// `TransceiverBase.createTransmitter`. Sends through the messenger its transceiver maps +/// the recipient's chain to. /// @dev Sender-only: no `receiveOpStackMessage`, so R3.1 is answered by absence rather than a /// guard. Binds to `ICrossDomainMessenger`, not `OptimismPortal`: the messenger un-aliases /// the sender, so `AddressDerive.undoL1ToL2Alias` stays unused. See @@ -22,8 +18,9 @@ contract OpStackTransmitter is OwnableTransmitter { override returns (bytes32 sendId) { - IOpStackMessengerSource t = IOpStackMessengerSource(transceiver); - return OpStackMessage.send(t.messenger(), t.messengerChainKey(), recipient, payload, attributes, value); + // forge-lint: disable-next-line(unsafe-typecast) set through the transceiver's address setter + address messenger = address(uint160(providerIdOf(transceiver, recipient))); + return OpStackMessage.send(messenger, recipient, payload, attributes, value); } /// @dev Zero: see `OpStackMessage`. @@ -33,7 +30,8 @@ contract OpStackTransmitter is OwnableTransmitter { override returns (uint256 nativeFee) { - return OpStackMessage.quote(IOpStackMessengerSource(transceiver).messengerChainKey(), recipient, attributes); + providerIdOf(transceiver, recipient); + return OpStackMessage.quote(recipient, attributes); } bytes4 public constant OP_STACK_MIN_GAS_LIMIT_ATTRIBUTE = OpStackMessage.MIN_GAS_LIMIT_ATTRIBUTE; diff --git a/contracts/evm/test/DestinationNaming.t.sol b/contracts/evm/test/DestinationNaming.t.sol index 44090572..1d16d8d5 100644 --- a/contracts/evm/test/DestinationNaming.t.sol +++ b/contracts/evm/test/DestinationNaming.t.sol @@ -257,4 +257,19 @@ contract DestinationNamingTest is Test { vm.assume(chainId != 0); assertEq(ChainKey.fromIdentifier(Erc7930.encodeEvmChain(chainId)), ChainKey.forEvm(chainId)); } + + function testFuzz_anEvmChainIdRoundTrips(uint256 chainId) public pure { + vm.assume(chainId != 0); + assertEq(Erc7930.evmChainId(Erc7930.parseStrict(Erc7930.encodeEvm(chainId, address(0xC0DE)))), chainId); + } + + function test_aNonEvmChainHasNoEvmChainId() public { + bytes memory starknet = Erc7930.encode(Erc7930.CT_STARKNET, bytes("SN_MAIN"), new bytes(32)); + vm.expectRevert(Erc7930.NotEvm.selector); + this.evmChainIdOf(starknet); + } + + function evmChainIdOf(bytes memory raw) external pure returns (uint256) { + return Erc7930.evmChainId(Erc7930.parseStrict(raw)); + } } diff --git a/contracts/evm/test/deploy/ChainConfig.t.sol b/contracts/evm/test/deploy/ChainConfig.t.sol index a6601985..a3150fcb 100644 --- a/contracts/evm/test/deploy/ChainConfig.t.sol +++ b/contracts/evm/test/deploy/ChainConfig.t.sol @@ -5,6 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {Provenance} from "src/registry/Provenance.sol"; import {DeployCheck} from "script/deploy/CrossProxyDeploy.sol"; import {ChainConfig, ChainEntry, Derivation} from "script/deploy/ChainConfig.sol"; +import {OpStackConfig} from "script/deploy/OpStackDeploy.sol"; import {CcipFixture} from "test/protocols/ccip/CcipFixture.sol"; import {LzFixture, LzZkSyncHarness} from "test/protocols/layerzero/LzFixture.sol"; import {LzDeploy} from "script/deploy/LzDeploy.sol"; @@ -112,10 +113,40 @@ contract ChainConfigTest is Test { assertEq(ChainConfig.providerId(dir, cs, "wormhole", base), _remoteId(new WormholeIds())); } - function _remoteId(IRemoteId f) internal view returns (uint256) { + function _remoteId(IRemoteId f) internal pure returns (uint256) { return f.remoteProviderId(); } + function opStackMessenger(string memory dir, uint256 localId, uint256 remoteId) external view returns (address) { + ChainEntry[] memory cs = ChainConfig.chains(dir); + return + OpStackConfig.messenger( + dir, cs, ChainConfig.chainWithId(cs, localId), ChainConfig.chainWithId(cs, remoteId) + ); + } + + /// @dev #41: the L1 reaches each OP Stack chain through that chain's own messenger, and each + /// reaches only the L1, through the predeploy. + function test_anOpStackMessengerDependsOnTheChainItIsReadFrom() public view { + string memory dir = ChainConfig.defaultDir(); + assertEq(this.opStackMessenger(dir, 1, 8453), 0x866E82a600A1414e583f7F13623F1aC5d58b0Afa, "Ethereum to Base"); + assertEq(this.opStackMessenger(dir, 1, 10), 0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1, "Ethereum to OP"); + assertEq(this.opStackMessenger(dir, 8453, 1), 0x4200000000000000000000000000000000000007, "Base to Ethereum"); + assertEq(this.opStackMessenger(dir, 8453, 10), address(0), "not L2 to L2: that is op-stack-l2-l2"); + assertEq(this.opStackMessenger(dir, 42161, 1), address(0), "Arbitrum is not an OP Stack chain"); + assertEq(this.opStackMessenger(dir, 1, 42161), address(0), "nor from Ethereum's side"); + } + + function test_twoOpStackChainsSharingAnL1MessengerAreRefused() public { + _expect("each OP Stack chain has its own L1 messenger"); + this.opStackMessenger(_dir("op-stack-shared-messenger"), 1, 10); + } + + function test_anOpStackChainNotInChainsTomlIsRefused() public { + _expect("OP Stack chains are keyed by chains.toml names"); + this.opStackMessenger(_dir("op-stack-unknown-chain"), 1, 10); + } + function test_aDuplicateChainIdIsRefused() public { _expect("chain_id is unique"); this.chains(_dir("duplicate-chain-id")); diff --git a/contracts/evm/test/deploy/ProductionDeploy.t.sol b/contracts/evm/test/deploy/ProductionDeploy.t.sol index 7350b6ff..b94e699e 100644 --- a/contracts/evm/test/deploy/ProductionDeploy.t.sol +++ b/contracts/evm/test/deploy/ProductionDeploy.t.sol @@ -15,23 +15,20 @@ import {DeployCcip} from "script/DeployCcip.s.sol"; import {DeployHyperlane} from "script/DeployHyperlane.s.sol"; import {DeployWormhole} from "script/DeployWormhole.s.sol"; import {DeployOpStack} from "script/DeployOpStack.s.sol"; +import {DeployOpStackInterop} from "script/DeployOpStackInterop.s.sol"; -/// @notice Each production script, run as a broadcast against mocks at fixed addresses and a -/// registry seeded with every provider's record, on a chain `deploy/chains.toml` -/// configures, lands its transceiver where the registry predicts. Each provider's -/// governor home id is read from `deploy/providers/`. +/// @notice What every production-script test shares: the environment, a chain +/// `deploy/chains.toml` configures, and a registry seeded with every provider's record. /// @dev `vm.setEnv` is process-wide and tests run in parallel, so every test sets the same -/// values: mocks and the registry sit at fixed addresses, and the registry records all five -/// providers. -abstract contract ProductionDeploySpec is Test { +/// values: mocks and the registry sit at fixed addresses, and the registry records every +/// provider. +abstract contract ProductionDeploySpecBase is Test { address internal constant REGISTRY = address(0x5EED0000); address internal constant BROADCASTER = DEFAULT_SENDER; uint256 internal constant HOME_CHAIN_ID = 1; /// @dev Base, a parity chain in deploy/chains.toml. uint256 internal constant LOCAL_CHAIN_ID = 8453; - function _script() internal virtual returns (DeployProvider); - /// @dev Each script reads its production inputs from the environment, which is how a /// broadcast receives them; the values are identical in every test. function _setEnv(string memory name, string memory value) internal { @@ -39,10 +36,8 @@ abstract contract ProductionDeploySpec is Test { vm.setEnv(name, value); } - function _providerName() internal pure virtual returns (string memory); - /// @notice Place the provider's mocks and set its own variables. - function _setUpProvider() internal virtual; + function _setUpProvider() internal virtual {} function setUp() public { // The scripts deploy only to a chain in deploy/chains.toml. @@ -55,16 +50,20 @@ abstract contract ProductionDeploySpec is Test { _setUpProvider(); } - /// @notice The registry at `REGISTRY`, homed on `HOME_CHAIN_ID`, recording all five providers + /// @notice The registry at `REGISTRY`, homed on `HOME_CHAIN_ID`, recording every provider /// as deployed by `deployedBy`. function _seedRegistry(address deployedBy) internal { _seedRegistry(deployedBy, Provenance.Predetermined); } function _seedRegistry(address deployedBy, Provenance homeGrade) internal { - string[5] memory names = ["layerzero", "ccip", "hyperlane", "wormhole", "op-stack"]; - ProviderSeed[] memory providers = new ProviderSeed[](5); - for (uint256 i; i < 5; ++i) { + _seedRegistry(deployedBy, homeGrade, HOME_CHAIN_ID); + } + + function _seedRegistry(address deployedBy, Provenance homeGrade, uint256 homeChainId) internal { + string[6] memory names = ["layerzero", "ccip", "hyperlane", "wormhole", "op-stack-l1-l2", "op-stack-l2-l2"]; + ProviderSeed[] memory providers = new ProviderSeed[](6); + for (uint256 i; i < 6; ++i) { providers[i] = ProviderSeed( names[i], deployedBy, keccak256(bytes(names[i])), keccak256(type(CrossProxy).creationCode) ); @@ -74,7 +73,7 @@ abstract contract ProductionDeploySpec is Test { abi.encode( address(this), RegistrySeed({ - governorHome: Erc7930.encodeEvmChain(HOME_CHAIN_ID), + governorHome: Erc7930.encodeEvmChain(homeChainId), governorHomeGrade: homeGrade, providers: providers }) @@ -82,6 +81,15 @@ abstract contract ProductionDeploySpec is Test { REGISTRY ); } +} + +/// @notice Each production script, run as a broadcast against mocks at fixed addresses, lands its +/// transceiver where the registry predicts. Each provider's governor home id is read +/// from `deploy/providers/`. +abstract contract ProductionDeploySpec is ProductionDeploySpecBase { + function _script() internal virtual returns (DeployProvider); + + function _providerName() internal pure virtual returns (string memory); function test_theTransceiverLandsWhereTheRegistryPredicts() public { _seedRegistry(BROADCASTER); @@ -100,6 +108,15 @@ abstract contract ProductionDeploySpec is Test { script.run(); } + /// @dev A registry seeded with another home has not registered this one, and the deploy + /// says so rather than failing inside the initializer. + function test_aRegistryWithoutTheGovernorsHomeIsRefused() public { + _seedRegistry(BROADCASTER, Provenance.Predetermined, 10); + DeployProvider script = _script(); + vm.expectRevert(abi.encodeWithSelector(DeployCheck.selector, "the registry has the governor's home")); + script.run(); + } + /// @dev A production deployment uses the recorded salt from the recorded caller only. function test_aBroadcasterOtherThanTheRecordedOneIsRefused() public { _seedRegistry(address(0xBAD)); @@ -178,12 +195,25 @@ contract DeployOpStackTest is ProductionDeploySpec { } function _providerName() internal pure override returns (string memory) { - return "op-stack"; + return "op-stack-l1-l2"; } - function _setUpProvider() internal override { - deployCodeTo("MockCrossDomainMessenger.sol:MockCrossDomainMessenger", address(0xE0006)); - _setEnv("OP_STACK_MESSENGER", vm.toString(address(0xE0006))); - _setEnv("OP_STACK_PAIRED_CHAIN_ID", vm.toString(HOME_CHAIN_ID)); + /// @dev No inputs of its own: on Base, the governor home (Ethereum) is reached through the + /// predeploy named in deploy/. + function _setUpProvider() internal override {} +} + +/// @notice `op-stack-l2-l2` waits for interop: its configuration lists no chain yet, so the +/// production script refuses every one, even with the predeploy in place. +contract DeployOpStackInteropTest is ProductionDeploySpecBase { + function test_noChainRunsInteropYet() public { + deployCodeTo( + "MockL2ToL2CrossDomainMessenger.sol:MockL2ToL2CrossDomainMessenger", + 0x4200000000000000000000000000000000000023 + ); + _seedRegistry(BROADCASTER); + DeployOpStackInterop script = new DeployOpStackInterop(); + vm.expectRevert(abi.encodeWithSelector(DeployCheck.selector, "this chain runs op-stack-l2-l2")); + script.run(); } } diff --git a/contracts/evm/test/deploy/config/op-stack-shared-messenger/chains.toml b/contracts/evm/test/deploy/config/op-stack-shared-messenger/chains.toml new file mode 100644 index 00000000..ead34687 --- /dev/null +++ b/contracts/evm/test/deploy/config/op-stack-shared-messenger/chains.toml @@ -0,0 +1,11 @@ +[chains.eth] +chain_id = 1 +derivation = "parity" + +[chains.a] +chain_id = 10 +derivation = "parity" + +[chains.b] +chain_id = 8453 +derivation = "parity" diff --git a/contracts/evm/test/deploy/config/op-stack-shared-messenger/providers/op-stack-l1-l2.toml b/contracts/evm/test/deploy/config/op-stack-shared-messenger/providers/op-stack-l1-l2.toml new file mode 100644 index 00000000..2bcddef0 --- /dev/null +++ b/contracts/evm/test/deploy/config/op-stack-shared-messenger/providers/op-stack-l1-l2.toml @@ -0,0 +1,6 @@ +l1 = "eth" +l2_messenger = "0x4200000000000000000000000000000000000007" + +[l1_messengers] +a = "0x0000000000000000000000000000000000000A11" +b = "0x0000000000000000000000000000000000000A11" diff --git a/contracts/evm/test/deploy/config/op-stack-unknown-chain/chains.toml b/contracts/evm/test/deploy/config/op-stack-unknown-chain/chains.toml new file mode 100644 index 00000000..ead34687 --- /dev/null +++ b/contracts/evm/test/deploy/config/op-stack-unknown-chain/chains.toml @@ -0,0 +1,11 @@ +[chains.eth] +chain_id = 1 +derivation = "parity" + +[chains.a] +chain_id = 10 +derivation = "parity" + +[chains.b] +chain_id = 8453 +derivation = "parity" diff --git a/contracts/evm/test/deploy/config/op-stack-unknown-chain/providers/op-stack-l1-l2.toml b/contracts/evm/test/deploy/config/op-stack-unknown-chain/providers/op-stack-l1-l2.toml new file mode 100644 index 00000000..13d52867 --- /dev/null +++ b/contracts/evm/test/deploy/config/op-stack-unknown-chain/providers/op-stack-l1-l2.toml @@ -0,0 +1,5 @@ +l1 = "eth" +l2_messenger = "0x4200000000000000000000000000000000000007" + +[l1_messengers] +c = "0x0000000000000000000000000000000000000A11" diff --git a/contracts/evm/test/protocols/ProviderAttribute.t.sol b/contracts/evm/test/protocols/ProviderAttribute.t.sol index 5779a975..aa2a9eff 100644 --- a/contracts/evm/test/protocols/ProviderAttribute.t.sol +++ b/contracts/evm/test/protocols/ProviderAttribute.t.sol @@ -21,6 +21,10 @@ contract ProviderAttributeHarness { { return ProviderAttribute.uintValue(attributes, selector, max, defaultValue); } + + function none(bytes[] memory attributes) external pure { + ProviderAttribute.none(attributes); + } } contract ProviderAttributeTest is Test { @@ -42,6 +46,18 @@ contract ProviderAttributeTest is Test { vm.expectRevert(abi.encodeWithSelector(ProviderAttribute.UnsupportedAttribute.selector, attribute)); } + /* =================================== none ==================================== */ + + function test_noneAcceptsNoAttributes() public view { + h.none(new bytes[](0)); + } + + function test_noneRefusesTheFirstAttribute() public { + bytes memory first = abi.encodePacked(SEL, uint256(1)); + _expectUnsupported(first); + h.none(_attrs(first, hex"00")); + } + /* =================================== body ==================================== */ function test_bodyAbsentWhenNoAttributes() public view { diff --git a/contracts/evm/test/protocols/ProviderBindingSpec.t.sol b/contracts/evm/test/protocols/ProviderBindingSpec.t.sol index 90070036..68e05b6c 100644 --- a/contracts/evm/test/protocols/ProviderBindingSpec.t.sol +++ b/contracts/evm/test/protocols/ProviderBindingSpec.t.sol @@ -72,9 +72,11 @@ abstract contract ProviderSendSpec is ProviderFixture { return Erc7930.encodeEvm(REMOTE_CHAIN_ID, REMOTE_COUNTERPART); } - /// @notice A well-formed recipient on a chain nothing has configured. + /// @notice A well-formed recipient on a chain nothing has configured: not the remote chain, + /// and not the governor's home (chain 1 in every fixture), which a transceiver is born + /// routed to. function _unconfiguredRecipient() internal pure returns (bytes memory) { - return Erc7930.encodeEvm(1, REMOTE_COUNTERPART); + return Erc7930.encodeEvm(424242, REMOTE_COUNTERPART); } function test_sendResolvesTheConfiguredDestination() public { diff --git a/contracts/evm/test/protocols/ZeroEndpoint.t.sol b/contracts/evm/test/protocols/ZeroEndpoint.t.sol index ea9a7461..dd801c12 100644 --- a/contracts/evm/test/protocols/ZeroEndpoint.t.sol +++ b/contracts/evm/test/protocols/ZeroEndpoint.t.sol @@ -12,18 +12,20 @@ import {HyperlaneTransmitter} from "src/protocols/hyperlane/HyperlaneTransmitter import {LzReceiver} from "src/protocols/layerzero/LzReceiver.sol"; import {LzTransceiver} from "src/protocols/layerzero/LzTransceiver.sol"; import {LzTransmitter} from "src/protocols/layerzero/LzTransmitter.sol"; -import {OpStackReceiver} from "src/protocols/op-stack/OpStackReceiver.sol"; -import {OpStackTransceiver} from "src/protocols/op-stack/OpStackTransceiver.sol"; +import {OpStackReceiver, IOpStackReceiverInit} from "src/protocols/op-stack/OpStackReceiver.sol"; +import {CrossProxy, ICrossProxy} from "src/account/CrossProxy.sol"; +import {Call} from "src/messaging/Call.sol"; import {WormholeReceiver} from "src/protocols/wormhole/WormholeReceiver.sol"; import {WormholeTransceiver} from "src/protocols/wormhole/WormholeTransceiver.sol"; import {WormholeTransmitter} from "src/protocols/wormhole/WormholeTransmitter.sol"; -/// @notice Every provider endpoint a constructor takes is immutable, so each refuses zero. +/// @notice Every provider endpoint a constructor takes is immutable, so each refuses zero, as +/// does the messenger OP Stack's receiver takes at initialization. contract ZeroEndpointTest is Test { address constant E = address(0xE0); function test_singleEndpointConstructorsRefuseZero() public { - bytes[11] memory code = [ + bytes[10] memory code = [ type(CcipReceiver).creationCode, type(CcipTransceiver).creationCode, type(CcipTransmitter).creationCode, @@ -33,7 +35,6 @@ contract ZeroEndpointTest is Test { type(LzReceiver).creationCode, type(LzTransceiver).creationCode, type(LzTransmitter).creationCode, - type(OpStackReceiver).creationCode, type(WormholeReceiver).creationCode ]; for (uint256 i; i < code.length; ++i) { @@ -41,12 +42,16 @@ contract ZeroEndpointTest is Test { } } - function test_opStackRefusesAZeroMessenger() public { - _assertRefusesOnlyZero( - type(OpStackTransceiver).creationCode, - abi.encode(address(0), bytes32(uint256(1))), - abi.encode(E, bytes32(uint256(1))) - ); + /// @dev OP Stack's receiver takes its messenger at initialization rather than in its + /// constructor, so it refuses zero there. + function test_opStackReceiverRefusesAZeroMessenger() public { + address impl = address(new OpStackReceiver()); + address proxy = address(new CrossProxy()); + vm.expectRevert(ProviderAddress.ZeroEndpoint.selector); + ICrossProxy(proxy) + .upgradeInitializeAndLock( + impl, abi.encodeCall(IOpStackReceiverInit.initialize, (address(0xABCD), new Call[](0), address(0))) + ); } /// @dev Core bridge, executor router, and quoter, each zeroed in turn. diff --git a/contracts/evm/test/protocols/op-stack/MockL2ToL2CrossDomainMessenger.sol b/contracts/evm/test/protocols/op-stack/MockL2ToL2CrossDomainMessenger.sol new file mode 100644 index 00000000..18a1fe5d --- /dev/null +++ b/contracts/evm/test/protocols/op-stack/MockL2ToL2CrossDomainMessenger.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice `L2ToL2CrossDomainMessenger` surface the binding calls. `relay` stands in for +/// `relayMessage`: it sets the context to the origin's sender and chain, calls `target` +/// with `message` as raw calldata, then clears it. `sendMessage` returns a non-zero +/// hash, as the predeploy does, so a binding that passed it on would be caught. +contract MockL2ToL2CrossDomainMessenger { + struct Sent { + uint256 destination; + address target; + bytes message; + address sender; + } + + error NotEntered(); + error MessageDestinationSameChain(); + + Sent[] internal _sent; + bool internal _entered; + address internal _sender; + uint256 internal _source; + + function sendMessage(uint256 destination, address target, bytes calldata message) external returns (bytes32) { + if (destination == block.chainid) revert MessageDestinationSameChain(); + _sent.push(Sent(destination, target, message, msg.sender)); + return keccak256(abi.encode(destination, target, message, _sent.length)); + } + + function sent(uint256 i) external view returns (Sent memory) { + return _sent[i]; + } + + function sentLength() external view returns (uint256) { + return _sent.length; + } + + function crossDomainMessageContext() external view returns (address, uint256) { + if (!_entered) revert NotEntered(); + return (_sender, _source); + } + + /// @dev Bubbles the target's revert so tests can assert on it. + function relay(uint256 source, address sender, address target, bytes calldata message) external { + (_entered, _sender, _source) = (true, sender, source); + (bool ok, bytes memory ret) = target.call(message); + (_entered, _sender, _source) = (false, address(0), 0); + if (!ok) { + assembly { + revert(add(ret, 32), mload(ret)) + } + } + } +} diff --git a/contracts/evm/test/protocols/op-stack/OpStackBinding.t.sol b/contracts/evm/test/protocols/op-stack/OpStackBinding.t.sol index fb274d1b..65a65faf 100644 --- a/contracts/evm/test/protocols/op-stack/OpStackBinding.t.sol +++ b/contracts/evm/test/protocols/op-stack/OpStackBinding.t.sol @@ -13,20 +13,23 @@ import {OpStackReceiver} from "src/protocols/op-stack/OpStackReceiver.sol"; import {OpStackMessage, IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; import { - ProviderSendSpec, + ProviderIdTableSpec, ProviderReceiveSpec, ProviderEvmRecipientSpec, ProviderTransmitterSpec } from "test/protocols/ProviderBindingSpec.t.sol"; import {OpStackFixture} from "test/protocols/op-stack/OpStackFixture.sol"; +import {MockCrossDomainMessenger} from "test/protocols/op-stack/MockCrossDomainMessenger.sol"; +import {OpStackTransceiver} from "src/protocols/op-stack/OpStackTransceiver.sol"; +import {TransceiverBase} from "src/messaging/transceiver/TransceiverBase.sol"; -/// @notice The destination is which messenger is called, so the check is that this one was, -/// addressed to the recipient. -contract OpStackTransceiverSendTest is ProviderSendSpec, ProviderEvmRecipientSpec, OpStackFixture { +/// @notice The destination is which messenger is called, so the check is that the one the table +/// maps the recipient's chain to was, addressed to the recipient. +contract OpStackTransceiverSendTest is ProviderIdTableSpec, ProviderEvmRecipientSpec, OpStackFixture { function _assertLastSendTargetedConfiguredDestination() internal view override { - assertEq(messenger.sentLength(), 1); - assertEq(messenger.sent(0).sender, address(harness)); - assertEq(messenger.sent(0).target, REMOTE_COUNTERPART); + assertEq(MESSENGER.sentLength(), 1); + assertEq(MESSENGER.sent(0).sender, address(harness)); + assertEq(MESSENGER.sent(0).target, REMOTE_COUNTERPART); } function _supportedAttribute() internal pure override returns (bytes memory) { @@ -35,8 +38,8 @@ contract OpStackTransceiverSendTest is ProviderSendSpec, ProviderEvmRecipientSpe function test_messageIsTheEntryPointCallWithThePayload() public { harness.sendMessagePublic(_configuredRecipient(), "payload", new bytes[](0), 0); - assertEq(messenger.sent(0).message, abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (bytes("payload")))); - assertEq(messenger.sent(0).value, 0); + assertEq(MESSENGER.sent(0).message, abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (bytes("payload")))); + assertEq(MESSENGER.sent(0).value, 0); } function test_minGasLimitDefaultsAndFollowsTheAttribute() public { @@ -44,8 +47,8 @@ contract OpStackTransceiverSendTest is ProviderSendSpec, ProviderEvmRecipientSpe attrs[0] = abi.encodePacked(OpStackMessage.MIN_GAS_LIMIT_ATTRIBUTE, uint256(900_000)); harness.sendMessagePublic(_configuredRecipient(), "x", new bytes[](0), 0); harness.sendMessagePublic(_configuredRecipient(), "x", attrs, 0); - assertEq(messenger.sent(0).minGasLimit, OpStackMessage.DEFAULT_MIN_GAS_LIMIT); - assertEq(messenger.sent(1).minGasLimit, 900_000); + assertEq(MESSENGER.sent(0).minGasLimit, OpStackMessage.DEFAULT_MIN_GAS_LIMIT); + assertEq(MESSENGER.sent(1).minGasLimit, 900_000); } /// @dev Value handed to the messenger is bridged to the target, not spent as a fee. @@ -55,24 +58,24 @@ contract OpStackTransceiverSendTest is ProviderSendSpec, ProviderEvmRecipientSpe harness.sendMessagePublic{value: 1}(_configuredRecipient(), "x", new bytes[](0), 1); } - /// @dev A recipient on another chain must not be delivered through this messenger, which - /// would land it at the same address on this messenger's chain. - function test_aRecipientOnAnotherChainIsRefused() public { - vm.expectRevert( - abi.encodeWithSelector( - OpStackMessage.NotThisMessengersChain.selector, ChainKey.forEvm(10), ChainKey.forEvm(REMOTE_CHAIN_ID) - ) - ); - harness.sendMessagePublic(Erc7930.encodeEvm(10, address(0xC0DE)), "x", new bytes[](0), 0); - } + /// @dev #41: Ethereum's transceiver reaches every OP Stack chain, each through its own + /// `L1CrossDomainMessenger`. + function test_eachChainIsSentThroughItsOwnMessenger() public { + address second = address(0x4E55E8); + deployCodeTo("MockCrossDomainMessenger.sol:MockCrossDomainMessenger", second); + address owner = TransceiverBase(payable(address(harness))).owner(); + vm.prank(owner); + OpStackTransceiver(payable(address(harness))).setMessenger(ChainKey.forEvm(10), second); + + harness.sendMessagePublic(Erc7930.encodeEvm(10, REMOTE_COUNTERPART), "to 10", new bytes[](0), 0); + harness.sendMessagePublic(_configuredRecipient(), "to the remote", new bytes[](0), 0); - function test_quoteRevertsForAnotherMessengersChain() public { - vm.expectRevert( - abi.encodeWithSelector( - OpStackMessage.NotThisMessengersChain.selector, ChainKey.forEvm(10), ChainKey.forEvm(REMOTE_CHAIN_ID) - ) + assertEq(MockCrossDomainMessenger(second).sentLength(), 1, "chain 10 through its messenger"); + assertEq( + MockCrossDomainMessenger(second).sent(0).message, + abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (bytes("to 10"))) ); - harness.quoteMessagePublic(Erc7930.encodeEvm(10, address(0xC0DE)), "x"); + assertEq(MESSENGER.sentLength(), 1, "the remote chain through its own"); } function test_minGasLimitAboveUint32IsRefused() public { @@ -84,9 +87,9 @@ contract OpStackTransceiverSendTest is ProviderSendSpec, ProviderEvmRecipientSpe } /// @notice The sharp edge: the sender is `xDomainMessageSender()`, never anything in the -/// delivered calldata, which the origin-side caller wrote in full. A messenger relays -/// only from its one paired chain, so the unconfigured-origin case is the impersonator -/// case. +/// delivered calldata, which the origin-side caller wrote in full. The receiver's one +/// messenger relays only from its home, so the unconfigured-origin case is the +/// impersonator case. contract OpStackReceiveTest is ProviderReceiveSpec, OpStackFixture { /// @dev `sendMessage` is permissionless: an attacker can put the transmitter's address /// anywhere in the message they send. Only `xDomainMessageSender()` counts. @@ -96,13 +99,13 @@ contract OpStackReceiveTest is ProviderReceiveSpec, OpStackFixture { abi.encode(SOURCE_TRANSMITTER) ); vm.expectRevert(ReceiverBase.NotSourceTransmitter.selector); - messenger.relay(address(0xBAD), receiver, claimed); + MESSENGER.relay(address(0xBAD), receiver, claimed); } /// @dev Outside a relay, `xDomainMessageSender()` reverts; a gateway calling in directly /// cannot supply a sender either. function test_theMessengerCallingOutsideARelayIsRejected() public { - vm.prank(address(messenger)); + vm.prank(REMOTE_MESSENGER); vm.expectRevert("CrossDomainMessenger: xDomainMessageSender is not set"); OpStackReceiver(payable(receiver)).receiveOpStackMessage(Payload.encodeCalls(new Call[](0))); } diff --git a/contracts/evm/test/protocols/op-stack/OpStackFixture.sol b/contracts/evm/test/protocols/op-stack/OpStackFixture.sol index 80f3bb7f..dfb97da8 100644 --- a/contracts/evm/test/protocols/op-stack/OpStackFixture.sol +++ b/contracts/evm/test/protocols/op-stack/OpStackFixture.sol @@ -4,19 +4,17 @@ pragma solidity ^0.8.20; import {TransceiverDeployment} from "script/deploy/TransceiverDeploy.sol"; import {OpStackDeploy} from "script/deploy/OpStackDeploy.sol"; import {Call} from "src/messaging/Call.sol"; -import {ChainKey} from "src/addressing/ChainKey.sol"; import {OpStackTransceiver} from "src/protocols/op-stack/OpStackTransceiver.sol"; import {IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; +import {IOpStackReceiverInit} from "src/protocols/op-stack/OpStackReceiver.sol"; import {MockCrossDomainMessenger} from "test/protocols/op-stack/MockCrossDomainMessenger.sol"; -import {ProviderFixture, defaultReceiverInit} from "test/protocols/ProviderFixture.sol"; +import {ProviderIdFixture} from "test/protocols/ProviderFixture.sol"; /// @notice Exposes the send seam for the shared send suite, and marks each message it handles. contract OpStackTransceiverHarness is OpStackTransceiver { event InboundHandled(bytes32 chainKey); - constructor(address messenger_, bytes32 messengerChainKey_) OpStackTransceiver(messenger_, messengerChainKey_) {} - function sendMessagePublic(bytes memory recipient, bytes memory payload, bytes[] memory attributes, uint256 value) external payable @@ -35,26 +33,32 @@ contract OpStackTransceiverHarness is OpStackTransceiver { } } -/// @notice The messenger is paired with `REMOTE_CHAIN_ID`, an implementation immutable, so -/// there is no provider id and nothing to configure. The sender is -/// `xDomainMessageSender()`, never anything in the delivered calldata. -abstract contract OpStackFixture is ProviderFixture { - MockCrossDomainMessenger internal messenger = new MockCrossDomainMessenger(); +/// @notice The messenger that reaches `REMOTE_CHAIN_ID` is its provider id, so the mock sits at a +/// fixed address. The sender is `xDomainMessageSender()`, never anything in the +/// delivered calldata. +abstract contract OpStackFixture is ProviderIdFixture { + address internal constant REMOTE_MESSENGER = address(0x4E55E7); + + MockCrossDomainMessenger internal constant MESSENGER = MockCrossDomainMessenger(REMOTE_MESSENGER); + + constructor() { + deployCodeTo("MockCrossDomainMessenger.sol:MockCrossDomainMessenger", REMOTE_MESSENGER); + } function _transceiverImplementation() internal override returns (address) { - return address(new OpStackTransceiverHarness(address(messenger), ChainKey.forEvm(REMOTE_CHAIN_ID))); + return address(new OpStackTransceiverHarness()); } function _receiverImplementation() internal override returns (address) { - return OpStackDeploy.receiverImplementation(address(messenger)); + return OpStackDeploy.receiverImplementation(); } function _transmitterImplementation() internal override returns (address) { return OpStackDeploy.transmitterImplementation(); } - function _deploy(TransceiverDeployment memory d, uint256) internal override returns (address) { - return OpStackDeploy.transceiver(d, address(messenger), ChainKey.forEvm(REMOTE_CHAIN_ID)); + function _deploy(TransceiverDeployment memory d, uint256 homeId) internal override returns (address) { + return OpStackDeploy.transceiver(d, address(uint160(homeId))); } function _initializeReceiver(address sourceTransmitter, Call[] memory calls) @@ -63,25 +67,37 @@ abstract contract OpStackFixture is ProviderFixture { override returns (bytes memory) { - return defaultReceiverInit(sourceTransmitter, calls); + return abi.encodeCall(IOpStackReceiverInit.initialize, (sourceTransmitter, calls, REMOTE_MESSENGER)); } - function _gateway() internal view override returns (address) { - return address(messenger); + function _gateway() internal pure override returns (address) { + return REMOTE_MESSENGER; } function _remoteProviderId() internal pure override returns (uint256) { - return 0; + return uint160(REMOTE_MESSENGER); + } + + function _setProviderId(address t, bytes32 chainKey, uint256 providerId) internal override { + OpStackTransceiver(payable(t)).setMessenger(chainKey, address(uint160(providerId))); } - function _configureRemote(address, address) internal override {} + function _configureRemote(address t, address) internal override { + _setRemoteProviderId(t); + } - /// @dev The messenger reports a 20-byte sender, so a wider one cannot be expressed. - function _deliver(address to, uint256, bytes32 sender, bytes memory message) internal override { + /// @dev Relayed by the messenger at `originId`, given the mock's code if it has none. The + /// messenger reports a 20-byte sender, so a wider one cannot be expressed. + function _deliver(address to, uint256 originId, bytes32 sender, bytes memory message) internal override { require(uint256(sender) >> 160 == 0, "OP Stack senders are addresses"); - messenger.relay( - address(uint160(uint256(sender))), to, abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (message)) - ); + address from = address(uint160(originId)); + if (from.code.length == 0) vm.etch(from, REMOTE_MESSENGER.code); + MockCrossDomainMessenger(from) + .relay( + address(uint160(uint256(sender))), + to, + abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (message)) + ); } function _deliverBypassingGateway(address to, bytes32, bytes memory message) internal override { diff --git a/contracts/evm/test/protocols/op-stack/OpStackInteropBinding.t.sol b/contracts/evm/test/protocols/op-stack/OpStackInteropBinding.t.sol new file mode 100644 index 00000000..83e422a7 --- /dev/null +++ b/contracts/evm/test/protocols/op-stack/OpStackInteropBinding.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Erc7930} from "src/addressing/Erc7930.sol"; +import {ChainKey} from "src/addressing/ChainKey.sol"; +import {TransceiverBase} from "src/messaging/transceiver/TransceiverBase.sol"; +import {Call} from "src/messaging/Call.sol"; +import {Payload} from "src/messaging/Payload.sol"; +import {ReceiverBase} from "src/messaging/inbound/ReceiverBase.sol"; +import {OpStackInteropMessage} from "src/protocols/op-stack/OpStackInteropMessage.sol"; +import {OpStackMessage, IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; + +import { + ProviderSendSpec, + ProviderReceiveSpec, + ProviderEvmRecipientSpec, + ProviderTransmitterSpec +} from "test/protocols/ProviderBindingSpec.t.sol"; +import {OpStackInteropFixture} from "test/protocols/op-stack/OpStackInteropFixture.sol"; + +/// @notice The destination is the recipient's chain id, handed to the one messenger. +contract OpStackInteropTransceiverSendTest is ProviderSendSpec, ProviderEvmRecipientSpec, OpStackInteropFixture { + function _assertLastSendTargetedConfiguredDestination() internal view override { + assertEq(MESSENGER.sentLength(), 1); + assertEq(MESSENGER.sent(0).destination, REMOTE_CHAIN_ID); + assertEq(MESSENGER.sent(0).target, REMOTE_COUNTERPART); + assertEq(MESSENGER.sent(0).sender, address(harness)); + } + + /// @dev The binding supports none, so any well-formed attribute stands in. + function _supportedAttribute() internal pure override returns (bytes memory) { + return abi.encodePacked(bytes4(keccak256("crossecute.test.any")), uint256(1)); + } + + function test_messageIsTheEntryPointCallWithThePayload() public { + harness.sendMessagePublic(_configuredRecipient(), "payload", new bytes[](0), 0); + assertEq(MESSENGER.sent(0).message, abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (bytes("payload")))); + } + + /// @dev `sendMessage` is not payable, so value can only be refused. + function test_nonzeroValueIsRefused() public { + vm.deal(address(this), 1 ether); + vm.expectRevert(abi.encodeWithSelector(OpStackMessage.OpStackValueNotSupported.selector, 1)); + harness.sendMessagePublic{value: 1}(_configuredRecipient(), "x", new bytes[](0), 1); + } + + /// @dev Even under a route to this chain, which nothing refuses at `setRoute`. The messenger + /// refuses it too; refusing first keeps the quote in step with the send. + function test_aSendToThisChainIsRefused() public { + address owner = TransceiverBase(payable(address(harness))).owner(); + vm.prank(owner); + TransceiverBase(payable(address(harness))).setRoute(ChainKey.local(), Erc7930.encodeEvmChain(block.chainid)); + bytes memory here = Erc7930.encodeEvm(block.chainid, REMOTE_COUNTERPART); + vm.expectRevert(OpStackInteropMessage.InteropToThisChain.selector); + harness.sendMessagePublic(here, "x", new bytes[](0), 0); + vm.expectRevert(OpStackInteropMessage.InteropToThisChain.selector); + harness.quoteMessagePublic(here, "x"); + } +} + +/// @notice The sender is `crossDomainMessageSender()`, never anything in the delivered calldata. +/// The receiver keeps no origin state, so the unconfigured-origin case is the +/// impersonator case. +contract OpStackInteropReceiveTest is ProviderReceiveSpec, OpStackInteropFixture { + function test_aSenderClaimedInsideTheMessageIsIgnored() public { + bytes memory claimed = abi.encodePacked( + abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (Payload.encodeCalls(new Call[](0)))), + abi.encode(SOURCE_TRANSMITTER) + ); + vm.expectRevert(ReceiverBase.NotSourceTransmitter.selector); + MESSENGER.relay(REMOTE_CHAIN_ID, address(0xBAD), receiver, claimed); + } + + /// @dev Outside a relay the messenger has no context, so a call from it cannot name a sender. + function test_theMessengerCallingOutsideARelayIsRejected() public { + vm.prank(address(MESSENGER)); + vm.expectRevert(); + IOpStackRecipient(receiver).receiveOpStackMessage(Payload.encodeCalls(new Call[](0))); + } +} + +contract OpStackInteropTransmitterInboundTest is ProviderTransmitterSpec, OpStackInteropFixture {} diff --git a/contracts/evm/test/protocols/op-stack/OpStackInteropFixture.sol b/contracts/evm/test/protocols/op-stack/OpStackInteropFixture.sol new file mode 100644 index 00000000..44f5c392 --- /dev/null +++ b/contracts/evm/test/protocols/op-stack/OpStackInteropFixture.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {TransceiverDeployment} from "script/deploy/TransceiverDeploy.sol"; +import {OpStackInteropDeploy} from "script/deploy/OpStackInteropDeploy.sol"; +import {Call} from "src/messaging/Call.sol"; +import {TransceiverBase} from "src/messaging/transceiver/TransceiverBase.sol"; +import {ChainKey} from "src/addressing/ChainKey.sol"; +import {Erc7930} from "src/addressing/Erc7930.sol"; +import {OpStackInteropTransceiver} from "src/protocols/op-stack/OpStackInteropTransceiver.sol"; +import {OpStackInteropMessage} from "src/protocols/op-stack/OpStackInteropMessage.sol"; +import {IOpStackRecipient} from "src/protocols/op-stack/OpStackMessage.sol"; + +import {MockL2ToL2CrossDomainMessenger} from "test/protocols/op-stack/MockL2ToL2CrossDomainMessenger.sol"; +import {ProviderFixture, defaultReceiverInit} from "test/protocols/ProviderFixture.sol"; + +/// @notice Exposes the send seam for the shared send suite, and marks each message it handles. +contract OpStackInteropTransceiverHarness is OpStackInteropTransceiver { + event InboundHandled(bytes32 chainKey); + + function sendMessagePublic(bytes memory recipient, bytes memory payload, bytes[] memory attributes, uint256 value) + external + payable + returns (bytes32) + { + return _sendMessage(recipient, payload, attributes, value); + } + + function quoteMessagePublic(bytes memory recipient, bytes memory payload) external view returns (uint256) { + return _quoteMessage(recipient, payload, new bytes[](0)); + } + + function _handleInbound(bytes32 origin, bytes calldata message) internal override { + emit InboundHandled(origin); + super._handleInbound(origin, message); + } +} + +/// @notice The messenger is the predeploy, so the mock sits at its address. A chain is named by +/// its chain id, and nothing is configured per chain but its route. +abstract contract OpStackInteropFixture is ProviderFixture { + MockL2ToL2CrossDomainMessenger internal constant MESSENGER = + MockL2ToL2CrossDomainMessenger(OpStackInteropMessage.MESSENGER); + + constructor() { + deployCodeTo("MockL2ToL2CrossDomainMessenger.sol:MockL2ToL2CrossDomainMessenger", address(MESSENGER)); + } + + function _transceiverImplementation() internal override returns (address) { + return address(new OpStackInteropTransceiverHarness()); + } + + function _receiverImplementation() internal override returns (address) { + return OpStackInteropDeploy.receiverImplementation(); + } + + function _transmitterImplementation() internal override returns (address) { + return OpStackInteropDeploy.transmitterImplementation(); + } + + function _deploy(TransceiverDeployment memory d, uint256) internal override returns (address) { + return OpStackInteropDeploy.transceiver(d); + } + + function _initializeReceiver(address sourceTransmitter, Call[] memory calls) + internal + pure + override + returns (bytes memory) + { + return defaultReceiverInit(sourceTransmitter, calls); + } + + function _gateway() internal pure override returns (address) { + return address(MESSENGER); + } + + /// @dev Interop names a chain by its chain id. + function _remoteProviderId() internal pure override returns (uint256) { + return REMOTE_CHAIN_ID; + } + + /// @dev With no id table, the route is the configuration a send needs. + function _configureRemote(address t, address) internal override { + address owner = TransceiverBase(payable(t)).owner(); + vm.prank(owner); + TransceiverBase(payable(t)).setRoute(ChainKey.forEvm(REMOTE_CHAIN_ID), Erc7930.encodeEvmChain(REMOTE_CHAIN_ID)); + } + + /// @dev The messenger reports a 20-byte sender, so a wider one cannot be expressed. + function _deliver(address to, uint256 originId, bytes32 sender, bytes memory message) internal override { + require(uint256(sender) >> 160 == 0, "interop senders are addresses"); + MESSENGER.relay( + originId, + address(uint160(uint256(sender))), + to, + abi.encodeCall(IOpStackRecipient.receiveOpStackMessage, (message)) + ); + } + + function _deliverBypassingGateway(address to, bytes32, bytes memory message) internal override { + IOpStackRecipient(to).receiveOpStackMessage(message); + } + + /// @dev `sendMessage` is not payable; the relay pays for itself. + function _setProviderFee(uint256) internal override {} + + function _expectedQuoteFor(uint256) internal pure override returns (uint256) { + return 0; + } +} diff --git a/contracts/evm/test/protocols/op-stack/OpStackInteropTransceiver.t.sol b/contracts/evm/test/protocols/op-stack/OpStackInteropTransceiver.t.sol new file mode 100644 index 00000000..be1bb1cb --- /dev/null +++ b/contracts/evm/test/protocols/op-stack/OpStackInteropTransceiver.t.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ChainKey} from "src/addressing/ChainKey.sol"; +import {OutboundBase} from "src/messaging/outbound/OutboundBase.sol"; + +import {ProviderGatewayRoleSpec} from "test/protocols/ProviderBindingSpec.t.sol"; +import {OpStackInteropFixture} from "test/protocols/op-stack/OpStackInteropFixture.sol"; +import {DeployCheck} from "script/deploy/CrossProxyDeploy.sol"; + +/// @notice The origin is the source chain the messenger reports, and the sender its +/// `crossDomainMessageSender()`, checked by the base against that chain's counterpart. +contract OpStackInteropTransceiverInboundTest is ProviderGatewayRoleSpec, OpStackInteropFixture { + /// @dev A chain outside the dependency set this transceiver was configured for has no route. + function test_aDeliveryFromAnUnroutedChainIsRefused() public { + _wire(); + bytes memory message = _bootstrap(); + vm.expectRevert(abi.encodeWithSelector(OutboundBase.NoRouteFor.selector, ChainKey.forEvm(7777777))); + _deliver(transceiver, 7777777, bytes32(uint256(uint160(ORIGIN_TRANSCEIVER))), message); + } +} + +/// @notice Deployment waits for interop: with no code at the predeploy, the shared deploy refuses. +contract OpStackInteropDeployTest is OpStackInteropFixture { + /// @dev External so that the expected revert attaches to the whole deploy. + function deploy() external returns (address) { + return _deployTransceiver(_config(), 0); + } + + function test_aChainWithoutInteropIsRefused() public { + vm.etch(address(MESSENGER), ""); + vm.expectRevert(abi.encodeWithSelector(DeployCheck.selector, "the L2ToL2CrossDomainMessenger is live here")); + this.deploy(); + } +} diff --git a/contracts/evm/test/protocols/op-stack/OpStackTransceiver.t.sol b/contracts/evm/test/protocols/op-stack/OpStackTransceiver.t.sol index 9a938783..07a27292 100644 --- a/contracts/evm/test/protocols/op-stack/OpStackTransceiver.t.sol +++ b/contracts/evm/test/protocols/op-stack/OpStackTransceiver.t.sol @@ -1,29 +1,43 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {IOpStackMessengerSource} from "src/protocols/op-stack/OpStackTransmitter.sol"; import {ChainKey} from "src/addressing/ChainKey.sol"; import {OutboundBase} from "src/messaging/outbound/OutboundBase.sol"; +import {TransceiverBase} from "src/messaging/transceiver/TransceiverBase.sol"; +import {ProviderChainId} from "src/protocols/ProviderChainId.sol"; +import {OpStackTransceiver} from "src/protocols/op-stack/OpStackTransceiver.sol"; -import {ProviderGatewayRoleSpec} from "test/protocols/ProviderBindingSpec.t.sol"; +import {ProviderInboundSpec, ProviderGovernorHomeSpec} from "test/protocols/ProviderBindingSpec.t.sol"; import {OpStackFixture} from "test/protocols/op-stack/OpStackFixture.sol"; -/// @notice The sender is `xDomainMessageSender()`, checked by the base against the paired -/// chain's counterpart; the messenger's gateway role is what admits the call. -contract OpStackTransceiverInboundTest is ProviderGatewayRoleSpec, OpStackFixture { - /// @dev The paired chain is the only origin, and with no route recorded for it nothing is - /// accepted, even from the counterpart's address. - function test_nothingIsAcceptedBeforeThePairedChainIsRouted() public { - bytes32 paired = ChainKey.forEvm(REMOTE_CHAIN_ID); +/// @notice The origin is the chain of the messenger that called, through the table, and the +/// sender is that messenger's `xDomainMessageSender()`, checked by the base against +/// the origin's counterpart. +contract OpStackTransceiverInboundTest is ProviderInboundSpec, OpStackFixture { + /// @dev The table is the gate, and it maps the caller before asking it for the sender: + /// an EOA would fail the sender read with no error data, not this one. + function _bypassRevert(address caller) internal pure override returns (bytes memory) { + return abi.encodeWithSelector(ProviderChainId.UnknownProviderId.selector, uint256(uint160(caller))); + } + + /// @dev A mapped messenger whose chain has no route delivers nothing. + function test_nothingIsAcceptedFromAMessengerWhoseChainIsUnrouted() public { + bytes32 remote = ChainKey.forEvm(REMOTE_CHAIN_ID); + address owner = TransceiverBase(payable(transceiver)).owner(); + vm.prank(owner); + OpStackTransceiver(payable(transceiver)).setMessenger(remote, REMOTE_MESSENGER); bytes memory message = _bootstrap(); - vm.expectRevert(abi.encodeWithSelector(OutboundBase.NoRouteFor.selector, paired)); + vm.expectRevert(abi.encodeWithSelector(OutboundBase.NoRouteFor.selector, remote)); _deliverTo(transceiver, ORIGIN_TRANSCEIVER, message); } - /// @dev `OpStackTransmitter` reads its messenger and paired chain from the transceiver that - /// created it. - function test_itAnswersWhatItsTransmittersRead() public view { - assertEq(IOpStackMessengerSource(transceiver).messenger(), address(messenger)); - assertEq(IOpStackMessengerSource(transceiver).messengerChainKey(), ChainKey.forEvm(REMOTE_CHAIN_ID)); + /// @dev `OpStackTransmitter` sends through what the transceiver's table answers here. + function test_itAnswersWhatItsTransmittersRead() public { + _wire(); + assertEq( + OpStackTransceiver(payable(transceiver)).messengerFor(ChainKey.forEvm(REMOTE_CHAIN_ID)), REMOTE_MESSENGER + ); } } + +contract OpStackGovernorHomeTest is ProviderGovernorHomeSpec, OpStackFixture {} diff --git a/docs/message-flow.md b/docs/message-flow.md index 91d7ad04..e03738a6 100644 --- a/docs/message-flow.md +++ b/docs/message-flow.md @@ -6,7 +6,8 @@ The two paths a message takes, the wire formats, and how the contracts fit toget `bootstrap`, the quote surface, the inbound funnel, and the reentrancy guard. The send and receive surfaces are ERC-7786's: `TransmitterBase` is an `IERC7786GatewaySource` and `ReceiverBase` an `IERC7786Recipient`. Native bindings for LayerZero, CCIP, Hyperlane, -Wormhole, and OP Stack live under `src/protocols/`, tested against mocks of each provider; +Wormhole, and OP Stack (`op-stack-l1-l2` and, over interop, `op-stack-l2-l2`) live under +`src/protocols/`, tested against mocks of each provider; nothing has crossed a real bridge yet. What a binding must implement is in [`provider-spec.md`](provider-spec.md). diff --git a/docs/provider-research.md b/docs/provider-research.md index c931b5bc..eacb58bc 100644 --- a/docs/provider-research.md +++ b/docs/provider-research.md @@ -30,7 +30,7 @@ implementing the standard directly; the [CCIP](#4-ccip-as-a-native-binding) and | [4. CCIP](#4-ccip-as-a-native-binding) | `smartcontractkit/ccip`, `ccip-develop` branch, commit `171f9f0c` | Chainlink changes `CCIPReceiver`'s authentication, `Client`'s struct shapes, or the Router/OnRamp/OffRamp split | | [5. Hyperlane](#5-hyperlane-as-a-native-binding) | `hyperlane-xyz/hyperlane-monorepo`, `main` branch, commit `983831f6`; pinned dependency versions read from `solidity/remappings.txt` (OZ `4.9.3`) | Hyperlane bumps its own OZ pin past a version this repo can share, or changes `MailboxClient`/`Router`'s shape | | [6. Wormhole](#6-wormhole-core-vs-the-relayer-are-two-different-bindings) | `wormhole-foundation/wormhole`, `main` branch, commit `2df4000c` (`IWormhole.sol`); `wormhole-foundation/wormhole-solidity-sdk`, `main` branch, commit `2cb855ea` (`IWormholeRelayer.sol`) | Wormhole Core adds general-message dedupe (it does not have it today), or the Relayer interface's delivery/quote shape changes | -| [7. OP Stack](#7-op-stack-as-a-native-binding) | `ethereum-optimism/optimism`, `develop` branch, commit `0abfb166` (`ICrossDomainMessenger.sol`) | Optimism changes `relayMessage`'s calling convention or how `xDomainMessageSender` is scoped | +| [7. OP Stack](#7-op-stack-as-a-native-binding) | `ethereum-optimism/optimism`, `develop` branch, commit `0abfb166` (`ICrossDomainMessenger.sol`); commit `dfe4f947` (`IL2ToL2CrossDomainMessenger.sol`, `ICrossL2Inbox.sol`) | Optimism changes `relayMessage`'s calling convention, how `xDomainMessageSender` or `crossDomainMessageSource` is scoped, or interop's message format | | [8. LayerZero](#8-layerzero-as-a-native-binding) | `@layerzerolabs/oapp-evm-upgradeable@0.1.3`, `@layerzerolabs/oapp-evm@0.4.1`; per-file commits in `script/vendor/layerzero.sh` | LayerZero moves OApp's storage namespace, its peer check, or `_payNative`'s `msg.value` rule | --- @@ -215,11 +215,14 @@ and nothing else, rather than trusting one attestation network with every destin once. What it costs is N deployments, N graded chain entries, and N sets of routes, which is the operational load a default counterpart exists to keep bearable. -**Reversed for OP Stack (#41), not yet implemented.** One transceiver per messaging layer per -chain is the rule, so Ethereum's OP Stack transceiver is to serve every OP Stack L2 through a -per-chain messenger table. The trust argument above survives if a delivery's origin is -decided by which messenger called, never by the message: a compromised Base bridge can then -only deliver as Base. The binding still serves one pair until #41 lands. +**Reversed for OP Stack (#41).** One transceiver per messaging layer per chain is the rule, so +`op-stack-l1-l2`'s transceiver on Ethereum serves every OP Stack chain through a write-once +table of the messenger that reaches each. The trust argument above survives because a +delivery's origin is the chain of the messenger that called, never anything in the message: +a compromised Base bridge can only deliver as Base. L2 to L2 is a separate provider, +`op-stack-l2-l2`, over Superchain interop's `L2ToL2CrossDomainMessenger` predeploy, which +names a destination by its chain id and reports the source chain on delivery +(`crossDomainMessageSource`). ### Chain-level deployment permissioning, which breaks bootstrap and not sends @@ -711,13 +714,12 @@ would inherit, and the one thing about its shape that is not like the other four `sendMessage(address _target, bytes memory _message, uint32 _minGasLimit) external payable` takes no destination chain at all. Each OP Stack chain has its OWN dedicated `L1CrossDomainMessenger` deployed at its own address on L1; the destination IS which -messenger contract you call, not an argument to it. This is the concrete form of what §2 -already concluded, "one transceiver pair per rollup, not one for the stack", and it means -[`ProviderChainId`](../contracts/evm/src/protocols/ProviderChainId.sol) does not apply to -this binding at all. `OpStackTransceiver` holds its messenger and the paired chain as -immutables, the same contract on the L1 and on the OP Stack chain; reaching another OP Stack -chain means another pair, registered as its own message provider, not a row in a table. -#41 reverses this (see §2); not yet implemented. +messenger contract you call, not an argument to it. So `op-stack-l1-l2` names a chain by the +address of the messenger that reaches it, as its row in +[`ProviderChainId`](../contracts/evm/src/protocols/ProviderChainId.sol): on the L1 one row per +OP Stack chain, and on each OP Stack chain one row, the L1, reached through the +`L2CrossDomainMessenger` predeploy. The same `OpStackTransceiver` runs on both sides. It +began as one pair per registered provider; #41 replaced that (see §2). **No on-chain quote, confirmed against the full interface.** There is no `quote`-shaped function anywhere in `ICrossDomainMessenger`. `baseGas(message, minGasLimit)` exists, but it @@ -898,7 +900,7 @@ Adopting it would mean deciding how a destination chain trusts a NEAR-MPC-derive as "the account" at all: an architecture question upstream of "add a binding," not a peer of the five in `provider-research.md`. -**Not required by, and must not block, the five bindings that exist.** Recorded here +**Not required by, and must not block, the six bindings that exist.** Recorded here so it isn't rediscovered under time pressure. The next step, if ever pursued, is source-verified research in the format above, of `v1.signer` and NEAR's validator threshold-signing scheme itself, not of the Intents/Verifier diff --git a/docs/provider-spec.md b/docs/provider-spec.md index 07f7fd5a..1f4cbe69 100644 --- a/docs/provider-spec.md +++ b/docs/provider-spec.md @@ -51,7 +51,7 @@ Keywords MUST, MUST NOT, SHOULD and MAY are used in the RFC 2119 sense. | Term | Meaning | | --- | --- | -| **provider** | The third-party transport: LayerZero, CCIP, Hyperlane, Wormhole, OP Stack. | +| **provider** | The third-party transport: LayerZero, CCIP, Hyperlane, Wormhole, and OP Stack as two providers, `op-stack-l1-l2` and `op-stack-l2-l2`. | | **binding** | The contracts in this repo that attach a provider to the protocol. | | **account** | A `CrossProxy` at `keccak256(abi.encode(owner, salt, homeChainKey))`: a transmitter at home, a receiver everywhere else. One address on every parity chain. | | **transceiver** | The shared contract, one per provider per chain, owned by the governor's own account there. It creates transmitters for accounts homed on its chain and receivers for accounts homed elsewhere. | @@ -111,8 +111,8 @@ provider's actual code, not its documentation, before writing anything else. Every binding here quotes on-chain with a `view`: LayerZero's `endpoint.quote`, CCIP's `getFee`, Hyperlane's `quoteDispatch`, and for Wormhole Core's `messageFee` plus the Executor -quoter router's `quoteExecution`. OP Stack's quote is zero: a deposit is paid in burned gas, -not value. +quoter router's `quoteExecution`. Both OP Stack quotes are zero: a deposit is paid in burned gas, +and an interop message by its relayer. --- @@ -126,7 +126,7 @@ A binding is a handful of files under `src/protocols//`, plus | `

Message.sol` | library | The shared send, quote, and attribute code, called by the transmitter and the transceiver. Where the SDK is inherited and already sends (LayerZero's OApp), it holds only the attribute. | | `

Transmitter.sol` | `OwnableTransmitter` (`TransmitterBase` + `OwnableUpgradeable`) | The per-user account at home. Sends on path A. | | `

Receiver.sol` | `ReceiverBase` | The per-user account on every other chain. Receives on path A. | -| `

Transceiver.sol` | `ProviderTransceiver` (`TransceiverBase` + `ProviderChainId`) | Both ends of path B: sends bootstraps and reports, receives both. The provider's wiring is an abstract `

TransceiverBase` in the same file, which the plain `

Transceiver` and the zkSync/Tron variants share. `ProviderTransceiver` only where a provider-native chain id survives; OP Stack, whose messenger reaches one chain, extends `TransceiverBase` directly, and under ERC-7786 a gateway binding has no id either. See [§9](#9-worked-skeleton-an-erc-7786-gateway-binding). | +| `

Transceiver.sol` | `ProviderTransceiver` (`TransceiverBase` + `ProviderChainId`) | Both ends of path B: sends bootstraps and reports, receives both. The provider's wiring is an abstract `

TransceiverBase` in the same file, which the plain `

Transceiver` and the zkSync/Tron variants share. `ProviderTransceiver` only where a provider-native chain id survives; for `op-stack-l1-l2` the id is the address of the messenger that reaches the chain; `op-stack-l2-l2` names a chain by its chain id, which the recipient carries, so it extends `TransceiverBase` directly, as does a gateway binding under ERC-7786. See [§9](#9-worked-skeleton-an-erc-7786-gateway-binding). | | `

DivergentTransceiver.sol` | `ZkSyncTransceiver` or `TronTransceiver`, beside `

TransceiverBase` | The same transceiver on zkSync or Tron, which derive account addresses their own way. Each override only names both bases. | Where the provider sits, on each path. Path A carries every ordinary message and touches @@ -377,7 +377,7 @@ send nothing, and its provider fails [P9](#2-provider-prerequisites-the-go-or-no **R2.3 It MUST price the exact bytes the send would carry.** The quote is taken over `Payload.encodeCalls(calls)` or `Envelope.encodeBootstrap(owner, salt, transmitter, calls)`, the same function `sendMessage` puts on the wire, not over an estimate of the length. LayerZero, CCIP, -and Hyperlane price per byte; Wormhole's Executor and OP Stack do not, and the rule costs them +and Hyperlane price per byte; Wormhole's Executor and the two OP Stack providers do not, and the rule costs them nothing. This is what makes a quote a number the send can pay rather than a number it must pad, and it is why the public surface below takes exactly `sendMessage`'s arguments. @@ -751,7 +751,7 @@ Those shared checks hold the proxy to its predicted address, locked on its imple the transceiver to its configuration, gateways, and governor's home; and, where the registry records the provider's deployment, R8.4. The production script adds what holds only on chain: the salt and caller are the record's; the chain, the governor's home, and the provider ids are -in `deploy/`; and the registry's grades agree with it. Steps 1 +in `deploy/`; and the registry has the home and grades every chain as `deploy/` does. Steps 1 and 3 to 7, and zkSync and Tron, are not scripted ([todo §3](todo.md#3-infrastructure)). The ordering above is their specification. @@ -794,18 +794,18 @@ to those, rather than flags: | Spec | Applies to | Covers | | --- | --- | --- | -| `ProviderSendSpec` | all five | C1, C2, C13, C14; an unknown or malformed attribute is refused | -| `ProviderFeeSpec` | all but OP Stack (no source fee) | C11 against mocks, C16, C26; `value` is spent even below `msg.value` | +| `ProviderSendSpec` | all six | C1, C2, C13, C14; an unknown or malformed attribute is refused | +| `ProviderFeeSpec` | all but the two OP Stack providers (no source fee) | C11 against mocks, C16, C26; `value` is spent even below `msg.value` | | `ProviderPayloadPricedSpec` | LayerZero, CCIP, Hyperlane | C12; the payload reaches the provider unchanged | | `ProviderRefundSpec` | LayerZero, Hyperlane, Wormhole | C25 | -| `ProviderIdTableSpec` | the four transceivers with an id table | C1 (transmitter lookup), C5 (transceiver), C28 | +| `ProviderIdTableSpec` | the five transceivers with an id table (all but `op-stack-l2-l2`) | C1 (transmitter lookup), C5 (transceiver), C28 | | `ProviderEvmRecipientSpec` | all but LayerZero (delivers to its peer) | R4.3 for recipients | -| `ProviderTransmitterSpec` | all five | C9 | -| `ProviderReceiveSpec` | all five | C4, C5, C6 (account), C18, C24 (account); the receiver grants the gateway its role | -| `ProviderWideSenderSpec` | all but OP Stack (sender is an address) | C10 | -| `ProviderInboundSpec` | all five | C4, C6, C7, C24 (transceiver); only the provider delivers | -| `ProviderGatewayRoleSpec` | all but LayerZero (OApp checks the endpoint itself) | The transceiver's initializer grants the provider `GATEWAY_ROLE` | -| `ProviderGovernorHomeSpec` | the four transceivers with an id table | The governor home's id and route are set at initialization (#28) | +| `ProviderTransmitterSpec` | all six | C9 | +| `ProviderReceiveSpec` | all six | C4, C5, C6 (account), C18, C24 (account); the receiver grants the gateway its role | +| `ProviderWideSenderSpec` | all but the two OP Stack providers (sender is an address) | C10 | +| `ProviderInboundSpec` | all six | C4, C6, C7, C24 (transceiver); only the provider delivers | +| `ProviderGatewayRoleSpec` | CCIP, Hyperlane, Wormhole, `op-stack-l2-l2` (LayerZero's OApp and `op-stack-l1-l2`'s messenger table check the caller themselves) | The transceiver's initializer grants the provider `GATEWAY_ROLE` | +| `ProviderGovernorHomeSpec` | the five transceivers with an id table | The governor home's id and route are set at initialization (#28) | | `ProviderZkSyncSpec` | the four zkSync transceivers | A receiver report is paid from the float, never the relayer, and any excess returns to the float | Protocol-level properties no binding can change are covered once, by the core tests named @@ -813,11 +813,11 @@ below. The column says where each line is held. | # | Property | Asserts | Covered by | | --- | --- | --- | --- | -| C1 | `send_reachesTheProviderWithTheRightRoute` | The provider was handed the destination the recipient's chain resolves to: its id for that chain, or for OP Stack its paired messenger. | `ProviderSendSpec`; transmitter lookup `ProviderIdTableSpec` | +| C1 | `send_reachesTheProviderWithTheRightRoute` | The provider was handed the destination the recipient's chain resolves to: its id for that chain, which for `op-stack-l1-l2` is the messenger that reaches it. | `ProviderSendSpec`; transmitter lookup `ProviderIdTableSpec` | | C2 | `send_toUnconfiguredDestinationReverts` | Reverts rather than falling back to a default. | `ProviderSendSpec` | | C3 | `send_addressesTheRecordedCounterpart` | Path A's destination is `counterpartOn(chainKey)`, which equals `address(this)` only on a parity chain. | core `Transport.t.sol` `test_aRecipientThatIsNotThisAccountIsRefused` | | C4 | `inbound_fromTheConfiguredOriginExecutes` | Round trip through `_onInbound`. | `ProviderInboundSpec`, `ProviderReceiveSpec` | -| C5 | `inbound_fromAnUnknownRouteReverts` | `UnknownRoute`, or the id table's refusal of an unmapped origin. | `ProviderIdTableSpec` (transceiver), OP Stack's `test_nothingIsAcceptedBeforeThePairedChainIsRouted`, `ProviderReceiveSpec` (account) | +| C5 | `inbound_fromAnUnknownRouteReverts` | `UnknownRoute`, or the id table's refusal of an unmapped origin. | `ProviderIdTableSpec` (transceiver), `op-stack-l1-l2`'s `test_nothingIsAcceptedFromAMessengerWhoseChainIsUnrouted`, `ProviderReceiveSpec` (account) | | C6 | `inbound_fromTheWrongSenderReverts` | `NotCounterpart` on a transceiver. | `ProviderInboundSpec`, `ProviderReceiveSpec` | | C7 | `inbound_senderBytesMatchTheRegistryExactly` | The [R4.2](#r4-the-byte-forms-which-are-the-authentication) trap, directly. | `ProviderInboundSpec` (asserts the chain key authentication accepted) | | C8 | `inbound_routeBytesRoundTripThroughTheCodec` | `chainKeyOfRoute(routeFor(k)) == k` for every configured chain. | core `DestinationNaming.t.sol`: the route is the chain identifier and the chainKey its hash | diff --git a/docs/todo.md b/docs/todo.md index 991ae13a..f58b3956 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -5,7 +5,7 @@ what rather than by size. [`message-flow.md`](message-flow.md) and [`encoding.md`](encoding.md) describe the design; this file is the gap between that design and the tree. -[`provider-spec.md`](provider-spec.md) is what the five bindings under +[`provider-spec.md`](provider-spec.md) is what the six bindings under `contracts/evm/src/protocols/` are held to; what they mean for an operator is in the README. --- @@ -17,9 +17,9 @@ this file is the gap between that design and the tree. Hyperlane 50,000 (the IGP default, written explicitly because the refund field follows it), the Wormhole Executor 200,000, and OP Stack's `minGasLimit` 200,000. `_reportReceiver` always sends with no attributes, and a bootstrap does unless its caller passes one; none of these - defaults is measured against either. Hyperlane's is probably too low. An OP Stack underestimate is recoverable (the messenger - records the failed relay and anyone can replay it with more gas); the others are not - known to be. + defaults is measured against either. Hyperlane's is probably too low. An OP Stack + underestimate is recoverable (the messenger records the failed relay and anyone can replay + it with more gas); the others are not known to be. - **The report float is not sized.** A zkSync or Tron transceiver pays every return report from its own float, in its own currency, for accounts homed on any chain, while each home charges its bootstrap fee in the home's currency. Nothing moves the fee to the chain that @@ -39,16 +39,11 @@ this file is the gap between that design and the tree. creation), so the cost of being wrong is a redeploy rather than a loss. zkSync Era's derivation (`ZkSyncAccounts`) is unverified the same way and needs the same one-account check. -- **Arachnid's factory on every target chain.** A chain is `Predetermined` only if its - transceivers were deployed through `CrossProxyDeployer`, which Arachnid's factory places - (#33). Ethereum, Base, Arbitrum One, OP Mainnet, and zkSync Era all have its code at its - address (checked over RPC, 2026-10-06, recorded in `contracts/evm/deploy/chains.toml`); a - chain added later is checked when it is. Code there is not enough on its own: zkSync Era has - it, but EraVM places `CrossProxyDeployer` elsewhere. A chain without it is `Unique` in every - registry and grades every other chain `Unique` in its own, so its transceivers cannot be - born configured: the governor's home is not `Predetermined` from there, and `initialize` - refuses that (#32). Supporting one needs a way to seed the home's counterpart on that chain, - such as an explicit counterpart in the registry seed. +- **A chain without Arachnid's factory is unsupported.** It is `Unique` in every registry and + grades every other chain `Unique` in its own (#33), so its transceivers cannot be born + configured: the governor's home is not `Predetermined` from there, and `initialize` refuses + that (#32). Supporting one needs a way to seed the home's counterpart on that chain, such as + an explicit counterpart in the registry seed. - **EIP-152 on every target chain.** Any chain can be a home, and the BLAKE2b commitment scheme needs the precompile at `0x09`. Without it `Blake2b256` fails closed (the scheme reverts), so the cost is the feature, not funds, but which target chains have it is not @@ -64,30 +59,17 @@ this file is the gap between that design and the tree. ## 3. Infrastructure -- **Deploy scripts cover step 2 only.** `script/Deploy.s.sol` deploys each - provider's account implementations and transceiver on a standard EVM chain, through the - `script/deploy/` functions the test suites also deploy through +- **Deploy scripts cover step 2 only** ([spec §6](provider-spec.md#6-configuration-a-compliant-deployment-performs)). Not scripted: step 1 (the per-chain timelock, the seeded registry, the treasury, so the timelock design is enforced by nothing), steps 3 to 7, and the payloads that write the N × N tables every chain - needs about every other chain. Their source exists: `contracts/evm/deploy/` holds each - chain's derivation, from which every pair's grade follows, and each provider's ids, checked - by every test and deployment. Nothing generates the payloads from it yet. How many gateways - each transceiver's initializer names is the deployer's `GATEWAYS` input; they cannot be - added later. Wormhole's contracts link `WormholeMessage` (#29), which `forge script` deploys - first through Arachnid's factory. zkSync and Tron have deploy functions the tests use but no - production script: their bytecode, with `WormholeMessage` linked, is built by zksolc and - TRON-solc, which these scripts do not drive. The registry's write-once record is keyed by - the provider names `layerzero`, `ccip`, `hyperlane`, `wormhole`, and `op-stack`. -- **OP Stack reaches one L2 per chain (#41).** Each transceiver is bound to one paired chain, - and a chain has one transceiver per provider, so Ethereum's reaches one OP Stack L2. Decided: - one transceiver per chain, holding a write-once messenger per paired chain, with a - delivery's origin taken from which messenger called. Not implemented. + needs about every other chain, whose source is `contracts/evm/deploy/`. zkSync and Tron have + deploy functions the tests use but no production script: their bytecode, with + `WormholeMessage` linked, is built by zksolc and TRON-solc, which these scripts do not drive. - **The compliance suite's gaps** ([spec §8](provider-spec.md#8-the-compliance-suite) says where every line is held). C11 and C29 to C31 against real endpoints are the fork tests - below; Wormhole's own replay (C29 to C31) is already tested, since the binding owns it. - C24's check cannot see a collision inside a single call, so two fields an initializer sets - together are covered only by the suites that read them back. + below. C24's check cannot see a collision inside a single call, so two fields an + initializer sets together are covered only by the suites that read them back. - **No fork tests.** Every binding is tested against a mock of its provider. C11, and C29 to C31 for every provider but Wormhole, test the transport rather than the binding, so until they run against each provider's real deployment, P7 and P9 remain documented assumptions. @@ -138,19 +120,16 @@ blocks it. ## 5. Post-launch: Superchain interop -OP Stack stays the L1-to-L2 binding (#41). Superchain interop's -`L2ToL2CrossDomainMessenger` fits the one-transceiver-per-chain shape and becomes a separate -provider once it is on mainnet. Before building it: +`op-stack-l2-l2` is built over the `L2ToL2CrossDomainMessenger` predeploy and tested against a +mock, and deploys with an interop L2 as its governor's home. It is not deployable until interop +is live: `deploy/providers/op-stack-l2-l2.toml` lists no chain. Before listing one: -- **L2 to L2 only.** Ethereum is not in the interop set, so an Ethereum home still needs the - L1-to-L2 binding. -- **Dependency sets.** A message executes only if its source is in the destination's - dependency set, so the routes must match each chain's set, and a bootstrap outside it must - revert at the quote. +- **Dependency sets are not recorded.** A message executes only if its source is in the + destination's dependency set, so a chain may be routed only within its set. Which chains + share a set is not in `deploy/`. - **Someone must relay.** `sendMessage` is non-payable and `relayMessage` permissionless, so delivery depends on an autorelayer or on this protocol relaying. -- **Retry and expiry are unverified.** A message relays at most once (`successfulMessages`); - whether a reverted relay can be retried, and whether a 7-day expiry on the source log - applies, is not settled. [Failure handling](message-flow.md#failure-handling) assumes - indefinite retry, so this is the provider checklist's work +- **Expiry is unverified.** Whether a source log expires after some window, ending its retry, + is not settled. [Failure handling](message-flow.md#failure-handling) assumes indefinite + retry, so this is the provider checklist's work ([spec §2](provider-spec.md#2-provider-prerequisites-the-go-or-no-go-checklist)).