Repository navigation
feat(op-stack): two OP Stack providers, Ethereum ↔ L2 and L2 ↔ L2 - #49
Conversation
For the L2-to-L2 OP Stack binding. Pinned to optimism develop @ dfe4f947, separately from the CrossDomainMessenger pin. The interop interface imports its sibling by contracts-bedrock's root-relative path, remapped only inside lib/optimism/.
…Ethereum The transceiver was bound to one paired chain by an implementation immutable, so Ethereum's could reach one OP Stack chain (#41). Now each chain's messenger is its provider id in ProviderChainId's write-once, injective table: on Ethereum one L1CrossDomainMessenger per OP Stack chain, on each OP Stack chain the L2CrossDomainMessenger predeploy. The same contract runs on both sides. A delivery's origin is the chain of the messenger that called, mapped before that messenger is asked for the sender; the table is the gate, so no messenger holds GATEWAY_ROLE. A receiver is armed with its home's messenger at initialization, and a transmitter sends through the messenger its transceiver maps the recipient's chain to. The provider is renamed op-stack-l1-l2, ahead of op-stack-l2-l2 for L2 to L2. deploy/providers/op-stack-l1-l2.toml records the messengers per direction, read from each L2's predeploy and checked back from Ethereum. Fixes #41
evmChainId reads the chain id back out of an eip155 identifier, the inverse of encodeEvmChain; none refuses every attribute, for a binding whose provider takes no per-message options. Both are for op-stack-l2-l2, whose messenger takes a destination chain id and no gas limit.
…home ProviderSendSpec's unconfigured recipient sat on chain 1, which every fixture names as the governor's home, and a transceiver is born routed to its home. Bindings with an id table still refused it (no id for chain 1), so the tests passed for the wrong reason, and a binding gated only by its routes would fail them. It now sits on a chain nothing configures.
…interop A second OP Stack provider over Superchain interop's L2ToL2CrossDomainMessenger predeploy, with its own record and so its own transceiver address on every chain. The messenger names a destination by its chain id, which the recipient carries, so there is no id table: the route gates every send and quote, on the transceiver and on transmitters. A delivery's origin is the source chain the messenger reports, which must be routed. Sends carry no value and no attributes; a relayer picks the gas. Deployment waits for interop. The shared deploy refuses a chain whose predeploy has no code, and the production script refuses any chain, or governor's home, that deploy/providers/op-stack-l2-l2.toml does not list; it lists none yet. Tested against a mock of the predeploy at its address.
…t and value error Both messengers call their target with calldata the sender chose, and a contract serves one provider, so op-stack-l2-l2 now takes messages at IOpStackRecipient.receiveOpStackMessage and refuses value with OpStackMessage.OpStackValueNotSupported; its own interface and error are gone. Its sources and tests move into op-stack/ beside op-stack-l1-l2's.
…s home The initializer already reverts for an unregistered home, but only as a bare UnknownChainKey. DeployProvider now refuses it by name before deploying. It matters most for op-stack-l2-l2, whose L2 home must be registered on chains whose registries were seeded with another home.
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
It introduces two new security-sensitive cross-chain messaging providers with reworked inbound authentication, vendored external interfaces, and new deploy scripts, which warrant final human review.
Review effort: Balanced
Findings: None
What changed in this PR
This PR splits the single op-stack message provider into two distinct providers, each with its own registry record (and therefore its own transceiver address per chain), resolving the one-L2-per-chain limitation in issue #41:
op-stack-l1-l2(Ethereum ↔ each OP Stack chain, overCrossDomainMessenger): the provider id for a chain becomes the address of the messenger that reaches it, held inProviderChainId's write-once injective table. A delivery's origin is derived from which messenger called (the table is the gate, so no messenger holdsGATEWAY_ROLE), and the receiver is armed with its home's messenger at initialization.op-stack-l2-l2(L2 ↔ L2, over Superchain interop'sL2ToL2CrossDomainMessengerpredeploy): names destinations by chain id, gated by routes rather than an id table; non-payable, no attributes, and not deployable until interop is live (its config lists no chains).
Changes:
- New interop binding (transceiver/receiver/transmitter/message) plus a rework of the L1↔L2 binding from immutable-pair to a messenger table; shared helpers
Erc7930.evmChainIdandProviderAttribute.none. - New/updated deploy scripts and TOML configs, a
DeployProviderguard that rejects a registry missing the governor's home, and a scoped foundry remapping for vendored interop interfaces. - Extensive doc updates (README, provider-spec, research, message-flow, todo, CLAUDE.md) and tests/fixtures, with a fix to
ProviderSendSpec's unconfigured-recipient chain.
| File | Description |
|---|---|
src/protocols/op-stack/OpStackTransceiver.sol |
Reworked to ProviderTransceiver with a messenger-address id table; table-gated inbound. |
src/protocols/op-stack/OpStackReceiver.sol |
Messenger now a 3-arg initializer param; 2-arg initializer reverts. |
src/protocols/op-stack/OpStackMessage.sol |
Drops per-messenger chain check; messenger passed in from the table. |
src/protocols/op-stack/OpStackTransmitter.sol |
Reads messenger from the transceiver's table via providerIdOf. |
src/protocols/op-stack/OpStackInterop{Transceiver,Receiver,Transmitter,Message}.sol |
New op-stack-l2-l2 binding over the interop predeploy. |
src/protocols/{ProviderChainId,ProviderAttribute}.sol |
Doc update; new none attribute helper. |
src/addressing/Erc7930.sol |
New evmChainId inverse of encodeEvmChain. |
script/Deploy{OpStack,OpStackInterop}.s.sol, script/deploy/{OpStack,OpStackInterop}Deploy.sol, DeployProvider.s.sol, ChainConfig.sol |
Deploy wiring, config readers, and registry-home guard. |
deploy/**, test/deploy/config/**, foundry.toml, lib/optimism/** |
Provider TOMLs, test configs, remapping, vendored interfaces. |
test/protocols/op-stack/**, test/DestinationNaming.t.sol, test/deploy/ProductionDeploy.t.sol, test/protocols/{ProviderBindingSpec,ProviderAttribute,ZeroEndpoint}* |
New/updated fixtures and specs. |
README.md, docs/**, CLAUDE.md |
Documentation for the two-provider model. |
I verified the authentication model (table-gated vs GATEWAY_ROLE-gated inbound), the evmChainId bounds (chainRef capped at 32 bytes by parseStrict, EVM-only), the deploy guards and config validation, the foundry context-scoped remapping, and that doc/test-name cross-references resolve. The changes are internally consistent and well-covered by the shared spec suite, and I did not identify a concrete defect within the changed regions.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…in its README bullet
Fixes #41.
OP Stack becomes two providers, each with its own registry record and so its own transceiver address on every chain.
op-stack-l1-l2: Ethereum ↔ each OP Stack chain (overCrossDomainMessenger)ProviderChainId's write-once, injective table: on Ethereum oneL1CrossDomainMessengerper OP Stack chain, on each OP Stack chain the0x4200…0007predeploy. The same contract runs on both sides, so Ethereum's transceiver now reaches every OP Stack chain (OP Stack: one registry record per provider name allows one paired L2 per chain #41).GATEWAY_ROLE.deploy/providers/op-stack-l1-l2.tomlrecords the messengers per direction. Base's and OP Mainnet'sL1CrossDomainMessengerwere read from each L2's predeploy and checked back from Ethereum; OP Mainnet's also matches itsSystemConfig.op-stack-l2-l2: L2 ↔ L2 (over Superchain interop'sL2ToL2CrossDomainMessenger,0x4200…0023)sendMessageis non-payable and takes no gas limit; a relayer picks the gas.op-stack-l1-l2's entry point (IOpStackRecipient.receiveOpStackMessage) and value error; sources and tests live beside it inop-stack/.deploy/providers/op-stack-l2-l2.tomldoes not list. It lists none yet. Tested against a mock of the predeploy at its address.Also
IL2ToL2CrossDomainMessengerandICrossL2Inboxat optimismdfe4f947, with a remapping scoped tolib/optimism/.Erc7930.evmChainId(inverse ofencodeEvmChain, fuzzed) andProviderAttribute.none.DeployProviderrefuses a registry that has not registered the governor's home, by name, rather than failing inside the initializer withUnknownChainKey.ProviderSendSpec's "unconfigured" recipient sat on the governor's home, which every transceiver is born routed to, so id-table bindings passed for the wrong reason. Fixed in its own commit. The same specs caught the first interop version sending to unrouted chains.message-flow.md, research §2/§7, the adding-a-chain guide,CLAUDE.md's per-byte line, anddocs/todo.md, which also drops items that are now decided or done.749 → 754 tests; fmt, both lint profiles, and the size check pass. Each removed or rewritten guard was checked by putting the old code back and watching its test fail.