Skip to content

feat(op-stack): two OP Stack providers, Ethereum ↔ L2 and L2 ↔ L2 - #49

Merged
pegahcarter merged 10 commits into
mainfrom
feat/op-stack-two-providers
Oct 7, 2026
Merged

pegahcarter merged 10 commits into
mainfrom
feat/op-stack-two-providers

Conversation

@pegahcarter

Copy link
Copy Markdown
Contributor

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 (over CrossDomainMessenger)

  • 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 0x4200…0007 predeploy. 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).
  • A delivery's origin is the chain of the messenger that called, mapped through the table 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; a transmitter sends through the messenger its transceiver maps the recipient's chain to.
  • deploy/providers/op-stack-l1-l2.toml records the messengers per direction. Base's and OP Mainnet's L1CrossDomainMessenger were read from each L2's predeploy and checked back from Ethereum; OP Mainnet's also matches its SystemConfig.

op-stack-l2-l2: L2 ↔ L2 (over Superchain interop's L2ToL2CrossDomainMessenger, 0x4200…0023)

  • The destination is the recipient's chain id; there is no id table, so the route gates every send and quote, on transceivers and transmitters. A delivery's origin is the source chain the messenger reports, which must be routed.
  • No value and no attributes: sendMessage is non-payable and takes no gas limit; a relayer picks the gas.
  • Shares op-stack-l1-l2's entry point (IOpStackRecipient.receiveOpStackMessage) and value error; sources and tests live beside it in op-stack/.
  • Deployed with an interop L2 as its governor's home. Deployment waits for interop: every 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.

Also

  • Vendored IL2ToL2CrossDomainMessenger and ICrossL2Inbox at optimism dfe4f947, with a remapping scoped to lib/optimism/.
  • Shared helpers: Erc7930.evmChainId (inverse of encodeEvmChain, fuzzed) and ProviderAttribute.none.
  • DeployProvider refuses a registry that has not registered the governor's home, by name, rather than failing inside the initializer with UnknownChainKey.
  • Found by the shared specs: 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.
  • Docs: README, spec §3/§8, message-flow.md, research §2/§7, the adding-a-chain guide, CLAUDE.md's per-byte line, and docs/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.

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.
Copilot AI balanced review requested due to automatic review settings October 7, 2026 07:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, over CrossDomainMessenger): the provider id for a chain becomes the address of the messenger that reaches it, held in ProviderChainId's write-once injective table. A delivery's origin is derived from which messenger called (the table is the gate, so no messenger holds GATEWAY_ROLE), and the receiver is armed with its home's messenger at initialization.
  • op-stack-l2-l2 (L2 ↔ L2, over Superchain interop's L2ToL2CrossDomainMessenger predeploy): 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.evmChainId and ProviderAttribute.none.
  • New/updated deploy scripts and TOML configs, a DeployProvider guard 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.

@pegahcarter
pegahcarter merged commit 1104ccb into main Oct 7, 2026
1 check passed
@pegahcarter
pegahcarter deleted the feat/op-stack-two-providers branch October 7, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OP Stack: one registry record per provider name allows one paired L2 per chain

2 participants