Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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/<provider>.sh`. An upstream
security fix has to be noticed, re-vendored, and diffed by hand. It then reaches no
Expand Down Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions contracts/evm/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ Before mainnet, from [todo §2](../../../docs/todo.md#2-chain-checks-before-main
`chain_id` and `derivation`.
- [`providers/<provider>.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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions contracts/evm/deploy/providers/op-stack-l1-l2.toml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions contracts/evm/deploy/providers/op-stack-l2-l2.toml
Original file line number Diff line number Diff line change
@@ -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 = []
3 changes: 3 additions & 0 deletions contracts/evm/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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_);
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading