diff --git a/docs/BROADCASTER.md b/docs/BROADCASTER.md new file mode 100644 index 0000000..cb73c9d --- /dev/null +++ b/docs/BROADCASTER.md @@ -0,0 +1,172 @@ +# Broadcaster + +The Broadcaster contract is a singleton deployed on each chain that enables publishing 32-byte messages on-chain. Messages are stored in deterministic storage slots, making them provable via storage proofs from other chains. + +## Core Concepts + +### Message Storage + +When a message is broadcast, the contract stores `block.timestamp` in a storage slot computed as: + +```solidity +slot = keccak256(abi.encode(message, publisher)) +``` + +This design provides several guarantees: + +1. **Deduplication**: Each `(message, publisher)` pair can only be broadcast once +2. **Timestamping**: The exact broadcast time is recorded and provable +3. **Deterministic slots**: Any verifier can compute the expected slot without calling the contract + +### Publishers + +Any address that calls `broadcastMessage()` is considered a "publisher". The publisher's address becomes part of the storage slot calculation, meaning: + +- Different publishers can broadcast the same message +- A single publisher cannot broadcast the same message twice +- Applications requiring multiple broadcasts of the same logical message must implement nonces at the application layer + +## Interface + +```solidity +interface IBroadcaster { + /// @notice Emitted when a message is broadcast. + event MessageBroadcast(bytes32 indexed message, address indexed publisher); + + /// @notice Broadcasts a message. Callers are called "publishers". + /// @dev MUST revert if the publisher has already broadcast the message. + /// MUST emit MessageBroadcast. + /// MUST store block.timestamp in slot keccak(message, msg.sender). + function broadcastMessage(bytes32 message) external; +} +``` + +## Standard Broadcaster + +The standard `Broadcaster` contract implements the minimal ERC-7888 specification: + +```solidity +contract Broadcaster is IBroadcaster { + error MessageAlreadyBroadcasted(); + + function broadcastMessage(bytes32 message) external { + bytes32 slot = keccak256(abi.encode(message, msg.sender)); + + if (StorageSlot.getUint256Slot(slot).value != 0) { + revert MessageAlreadyBroadcasted(); + } + + StorageSlot.getUint256Slot(slot).value = block.timestamp; + + emit MessageBroadcast(message, msg.sender); + } +} +``` + +### Helper Function + +The contract also includes a view function to check if a message has been broadcast: + +```solidity +function hasBroadcasted(bytes32 message, address publisher) external view returns (bool) +``` + +This is not required by the standard but provides useful visibility without requiring storage proofs. + +## ZkSync Broadcaster + +ZkSync ERA has a unique architecture where L2 state is proven to L1 via L2 logs rather than storage proofs. The `ZkSyncBroadcaster` extends the standard broadcaster by sending an L2→L1 message for each broadcast: + +```solidity +contract ZkSyncBroadcaster is IBroadcaster { + IL1Messenger private _l1Messenger; + + function broadcastMessage(bytes32 message) external { + bytes32 slot = keccak256(abi.encode(message, msg.sender)); + + if (StorageSlot.getUint256Slot(slot).value != 0) { + revert MessageAlreadyBroadcasted(); + } + + StorageSlot.getUint256Slot(slot).value = block.timestamp; + + // Send to L1 via ZkSync's L1Messenger + _l1Messenger.sendToL1(abi.encode(slot, uint256(block.timestamp))); + + emit MessageBroadcast(message, msg.sender); + } +} +``` + +### L1Messenger Integration + +The `IL1Messenger` interface wraps ZkSync's native L2→L1 messaging system: + +```solidity +interface IL1Messenger { + function sendToL1(bytes calldata _message) external returns (bytes32); +} +``` + +The message sent to L1 contains: +- `slot`: The storage slot where the timestamp is stored (`bytes32`) +- `timestamp`: The block timestamp when the message was broadcast (`uint256`) + +This data is included in ZkSync batches and can be verified on L1 using the ZkSync `ParentToChildProver`. + +### Why ZkSync Needs a Custom Broadcaster + +Unlike other rollups where you can directly prove storage slots via Merkle-Patricia Trie proofs, ZkSync uses: + +1. **Different state tree structure**: ZkSync doesn't expose storage in a standard MPT format +2. **L2 logs for cross-chain communication**: ZkSync's native L2→L1 messaging system is the canonical way to communicate state to L1 +3. **Batch settlement**: Messages are grouped into batches and their inclusion is verified via Merkle proofs against the batch's L2 logs root hash + +The `ZkSyncBroadcaster` ensures messages are accessible via ZkSync's native proving mechanism. + +## Usage Example + +### Broadcasting a Message + +```solidity +// Prepare a 32-byte message +bytes32 message = keccak256(abi.encode( + "transfer", + recipient, + amount, + nonce +)); + +// Broadcast on the source chain +broadcaster.broadcastMessage(message); +``` + +### Verifying on Destination Chain + +See [RECEIVER.md](./RECEIVER.md) for details on verifying broadcast messages from other chains. + +## Storage Layout Verification + +The deterministic storage layout enables verification without trusting the Broadcaster contract's logic. Given: +- A block hash (or state root) from the source chain +- A storage proof for the Broadcaster contract + +Any verifier can: +1. Compute the expected slot: `keccak256(abi.encode(message, publisher))` +2. Verify the storage proof against the state root +3. Confirm the slot value (timestamp) is non-zero + +This makes the Broadcaster's storage "self-describing" - you don't need to trust any specific contract implementation to verify a message was broadcast. + +## Deployment Considerations + +- The Broadcaster should be deployed as a singleton on each chain +- The contract address should be consistent across chains when possible (using CREATE2) +- For ZkSync chains, use `ZkSyncBroadcaster` instead of the standard `Broadcaster` +- The L1Messenger address on ZkSync ERA mainnet is a system contract + +## Related Documentation + +- [RECEIVER.md](./RECEIVER.md) - Verifying broadcast messages on destination chains +- [PROVERS.md](./PROVERS.md) - Understanding StateProvers and verification logic +- [provers/ZKSYNC.md](./provers/ZKSYNC.md) - ZkSync-specific prover implementation details diff --git a/docs/ERC7888.md b/docs/ERC7888.md deleted file mode 100644 index 96d6860..0000000 --- a/docs/ERC7888.md +++ /dev/null @@ -1,299 +0,0 @@ -# ERC-7888: Crosschain Broadcaster - -**Reference:** [EIP-7888 Specification](https://eips.ethereum.org/EIPS/eip-7888) - -## Introduction - -ERC-7888 defines a protocol for trustless cross-chain message verification using cryptographic storage proofs. The protocol enables any chain to verify messages broadcast on any other chain that shares a common ancestor, creating a foundation for cross-chain interoperability without trusted intermediaries. - -### Core Components - -| Component | Description | -|-----------|-------------| -| **Broadcaster** | Stores messages in deterministic storage slots, one per (message, publisher) pair | -| **Receiver** | Verifies messages from remote chains using storage proofs | -| **StateProver** | Chain-specific logic for verifying state commitments and storage slots | -| **StateProverPointer** | Upgradeable pointer to the latest StateProver version | - -Each chain deploys singleton `Broadcaster` and `Receiver` contracts, enabling permissionless message broadcasting and verification across the ecosystem. - -### State Commitments - -A **state commitment** is a `bytes32` hash that commits to a chain's state at a particular point in time. The protocol supports different types of state commitments depending on what the rollup commits to its parent chain: - -- **Block hash** (recommended): A hash of the block header -- **State root**: The root of the state tree (e.g., Merkle-Patricia Trie) -- **Batch hash**: A hash of a batch of blocks (for rollups that commit batches) - ---- - -## Broadcasting Messages - -Publishers broadcast messages by calling `broadcastMessage()` on the Broadcaster contract: - -```solidity -interface IBroadcaster { - /// @notice Emitted when a message is broadcast. - event MessageBroadcast(bytes32 indexed message, address indexed publisher); - - /// @notice Broadcasts a message. Callers are called "publishers". - /// @dev MUST revert if the publisher has already broadcast the message. - /// MUST emit MessageBroadcast. - /// MUST store block.timestamp in slot keccak(message, msg.sender). - function broadcastMessage(bytes32 message) external; -} -``` - -### Storage Layout - -The Broadcaster stores `block.timestamp` in storage slot `keccak256(abi.encode(message, publisher))`, creating a unique slot per (message, publisher) pair. - -**Key constraints:** -- Each publisher can only broadcast a specific message **once** -- Applications requiring multiple broadcasts of the same logical message must implement nonce logic at the application layer - ---- - -## Message Verification - -### Routes - -A **route** is a relative path from a Receiver on a local chain to a remote chain. Routes are constructed from StateProverPointer addresses, where each pointer lives on its home chain and references a StateProver that can prove the next chain's state commitment. - -**Route validity rules:** -- Home chain of `route[0]` must equal the local chain -- Target chain of `route[i]` must equal home chain of `route[i+1]` - -### Remote Account Identifiers - -Accounts on remote chains are identified by accumulating the route addresses plus the remote address: - -```solidity -function accumulator(address[] memory elems) pure returns (bytes32 acc) { - for (uint256 i = 0; i < elems.length; i++) { - acc = keccak256(abi.encode(acc, elems[i])); - } -} -``` - -IDs depend on the route and are therefore always **relative** to a local chain. The same account on a given chain will have different IDs depending on the route taken. - -### Verification Process: `verifyBroadcastMessage` - -```solidity -interface IReceiver { - struct RemoteReadArgs { - address[] route; // StateProverPointer addresses along the route - bytes[] scpInputs; // Inputs for each StateProver - bytes proof; // Final storage proof for the message slot - } - - function verifyBroadcastMessage( - RemoteReadArgs calldata broadcasterReadArgs, - bytes32 message, - address publisher - ) external view returns (bytes32 broadcasterId, uint256 timestamp); -} -``` - -**Three-stage verification:** - -1. **Initial State Commitment Retrieval**: For the first hop, the Receiver calls the StateProverPointer at `route[0]` to get the implementation address, then calls `getTargetStateCommitment()`. Since this executes on the home chain, the prover directly accesses the target chain's state commitment. - -2. **State Commitment Chain Verification**: For subsequent hops, the Receiver uses locally stored StateProverCopies to call `verifyTargetStateCommitment()` with the previous chain's state commitment and a proof. This builds a chain: `stateCommitment[0] → stateCommitment[1] → ... → stateCommitment[n]`. - -3. **Storage Slot Verification**: With the target chain's state commitment established, the Receiver calls `verifyStorageSlot()` on the final StateProver, verifying: - - The slot matches `keccak256(abi.encode(message, publisher))` - - The slot value is non-zero (message was broadcast) - -The slot value (`block.timestamp`) is returned as the timestamp. - ---- - -## StateProvers - -A `StateProver` implements chain-specific verification logic. Each rollup stores parent/child state commitments differently, requiring custom prover implementations. - -```solidity -interface IStateProver { - /// @notice Get the state commitment of the target chain when called on the home chain. - /// @dev MUST revert if not called on the home chain. - function getTargetStateCommitment(bytes calldata input) - external view returns (bytes32 targetStateCommitment); - - /// @notice Verify the state commitment of the target chain from a remote chain. - /// @dev MUST revert if called on the home chain. - function verifyTargetStateCommitment(bytes32 homeStateCommitment, bytes calldata input) - external view returns (bytes32 targetStateCommitment); - - /// @notice Verify a storage slot given a target chain state commitment. - function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) - external view returns (address account, uint256 slot, bytes32 value); - - /// @notice The version of this prover implementation. - function version() external pure returns (uint256); -} -``` - -### Operating Modes - -| Mode | Function | Context | -|------|----------|---------| -| **Home chain** | `getTargetStateCommitment()` | Directly reads target chain's state commitment from storage | -| **Remote chain** | `verifyTargetStateCommitment()` | Verifies a proof establishing the target chain's state commitment | - -### Prover Requirements - -- **Code hash consistency**: StateProvers MUST ensure they have the same deployed code hash on all chains -- **Pure functions**: `verifyTargetStateCommitment()`, `verifyStorageSlot()`, and `version()` MUST be pure (with exception: MAY read `address(this).code`) -- **Chain-specific logic**: Each prover is fixed to a specific (home chain, target chain) pair - ---- - -## StateProverPointers - -Rollup storage layouts may change over time, requiring prover updates. However, routes reference provers by address, and changing addresses would break existing routes. `StateProverPointers` provide indirection to enable upgrades without breaking routes. - -```solidity -// Fixed storage slot for the code hash -uint256 constant STATE_PROVER_POINTER_SLOT = uint256(keccak256("eip7888.pointer.slot")) - 1; - -interface IStateProverPointer { - /// @notice Return the code hash of the latest version of the prover. - function implementationCodeHash() external view returns (bytes32); - - /// @notice Return the address of the latest version of the prover on the home chain. - function implementationAddress() external view returns (address); -} -``` - -### Upgrade Process - -1. Deploy a new `StateProver` with a higher `version()` number -2. Pointer owner calls `setImplementationAddress()` to point to the new prover -3. Pointer stores the new prover's code hash in `STATE_PROVER_POINTER_SLOT` -4. Receivers update local copies via `updateStateProverCopy()`, which verifies the code hash matches - -**Upgrade constraints:** -- The new StateProver MUST have the same home and target chains -- The new StateProver MUST have a higher `version()` than the previous one - -### Security Considerations for Pointer Ownership - -The StateProverPointer owner can DoS or forge messages. Therefore: - -| Target Chain | Expected Owner | -|--------------|----------------| -| Parent chain | Home chain owner | -| Child chain | Target chain owner | - ---- - -## StateProverCopies - -Receivers cannot call contracts on remote chains directly. To verify proofs from remote chains, Receivers maintain local copies of StateProvers with matching code. - -### Copy Registration - -```solidity -function updateStateProverCopy( - RemoteReadArgs calldata scpPointerReadArgs, - IStateProver scpCopy -) external returns (bytes32 scpPointerId); -``` - -The Receiver verifies: -1. The proof reads from `STATE_PROVER_POINTER_SLOT` -2. The copy's code hash matches the Pointer's stored code hash -3. The copy's version is higher than any existing copy (if updating) - -Copies are stored keyed by `scpPointerId`, calculated by accumulating hashes of addresses in the route to the Pointer. - ---- - -## Complete Interface Reference - -### IBroadcaster - -```solidity -interface IBroadcaster { - event MessageBroadcast(bytes32 indexed message, address indexed publisher); - - function broadcastMessage(bytes32 message) external; -} -``` - -### IReceiver - -```solidity -interface IReceiver { - struct RemoteReadArgs { - address[] route; - bytes[] scpInputs; - bytes proof; - } - - function verifyBroadcastMessage( - RemoteReadArgs calldata broadcasterReadArgs, - bytes32 message, - address publisher - ) external view returns (bytes32 broadcasterId, uint256 timestamp); - - function updateStateProverCopy( - RemoteReadArgs calldata scpPointerReadArgs, - IStateProver scpCopy - ) external returns (bytes32 scpPointerId); - - function stateProverCopy(bytes32 scpPointerId) - external view returns (IStateProver scpCopy); -} -``` - -### IStateProver - -```solidity -interface IStateProver { - function verifyTargetStateCommitment(bytes32 homeStateCommitment, bytes calldata input) - external view returns (bytes32 targetStateCommitment); - - function getTargetStateCommitment(bytes calldata input) - external view returns (bytes32 targetStateCommitment); - - function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) - external view returns (address account, uint256 slot, bytes32 value); - - function version() external pure returns (uint256); -} -``` - -### IStateProverPointer - -```solidity -interface IStateProverPointer { - function implementationCodeHash() external view returns (bytes32); - function implementationAddress() external view returns (address); -} -``` - ---- - -## Security Considerations - -### Chain Upgrades - -If a chain upgrades such that a StateProver's verification functions might return data besides a finalized target state commitment, invalid messages could be read. Either: -- The StateProver should detect such changes -- The chain owner responsible for storage layout changes should also own the StateProverPointer - -### Message Guarantees - -This protocol ensures messages **CAN** be read, not that they **WILL** be read. It is the Receiver caller's responsibility to choose which messages to read. - -Since the protocol only uses finalized blocks, messages may take time to propagate. Finalization occurs sequentially in the route, so total propagation time equals the sum of finalization times at each step. - ---- - -## Links - -- **EIP Specification**: [EIP-7888](https://eips.ethereum.org/EIPS/eip-7888) -- **Discussion**: [Ethereum Magicians Forum](https://ethereum-magicians.org/t/new-erc-cross-chain-broadcaster/22927) diff --git a/docs/PROVERS.md b/docs/PROVERS.md new file mode 100644 index 0000000..1c33f3b --- /dev/null +++ b/docs/PROVERS.md @@ -0,0 +1,375 @@ +# StateProvers, Pointers, and Copies + +This document explains the architecture of StateProvers, how they work with StateProverPointers for upgradeability, and the mechanism of StateProverCopies for cross-chain verification. + +## Overview + +The verification system consists of three components: + +| Component | Role | Location | +|-----------|------|----------| +| **StateProver** | Contains chain-specific verification logic | Deployed on home chain | +| **StateProverPointer** | Upgradeable reference to a StateProver | Deployed on home chain | +| **StateProverCopy** | Exact bytecode copy of a StateProver | Deployed on any chain | + +## StateProver + +A StateProver implements chain-specific logic to: +1. Retrieve or verify state commitments between two chains +2. Verify storage proofs against those state commitments + +Each prover is **unidirectional**, fixed to a specific `(home chain, target chain)` pair. + +### Interface + +```solidity +interface IStateProver { + /// @notice Get state commitment when called on the home chain. + /// @dev MUST revert if not on home chain. + function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment); + + /// @notice Verify state commitment from a remote chain. + /// @dev MUST revert if called on home chain. + function verifyTargetStateCommitment(bytes32 homeStateCommitment, bytes calldata input) + external view returns (bytes32 targetStateCommitment); + + /// @notice Verify a storage slot given a target state commitment. + function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) + external view returns (address account, uint256 slot, bytes32 value); + + /// @notice Version number for upgrade ordering. + function version() external pure returns (uint256); +} +``` + +### Operating Modes + +StateProvers have two operating modes based on the calling chain: + +| Mode | Function | Behavior | +|------|----------|----------| +| **Home chain** | `getTargetStateCommitment()` | Directly reads target chain's state commitment from local storage | +| **Remote chain** | `verifyTargetStateCommitment()` | Verifies a proof to derive target chain's state commitment | + +```solidity +// Home chain check pattern used in all provers +function getTargetStateCommitment(bytes calldata input) external view returns (bytes32) { + if (block.chainid != homeChainId) { + revert CallNotOnHomeChain(); + } + // Direct storage read... +} + +function verifyTargetStateCommitment(bytes32 homeStateCommitment, bytes calldata input) + external view returns (bytes32) +{ + if (block.chainid == homeChainId) { + revert CallOnHomeChain(); + } + // Proof verification... +} +``` + +### Prover Types + +Each chain needs two provers: + +1. **ChildToParentProver**: Proves parent chain state from child chain + - Home: Child chain (L2) + - Target: Parent chain (L1) + +2. **ParentToChildProver**: Proves child chain state from parent chain + - Home: Parent chain (L1) + - Target: Child chain (L2) + +### Purity Requirements + +To ensure consistent behavior across chains, provers must be **pure** (with one exception): + +- `verifyTargetStateCommitment()`, `verifyStorageSlot()`, and `version()` MUST NOT access storage +- Exception: MAY read `address(this).code` (for bytecode introspection) +- `getTargetStateCommitment()` may make external calls (only runs on home chain) + +## StateProverPointer + +Rollup storage layouts may change over time, requiring prover updates. StateProverPointers provide **upgradeable indirection**: + +``` +Route Address → StateProverPointer → StateProver + (fixed) (fixed) (upgradeable) +``` + +### Interface + +```solidity +interface IStateProverPointer { + /// @notice Returns the code hash of the current StateProver. + function implementationCodeHash() external view returns (bytes32); + + /// @notice Returns the address of the current StateProver. + function implementationAddress() external view returns (address); +} +``` + +### Storage Slot + +The pointer stores the implementation's code hash in a well-known slot: + +```solidity +bytes32 constant STATE_PROVER_POINTER_SLOT = + bytes32(uint256(keccak256("eip7888.pointer.slot")) - 1); +``` + +This deterministic slot enables verification of the pointer's state from remote chains. + +### Implementation + +```solidity +contract StateProverPointer is IStateProverPointer, Ownable { + address internal _implementationAddress; + + function setImplementationAddress(address _newImplementation) external onlyOwner { + // Verify it's a valid StateProver + uint256 newVersion = IStateProver(_newImplementation).version(); + + // Ensure version increases + if (_implementationAddress != address(0)) { + uint256 oldVersion = IStateProver(_implementationAddress).version(); + if (newVersion <= oldVersion) { + revert NonIncreasingVersion(newVersion, oldVersion); + } + } + + _implementationAddress = _newImplementation; + + // Store code hash in the well-known slot + StorageSlot.getBytes32Slot(STATE_PROVER_POINTER_SLOT).value = + _newImplementation.codehash; + } +} +``` + +### Upgrade Process + +1. Deploy new StateProver with higher `version()` number +2. Owner calls `setImplementationAddress()` on the pointer +3. Pointer stores the new prover's code hash +4. Receivers update their local copies via `updateStateProverCopy()` + +### Upgrade Constraints + +- New StateProver MUST have same home and target chains +- New StateProver MUST have strictly higher version +- Existing routes continue to work (route addresses don't change) + +### Pointer Ownership + +The pointer owner has significant power (can DoS or enable message forgery). Recommended ownership: + +| Target Chain Type | Recommended Owner | +|-------------------|-------------------| +| Parent chain (L1) | Home chain owner (L2 governance) | +| Child chain (L2) | Target chain owner (L2 governance) | + +The general rule: whoever can modify the chain's state commitment storage should own the pointer. + +## StateProverCopies + +Receivers cannot call contracts on remote chains. To verify proofs, they maintain **local copies** of StateProvers with identical bytecode. + +### Why Copies? + +``` +Chain A (Home) Chain B (Local/Receiver) +┌──────────────────┐ ┌──────────────────┐ +│ │ │ │ +│ StateProver │ │ StateProverCopy │ +│ (original) │ ═══════ │ (same bytecode) │ +│ │ │ │ +│ codehash: 0x123 │ │ codehash: 0x123 │ +└──────────────────┘ └──────────────────┘ +``` + +Since the prover's verification functions are pure (deterministic), a copy with identical bytecode will produce identical results. + +### Registration Flow + +```solidity +function updateStateProverCopy( + RemoteReadArgs calldata scpPointerReadArgs, + IStateProver scpCopy +) external returns (bytes32 scpPointerId) { + // 1. Verify the remote pointer's storage slot + (scpPointerId, slot, scpCodeHash) = _readRemoteSlot(scpPointerReadArgs); + + // 2. Ensure we're reading the code hash slot + if (slot != uint256(STATE_PROVER_POINTER_SLOT)) { + revert WrongStateProverPointerSlot(); + } + + // 3. Verify local copy matches remote pointer's code hash + if (address(scpCopy).codehash != scpCodeHash) { + revert DifferentCodeHash(); + } + + // 4. Ensure version is increasing + IStateProver oldCopy = _stateProverCopies[scpPointerId]; + if (address(oldCopy) != address(0) && oldCopy.version() >= scpCopy.version()) { + revert NewerProverVersion(); + } + + // 5. Store the copy + _stateProverCopies[scpPointerId] = scpCopy; +} +``` + +### Copy ID Calculation + +Copies are stored keyed by their **pointer ID**, which is the accumulated hash of the route to the pointer: + +```solidity +// Route: [PointerA, PointerB] → scpPointerId = accumulator([0, PointerA, PointerB]) +bytes32 scpPointerId = keccak256(abi.encode( + keccak256(abi.encode(bytes32(0), PointerA)), + PointerB +)); +``` + +This means the same prover accessed via different routes will have different IDs. + +### Deploying Copies + +To deploy a StateProverCopy: + +1. Get the original prover's bytecode from the home chain +2. Deploy with identical bytecode on the local chain (using `CREATE2` or `vm.etch` in tests) +3. Call `receiver.updateStateProverCopy()` with the appropriate proof + +```solidity +// In tests, using Foundry's vm.etch +function _deployProverCopy(address original) internal returns (address copy) { + bytes memory bytecode = original.code; + copy = makeAddr("proverCopy"); + vm.etch(copy, bytecode); + // copy.codehash == original.codehash +} +``` + +## Verification Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Receiver (Local Chain) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ route[0] route[1] route[n] │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │Pointer │ │ Copy │ │ Copy │ │ +│ │(local) │ │(remote │ │(remote │ │ +│ └───┬────┘ │pointer)│ │pointer)│ │ +│ │ └───┬────┘ └───┬────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │Prover │ ──stateComm──► │Prover │ ──stateComm──► │Prover │ │ +│ │(local) │ │Copy │ │Copy │ │ +│ └───┬────┘ └───┬────┘ └───┬────┘ │ +│ │ │ │ │ +│ │ getTargetState │ verifyTargetState │ verify │ +│ │ Commitment() │ Commitment() │ Storage │ +│ │ │ │ Slot() │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ State Commitment Chain │ │ +│ │ stateComm[0] → stateComm[1] → ... → stateComm[n] → storage slot │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## ProverUtils Library + +The `ProverUtils` library provides common verification utilities: + +### Block Header Verification + +```solidity +function getSlotFromBlockHeader( + bytes32 blockHash, + bytes memory rlpBlockHeader, + address account, + uint256 slot, + bytes memory rlpAccountProof, + bytes memory rlpStorageProof +) internal pure returns (bytes32 value); +``` + +1. Verifies `keccak256(rlpBlockHeader) == blockHash` +2. Extracts state root from block header +3. Verifies account proof against state root +4. Verifies storage proof against account's storage root + +### State Root Verification + +```solidity +function getStorageSlotFromStateRoot( + bytes32 stateRoot, + bytes memory rlpAccountProof, + bytes memory rlpStorageProof, + address account, + uint256 slot +) internal pure returns (bytes32 value); +``` + +For chains that store state roots directly (like Scroll), this skips the block header step. + +### RLP Indices + +```solidity +uint256 internal constant STATE_ROOT_INDEX = 3; // In block header +uint256 internal constant CODE_HASH_INDEX = 3; // In account data +uint256 internal constant STORAGE_ROOT_INDEX = 2; // In account data +``` + +## Security Considerations + +### Code Hash Verification + +The code hash matching requirement ensures: +- No malicious prover implementations +- Deterministic verification results +- Trust-minimized cross-chain proofs + +### Version Monotonicity + +Version numbers must strictly increase to prevent: +- Rollback to vulnerable versions +- Replay attacks with old provers +- Inconsistent state across chains + +### Chain-Specific Risks + +Different chains have different trust assumptions: + +| Chain | State Commitment | Finality | Notes | +|-------|-----------------|----------|-------| +| Ethereum | Block hash | ~15 min | Strong finality | +| Arbitrum | Block hash via Outbox | ~1 week | Challenge period | +| Optimism | Block hash via L1Block | ~7 days | Challenge period (older) / instant with validity proofs | +| Scroll | State root | ~hours | ZK proven | +| ZkSync | L2 logs root | ~hours | ZK proven | +| Linea | SMT state root | ~hours | ZK proven, uses MiMC hashing | +| Taiko | Block hash via SignalService | ~hours | ZK proven | + +## Related Documentation + +- [RECEIVER.md](./RECEIVER.md) - How the Receiver uses provers +- Chain-specific implementations: + - [provers/ARBITRUM.md](./provers/ARBITRUM.md) + - [provers/OPTIMISM.md](./provers/OPTIMISM.md) + - [provers/LINEA.md](./provers/LINEA.md) + - [provers/SCROLL.md](./provers/SCROLL.md) + - [provers/TAIKO.md](./provers/TAIKO.md) + - [provers/ZKSYNC.md](./provers/ZKSYNC.md) diff --git a/docs/README.md b/docs/README.md index 43ecf1a..3459dc1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,44 +1,90 @@ -# Documentation - -## ERC-7888 Crosschain Broadcaster +# ERC-7888 Crosschain Broadcaster Documentation This repository implements [ERC-7888](https://eips.ethereum.org/EIPS/eip-7888), a protocol for trustless cross-chain message verification using cryptographic storage proofs. -### Documentation Index +## Overview + +ERC-7888 enables any chain to verify messages broadcast on any other chain that shares a common ancestor, creating a foundation for cross-chain interoperability without trusted intermediaries. + +### Core Components + +| Component | Description | Documentation | +|-----------|-------------|---------------| +| **Broadcaster** | Stores messages in deterministic storage slots | [BROADCASTER.md](BROADCASTER.md) | +| **Receiver** | Verifies messages from remote chains using storage proofs | [RECEIVER.md](RECEIVER.md) | +| **StateProver** | Chain-specific logic for verifying state commitments | [PROVERS.md](PROVERS.md) | +| **StateProverPointer** | Upgradeable pointer to StateProver implementations | [PROVERS.md](PROVERS.md) | + +## Documentation Index + +### Core Documentation | Document | Description | |----------|-------------| -| [ERC7888.md](ERC7888.md) | Complete specification overview, interfaces, and protocol mechanics | -| [PROVERS.md](PROVERS.md) | Chain-specific StateProver implementations and how to add new ones | -| [TUTORIAL.md](TUTORIAL.md) | Step-by-step guide to broadcasting and verifying messages | +| [BROADCASTER.md](BROADCASTER.md) | How to broadcast messages, storage layout, ZkSync broadcaster | +| [RECEIVER.md](RECEIVER.md) | Message verification, routes, prover copies, usage examples | +| [PROVERS.md](PROVERS.md) | StateProver architecture, pointers, copies mechanism | + +### Chain-Specific Provers + +| Chain | Documentation | State Commitment Type | +|-------|--------------|----------------------| +| Arbitrum | [provers/ARBITRUM.md](provers/ARBITRUM.md) | Block hash via Outbox | +| Optimism | [provers/OPTIMISM.md](provers/OPTIMISM.md) | Block hash via L1Block/FaultDisputeGame | +| Linea | [provers/LINEA.md](provers/LINEA.md) | SMT state root (MiMC) | +| Scroll | [provers/SCROLL.md](provers/SCROLL.md) | State root (direct) | +| Taiko | [provers/TAIKO.md](provers/TAIKO.md) | Block hash via SignalService | +| ZkSync | [provers/ZKSYNC.md](provers/ZKSYNC.md) | L2 logs root hash | + +## Quick Start + +### 1. Broadcasting a Message + +```solidity +// On source chain +IBroadcaster broadcaster = IBroadcaster(BROADCASTER_ADDRESS); +bytes32 message = keccak256(abi.encode(yourData)); +broadcaster.broadcastMessage(message); +``` + +### 2. Verifying a Message -### Quick Links +```solidity +// On destination chain +IReceiver receiver = IReceiver(RECEIVER_ADDRESS); -- **Specification**: [EIP-7888](https://eips.ethereum.org/EIPS/eip-7888) -- **Discussion**: [Ethereum Magicians](https://ethereum-magicians.org/t/new-erc-cross-chain-broadcaster/22927) +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, // StateProverPointer addresses + scpInputs: scpInputs, // Inputs for each prover + proof: storageProof // Final storage proof +}); -### Getting Started +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` -1. Read [ERC7888.md](ERC7888.md) for protocol fundamentals -2. Follow [TUTORIAL.md](TUTORIAL.md) to implement your first cross-chain message -3. Reference [PROVERS.md](PROVERS.md) for chain-specific details +See [RECEIVER.md](RECEIVER.md) for detailed examples. -### Supported Chains +## Supported Chains -| Chain | ChildToParent | ParentToChild | -|-------|---------------|---------------| -| Arbitrum | ✅ | ✅ | -| Optimism | ✅ | ✅ | -| Linea | ✅ | ✅ | -| Scroll | ✅ | ✅ | -| zkSync Era | ✅ | ✅ | -| Taiko | ✅ | ✅ | +| Chain | ChildToParent | ParentToChild | Notes | +|-------|:-------------:|:-------------:|-------| +| Arbitrum | ✅ | ✅ | Uses block hash buffer on L2, Outbox on L1 | +| Optimism | ✅ | ✅ | L1Block predeploy, FaultDisputeGame proofs | +| Linea | ✅ | ✅ | Sparse Merkle Tree with MiMC hashing | +| Scroll | ✅ | ✅ | Direct state roots (simplified proofs) | +| Taiko | ✅ | ✅ | SignalService checkpoints on both chains | +| ZkSync ERA | ✅ | ✅ | L2 logs Merkle proofs, custom Broadcaster | -### Core Contracts +## Contract Structure ``` src/contracts/ -├── Broadcaster.sol # Message broadcasting +├── Broadcaster.sol # Standard message broadcasting +├── ZkSyncBroadcaster.sol # ZkSync-specific broadcaster ├── Receiver.sol # Message verification ├── StateProverPointer.sol # Upgradeable prover reference ├── interfaces/ @@ -46,11 +92,48 @@ src/contracts/ │ ├── IReceiver.sol │ ├── IStateProver.sol │ └── IStateProverPointer.sol +├── libraries/ +│ ├── ProverUtils.sol # MPT verification utilities +│ └── linea/ +│ ├── Mimc.sol # MiMC hash function +│ └── SparseMerkleProof.sol └── provers/ ├── arbitrum/ + │ ├── ChildToParentProver.sol + │ └── ParentToChildProver.sol ├── optimism/ + │ ├── ChildToParentProver.sol + │ └── ParentToChildProver.sol ├── linea/ + │ ├── ChildToParentProver.sol + │ └── ParentToChildProver.sol ├── scroll/ - ├── zksync/ - └── taiko/ + │ ├── ChildToParentProver.sol + │ └── ParentToChildProver.sol + ├── taiko/ + │ ├── ChildToParentProver.sol + │ └── ParentToChildProver.sol + └── zksync/ + ├── ChildToParentProver.sol + ├── ParentToChildProver.sol + └── libraries/ + ├── Merkle.sol + └── MessageHashing.sol ``` + +## External Links + +- **EIP Specification**: [EIP-7888](https://eips.ethereum.org/EIPS/eip-7888) +- **Discussion**: [Ethereum Magicians Forum](https://ethereum-magicians.org/t/new-erc-cross-chain-broadcaster/22927) + +## Security Considerations + +1. **Finalization**: Only finalized blocks can be proven. Propagation time equals the sum of finalization times along the route. + +2. **Route Trust**: The route determines which StateProverPointers (and their owners) are trusted. Only use routes through trusted pointers. + +3. **Prover Upgrades**: StateProverPointer owners can update prover implementations. Ensure pointers are owned by appropriate parties. + +4. **ZkSync**: Must use `ZkSyncBroadcaster` instead of standard `Broadcaster` due to different proof mechanism. + +See individual documentation files for chain-specific security considerations. diff --git a/docs/RECEIVER.md b/docs/RECEIVER.md new file mode 100644 index 0000000..eec5a2f --- /dev/null +++ b/docs/RECEIVER.md @@ -0,0 +1,322 @@ +# Receiver + +The Receiver contract is a singleton deployed on each chain that verifies broadcast messages from remote chains using cryptographic storage proofs. It orchestrates the verification process by following a **route** through a chain of StateProvers. + +## Core Concepts + +### Routes + +A **route** is a path from the local chain to a remote chain, defined as an array of `StateProverPointer` addresses. Each pointer in the route lives on its home chain and references a StateProver that can verify the next chain's state commitment. + +``` +Local Chain → Chain A → Chain B → Remote Chain + [PointerA, PointerB, PointerC] +``` + +**Route validity rules:** +- `route[0]`'s home chain must be the local chain +- `route[i]`'s target chain must equal `route[i+1]`'s home chain + +### Remote Account Identifiers + +Accounts on remote chains are uniquely identified by accumulating hashes of the route addresses plus the remote address: + +```solidity +function accumulator(bytes32 acc, address addr) internal pure returns (bytes32) { + return keccak256(abi.encode(acc, addr)); +} + +// Example: ID for Broadcaster at 0x3 via route [0xA, 0xB] +// id = accumulator(accumulator(accumulator(0, 0xA), 0xB), 0x3) +``` + +IDs are always **relative** to the local chain. The same account on a remote chain will have different IDs depending on the route taken. + +### State Commitments + +A **state commitment** is a `bytes32` hash that commits to a chain's state: +- **Block hash**: Most common, commits to the entire block header +- **State root**: The Merkle-Patricia Trie root of account states +- **Batch root**: Used by some rollups that commit batches instead of individual blocks + +## Interface + +```solidity +interface IReceiver { + struct RemoteReadArgs { + address[] route; // StateProverPointer addresses along the route + bytes[] scpInputs; // Inputs for each StateProver + bytes proof; // Final storage proof for the message slot + } + + function verifyBroadcastMessage( + RemoteReadArgs calldata broadcasterReadArgs, + bytes32 message, + address publisher + ) external view returns (bytes32 broadcasterId, uint256 timestamp); + + function updateStateProverCopy( + RemoteReadArgs calldata scpPointerReadArgs, + IStateProver scpCopy + ) external returns (bytes32 scpPointerId); + + function stateProverCopy(bytes32 scpPointerId) + external view returns (IStateProver scpCopy); +} +``` + +## Verification Process + +### Single-Hop Verification (L2 → L1 or L1 → L2) + +For direct parent-child chain verification: + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Local Chain │ │ Remote Chain │ +│ │ │ │ +│ ┌───────────┐ │ │ ┌───────────┐ │ +│ │ Receiver │ │ ← storage proof ← │ │Broadcaster│ │ +│ └─────┬─────┘ │ │ └───────────┘ │ +│ │ │ │ │ +│ ▼ │ │ │ +│ ┌───────────┐ │ │ │ +│ │ Pointer │──┼──→ getTargetState ───┼────────────────►│ +│ └─────┬─────┘ │ │ │ +│ │ │ │ │ +│ ▼ │ │ │ +│ ┌───────────┐ │ │ │ +│ │ Prover │ │ │ │ +│ └───────────┘ │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +1. Receiver calls `route[0]` pointer to get the prover address +2. Prover's `getTargetStateCommitment()` retrieves the remote chain's state commitment +3. Prover's `verifyStorageSlot()` verifies the storage proof and extracts the slot value + +### Multi-Hop Verification (L2 → L2 via common ancestor) + +For cross-L2 verification through a shared parent (e.g., Ethereum): + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Local L2 │ │ Parent Chain │ │ Remote L2 │ +│ (Optimism) │ │ (Ethereum) │ │ (Arbitrum) │ +│ │ │ │ │ │ +│ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ +│ │ Receiver │ │ │ │ Pointer │◄─┼──────┼──│Broadcaster│ │ +│ └─────┬─────┘ │ │ │ (Arb P2C) │ │ │ └───────────┘ │ +│ │ │ │ └───────────┘ │ │ │ +│ ▼ │ │ │ │ │ +│ ┌───────────┐ │ │ │ │ │ +│ │ Pointer │──┼──────┼────────────────►│ │ │ +│ │ (OP C2P) │ │ │ │ │ │ +│ └─────┬─────┘ │ │ │ │ │ +│ │ │ │ │ │ │ +│ ▼ │ │ │ │ │ +│ ┌───────────┐ │ │ │ │ │ +│ │Prover Copy│ │ │ │ │ │ +│ │(Arb P2C) │ │ │ │ │ │ +│ └───────────┘ │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +Steps: +1. **Get parent state**: Local prover's `getTargetStateCommitment()` retrieves the parent chain's state commitment (e.g., Ethereum block hash) +2. **Verify remote state**: Prover copy's `verifyTargetStateCommitment()` proves the remote L2's state from the parent's state +3. **Verify storage**: Prover copy's `verifyStorageSlot()` verifies the Broadcaster's storage proof + +## Internal Flow: `_readRemoteSlot` + +The Receiver's core logic is in `_readRemoteSlot`: + +```solidity +function _readRemoteSlot(RemoteReadArgs calldata readArgs) + internal view + returns (bytes32 remoteAccountId, uint256 slot, bytes32 slotValue) +{ + IStateProver prover; + bytes32 stateCommitment; + + for (uint256 i = 0; i < readArgs.route.length; i++) { + // Accumulate the remote account ID + remoteAccountId = accumulator(remoteAccountId, readArgs.route[i]); + + if (i == 0) { + // First hop: call getTargetStateCommitment on home chain + prover = IStateProver( + IStateProverPointer(readArgs.route[0]).implementationAddress() + ); + stateCommitment = prover.getTargetStateCommitment(readArgs.scpInputs[0]); + } else { + // Subsequent hops: use prover copies with verifyTargetStateCommitment + prover = _stateProverCopies[remoteAccountId]; + stateCommitment = prover.verifyTargetStateCommitment( + stateCommitment, + readArgs.scpInputs[i] + ); + } + } + + // Final step: verify the storage slot + address remoteAccount; + (remoteAccount, slot, slotValue) = prover.verifyStorageSlot( + stateCommitment, + readArgs.proof + ); + + remoteAccountId = accumulator(remoteAccountId, remoteAccount); +} +``` + +## StateProver Copies + +The Receiver cannot call contracts on remote chains. To verify proofs from remote chains, it maintains **local copies** of StateProvers with matching bytecode. + +### Registering a Copy + +```solidity +function updateStateProverCopy( + RemoteReadArgs calldata scpPointerReadArgs, + IStateProver scpCopy +) external returns (bytes32 scpPointerId); +``` + +The Receiver verifies: +1. The proof reads from `STATE_PROVER_POINTER_SLOT` on the remote pointer +2. The local copy's code hash matches the pointer's stored code hash +3. The new version is higher than any existing copy (monotonicity) + +### Why Code Hash Matching? + +StateProvers must have identical bytecode on all chains to ensure: +- Same verification logic everywhere +- No trust assumptions about remote prover behavior +- Deterministic results regardless of execution location + +## Usage Examples + +### Example 1: Verify Ethereum → Arbitrum (Single Hop) + +On Arbitrum, verify a message broadcast on Ethereum: + +```solidity +// Setup: Deploy receiver and prover on Arbitrum +Receiver receiver = new Receiver(); +ChildToParentProver prover = new ChildToParentProver(block.chainid); +StateProverPointer pointer = new StateProverPointer(owner); +pointer.setImplementationAddress(address(prover)); + +// Construct the route (single hop: Arbitrum → Ethereum) +address[] memory route = new address[](1); +route[0] = address(pointer); + +// Prepare inputs +bytes[] memory scpInputs = new bytes[](1); +scpInputs[0] = abi.encode(ethereumBlockNumber); // Input for getTargetStateCommitment + +// Storage proof for the Broadcaster on Ethereum +bytes memory storageProof = abi.encode( + rlpBlockHeader, + broadcasterAddress, + slot, + accountProof, + storageProof +); + +// Verify +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: storageProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +### Example 2: Verify Arbitrum → Optimism (Two Hops via Ethereum) + +On Optimism, verify a message broadcast on Arbitrum: + +```solidity +// Step 1: Register the Arbitrum ParentToChildProver copy on Optimism +// (This proves Arbitrum state from Ethereum state) + +// First, prove the Ethereum pointer's storage slot contains the prover code hash +IReceiver.RemoteReadArgs memory pointerProofArgs = IReceiver.RemoteReadArgs({ + route: [opChildToParentPointer], // OP → Ethereum + scpInputs: [bytes("")], // No input needed for OP C2P + proof: pointerStorageProof // Proof of pointer's code hash slot +}); + +receiver.updateStateProverCopy(pointerProofArgs, arbParentToChildProverCopy); + +// Step 2: Verify the message from Arbitrum + +address[] memory route = new address[](2); +route[0] = address(opChildToParentPointer); // OP → Ethereum +route[1] = arbParentToChildPointerAddress; // Ethereum → Arbitrum + +bytes[] memory scpInputs = new bytes[](2); +scpInputs[0] = bytes(""); // OP C2P: returns latest L1 block hash +scpInputs[1] = abi.encode( // Arb P2C: proves Arb state from Eth + rlpEthBlockHeader, + sendRoot, + accountProof, + storageProof +); + +bytes memory broadcasterProof = abi.encode( + rlpArbBlockHeader, + broadcasterAddress, + slot, + accountProof, + storageProof +); + +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: broadcasterProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +## Error Handling + +| Error | Cause | +|-------|-------| +| `InvalidRouteLength` | `route.length != scpInputs.length` | +| `EmptyRoute` | Empty route array provided | +| `ProverCopyNotFound` | No registered prover copy for a route hop | +| `MessageNotFound` | Storage slot value is zero (message not broadcast) | +| `WrongMessageSlot` | Proven slot doesn't match expected `keccak256(message, publisher)` | +| `WrongStateProverPointerSlot` | Update proof doesn't target `STATE_PROVER_POINTER_SLOT` | +| `DifferentCodeHash` | Local prover copy's code hash doesn't match remote pointer | +| `NewerProverVersion` | Existing prover copy has version >= new copy | + +## Security Considerations + +1. **Proof freshness**: Storage proofs are only valid against specific block hashes. Ensure proofs are generated against finalized blocks. + +2. **Route trust**: The route determines which StateProverPointers (and their owners) are trusted. Only use routes through trusted pointers. + +3. **Version monotonicity**: Prover copies can only be updated to newer versions, preventing rollback attacks. + +4. **Finalization delays**: Messages can only be verified after their containing block is finalized. Total propagation time equals the sum of finalization times along the route. + +## Related Documentation + +- [BROADCASTER.md](./BROADCASTER.md) - How messages are broadcast +- [PROVERS.md](./PROVERS.md) - StateProver and StateProverPointer details +- Chain-specific provers: [ARBITRUM.md](./provers/ARBITRUM.md), [OPTIMISM.md](./provers/OPTIMISM.md), etc. diff --git a/docs/provers/ARBITRUM.md b/docs/provers/ARBITRUM.md new file mode 100644 index 0000000..b3ebdd0 --- /dev/null +++ b/docs/provers/ARBITRUM.md @@ -0,0 +1,341 @@ +# Arbitrum Provers + +Arbitrum is an Optimistic Rollup that settles to Ethereum. It stores finalized L2 block hashes in the L1 Outbox contract and provides access to L1 block hashes via a block hash buffer contract on L2. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Ethereum (L1) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Outbox Contract │ │ +│ │ mapping(bytes32 sendRoot => bytes32 blockHash) public roots; │ │ +│ │ │ │ +│ │ • Stores Arbitrum block hashes indexed by sendRoot │ │ +│ │ • Updated by the Rollup contract after challenge periods │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ sendRoot → blockHash + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Arbitrum (L2) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Block Hash Buffer │ │ +│ │ mapping(uint256 blockNumber => bytes32 blockHash) │ │ +│ │ Address: 0x0000000048C4Ed10cF14A02B9E0AbDDA5227b071 │ │ +│ │ │ │ +│ │ • Stores L1 block hashes pushed by the Sequencer │ │ +│ │ • Historical L1 block hashes available on L2 │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## ChildToParentProver + +**Direction**: Arbitrum (L2) → Ethereum (L1) + +This prover enables verification of Ethereum state from Arbitrum by reading L1 block hashes from the block hash buffer contract. + +### Configuration + +```solidity +contract ChildToParentProver is IStateProver { + /// @dev Block hash buffer on Arbitrum One + address public constant blockHashBuffer = 0x0000000048C4Ed10cF14A02B9E0AbDDA5227b071; + + /// @dev Storage slot for parentChainBlockHash mapping + uint256 public constant blockHashMappingSlot = 51; + + /// @dev Arbitrum chain ID (set at deployment) + uint256 public immutable homeChainId; +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Arbitrum (home chain) +**Returns**: Ethereum block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint256 targetBlockNumber = abi.decode(input, (uint256)); + targetStateCommitment = IBuffer(blockHashBuffer).parentChainBlockHash(targetBlockNumber); +} +``` + +**Input encoding**: `abi.encode(uint256 targetBlockNumber)` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Arbitrum) +**Returns**: Ethereum block hash (proven from Arbitrum state) + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + uint256 targetBlockNumber, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, uint256, bytes, bytes)); + + // Calculate storage slot: keccak256(abi.encode(targetBlockNumber, 51)) + uint256 slot = SlotDerivation.deriveMapping(bytes32(blockHashMappingSlot), targetBlockNumber); + + // Verify proof and return L1 block hash + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeBlockHash, rlpBlockHeader, blockHashBuffer, slot, accountProof, storageProof + ); +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Arbitrum block header + uint256 targetBlockNumber, // L1 block number to retrieve + bytes accountProof, // MPT proof for buffer contract + bytes storageProof // MPT proof for storage slot +) +``` + +#### `verifyStorageSlot` + +**Called on**: Any chain +**Verifies**: Storage slot against Ethereum block hash + +```solidity +function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) + external pure returns (address account, uint256 slot, bytes32 value) +{ + ( + bytes memory rlpBlockHeader, + address account, + uint256 slot, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, address, uint256, bytes, bytes)); + + value = ProverUtils.getSlotFromBlockHeader( + targetStateCommitment, rlpBlockHeader, account, slot, accountProof, storageProof + ); +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + address account, // Contract address on Ethereum + uint256 slot, // Storage slot + bytes accountProof, // MPT account proof + bytes storageProof // MPT storage proof +) +``` + +## ParentToChildProver + +**Direction**: Ethereum (L1) → Arbitrum (L2) + +This prover enables verification of Arbitrum state from Ethereum by reading L2 block hashes from the Outbox contract. + +### Configuration + +```solidity +contract ParentToChildProver is IStateProver { + /// @dev Outbox contract address on Ethereum (chain-specific) + address public immutable outbox; + + /// @dev Storage slot for roots mapping in Outbox (typically 3) + uint256 public immutable rootsSlot; + + /// @dev Ethereum chain ID + uint256 public immutable homeChainId; +} +``` + +**Outbox addresses (examples)**: +- Arbitrum One: `0x0B9857ae2D4A3DBe74ffE1d7DF045bb7F96E4840` +- Arbitrum Sepolia: `0x65f07C7D521164a4d5DaC6eB8Fac8DA067A3B78F` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Ethereum (home chain) +**Returns**: Arbitrum block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + bytes32 sendRoot = abi.decode(input, (bytes32)); + targetStateCommitment = IOutbox(outbox).roots(sendRoot); + + if (targetStateCommitment == bytes32(0)) { + revert TargetBlockHashNotFound(); + } +} +``` + +**Input encoding**: `abi.encode(bytes32 sendRoot)` + +The `sendRoot` is a commitment to the Arbitrum state that can be looked up in the Rollup contract or derived from Arbitrum blocks. + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Ethereum) +**Returns**: Arbitrum block hash (proven from Ethereum state) + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + bytes32 sendRoot, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, bytes32, bytes, bytes)); + + // Calculate storage slot: keccak256(abi.encode(sendRoot, rootsSlot)) + uint256 slot = SlotDerivation.deriveMapping(bytes32(rootsSlot), sendRoot); + + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeBlockHash, rlpBlockHeader, outbox, slot, accountProof, storageProof + ); + + if (targetStateCommitment == bytes32(0)) { + revert TargetBlockHashNotFound(); + } +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + bytes32 sendRoot, // Arbitrum sendRoot commitment + bytes accountProof, // MPT proof for Outbox contract + bytes storageProof // MPT proof for roots[sendRoot] slot +) +``` + +#### `verifyStorageSlot` + +**Called on**: Any chain +**Verifies**: Storage slot against Arbitrum block hash + +```solidity +function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) + external pure returns (address account, uint256 slot, bytes32 value) +{ + ( + bytes memory rlpBlockHeader, + address account, + uint256 slot, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, address, uint256, bytes, bytes)); + + value = ProverUtils.getSlotFromBlockHeader( + targetStateCommitment, rlpBlockHeader, account, slot, accountProof, storageProof + ); +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Arbitrum block header + address account, // Contract address on Arbitrum + uint256 slot, // Storage slot + bytes accountProof, // MPT account proof + bytes storageProof // MPT storage proof +) +``` + +## Usage Example: Verifying Arbitrum Message from Optimism + +```solidity +// On Optimism, verify a message broadcast on Arbitrum + +// 1. Route: Optimism → Ethereum → Arbitrum +address[] memory route = new address[](2); +route[0] = opChildToParentPointer; // OP's C2P prover pointer +route[1] = arbParentToChildPointer; // Arb's P2C prover pointer (on Ethereum) + +// 2. Inputs for each hop +bytes[] memory scpInputs = new bytes[](2); + +// First hop: OP C2P (get Ethereum block hash) +scpInputs[0] = bytes(""); // OP uses L1Block predeploy, no input needed + +// Second hop: Arb P2C (prove Arbitrum state from Ethereum) +scpInputs[1] = abi.encode( + rlpEthBlockHeader, // Ethereum block header + arbSendRoot, // Arbitrum sendRoot + outboxAccountProof, // Proof for Outbox contract + outboxStorageProof // Proof for roots[sendRoot] +); + +// 3. Final proof: Broadcaster storage on Arbitrum +bytes memory broadcasterProof = abi.encode( + rlpArbBlockHeader, // Arbitrum block header + broadcasterAddress, // Broadcaster contract + messageSlot, // keccak256(message, publisher) + accountProof, // Proof for Broadcaster + storageProof // Proof for message slot +); + +// 4. Verify +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: broadcasterProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +## Key Considerations + +### SendRoot vs Block Hash + +Arbitrum uses a two-step lookup: +1. The `sendRoot` is a commitment included in Arbitrum blocks +2. The Outbox maps `sendRoot → blockHash` + +This requires knowing the sendRoot for the Arbitrum block you want to prove against. + +### Block Hash Buffer + +The block hash buffer on Arbitrum: +- Is maintained by Offchain Labs' infrastructure +- Stores historical L1 block hashes +- Uses slot 51 for the `parentChainBlockHash` mapping +- Address is deterministic: `0x0000000048C4Ed10cF14A02B9E0AbDDA5227b071` + +### Finality + +Arbitrum's optimistic nature means: +- Block hashes are only in the Outbox after the challenge period (~1 week) +- More recent blocks may use different proving mechanisms +- The prover only works with finalized Arbitrum blocks + +## Related Documentation + +- [../PROVERS.md](../PROVERS.md) - General prover architecture +- [../RECEIVER.md](../RECEIVER.md) - How routes and verification work +- [Arbitrum Documentation](https://docs.arbitrum.io/) diff --git a/docs/provers/LINEA.md b/docs/provers/LINEA.md new file mode 100644 index 0000000..faaff74 --- /dev/null +++ b/docs/provers/LINEA.md @@ -0,0 +1,427 @@ +# Linea Provers + +Linea is a ZK Rollup that settles to Ethereum. Unlike most rollups that use Merkle-Patricia Tries (MPT), Linea uses **Sparse Merkle Trees (SMT)** with **MiMC hashing** for its state structure. This requires special handling in the provers. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Ethereum (L1) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ LineaRollup │ │ +│ │ mapping(uint256 blockNumber => bytes32 stateRootHash) │ │ +│ │ │ │ +│ │ • Stores Linea L2 state roots (SMT roots, not block hashes) │ │ +│ │ • Updated after ZK proof verification │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ L2 SMT state root + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Linea (L2) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Block Hash Buffer │ │ +│ │ mapping(uint256 blockNumber => bytes32 blockHash) │ │ +│ │ │ │ +│ │ • Stores L1 block hashes pushed via the bridge │ │ +│ │ • Uses standard MPT proofs for L1 state │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Sparse Merkle Tree State │ │ +│ │ • 42-level binary tree with MiMC hashing │ │ +│ │ • Proofs via linea_getProof RPC method │ │ +│ │ • Different from eth_getProof format │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Understanding Linea's SMT + +### Key Differences from MPT + +| Aspect | MPT (Ethereum) | SMT (Linea) | +|--------|---------------|-------------| +| Structure | Patricia Trie | Binary Merkle Tree | +| Hash Function | Keccak256 | MiMC | +| Proof Format | Variable-length nodes | Fixed 42 levels | +| Proof RPC | `eth_getProof` | `linea_getProof` | +| Key Derivation | Keccak256 | MiMC | + +### Proof Components + +Linea proofs from `linea_getProof` include: + +``` +Account Proof: +├── leafIndex: Position in the tree +├── proof.proofRelatedNodes: 42 sibling hashes +└── proof.value: 192-byte account data + +Storage Proof: +├── leafIndex: Position in storage tree +├── proof.proofRelatedNodes: 42 sibling hashes +└── value: Storage slot value +``` + +### Account Data Structure (192 bytes) + +```solidity +struct Account { + uint256 nonce; + uint256 balance; + bytes32 storageRoot; // SMT root of account's storage + bytes32 mimcCodeHash; // MiMC hash of code + bytes32 keccakCodeHash; + uint64 codeSize; +} +``` + +## ChildToParentProver + +**Direction**: Linea (L2) → Ethereum (L1) + +This prover reads L1 block hashes from a block hash buffer contract on Linea. It uses **standard MPT proofs** because it's proving against Linea's internal buffer storage. + +### Configuration + +```solidity +contract ChildToParentProver is IStateProver { + /// @dev Block hash buffer address (deployment-specific) + address public immutable blockHashBuffer; + + /// @dev Storage slot for parentChainBlockHash mapping + uint256 public constant blockHashMappingSlot = 1; + + /// @dev Linea chain ID + uint256 public immutable homeChainId; +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Linea (home chain) +**Returns**: Ethereum block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint256 targetBlockNumber = abi.decode(input, (uint256)); + targetStateCommitment = IBuffer(blockHashBuffer).parentChainBlockHash(targetBlockNumber); +} +``` + +**Input encoding**: `abi.encode(uint256 targetBlockNumber)` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Linea) +**Returns**: Ethereum block hash (proven from Linea state) + +Uses standard MPT proofs against the buffer contract. + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Linea block header + uint256 targetBlockNumber, // L1 block number + bytes accountProof, // MPT proof for buffer contract + bytes storageProof // MPT proof for storage slot +) +``` + +#### `verifyStorageSlot` + +Standard MPT verification against Ethereum state. + +## ParentToChildProver + +**Direction**: Ethereum (L1) → Linea (L2) + +This prover verifies Linea L2 state from Ethereum. The critical difference is that `verifyStorageSlot` uses **SMT proofs with MiMC hashing**. + +### Configuration + +```solidity +contract ParentToChildProver is IStateProver { + /// @dev LineaRollup address on Ethereum + address public immutable lineaRollup; + + /// @dev Storage slot for stateRootHashes mapping + uint256 public immutable stateRootHashesSlot; + + /// @dev Ethereum chain ID + uint256 public immutable homeChainId; +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Ethereum (home chain) +**Returns**: Linea SMT state root + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint256 l2BlockNumber = abi.decode(input, (uint256)); + targetStateCommitment = ZkEvmV2(lineaRollup).stateRootHashes(l2BlockNumber); + + if (targetStateCommitment == bytes32(0)) { + revert TargetStateRootNotFound(); + } +} +``` + +**Input encoding**: `abi.encode(uint256 l2BlockNumber)` + +**Important**: The returned value is an **SMT state root**, not a block hash. + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Ethereum) +**Returns**: Linea SMT state root + +Uses MPT proofs to verify the LineaRollup storage on Ethereum. + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + uint256 l2BlockNumber, // Linea L2 block number + bytes accountProof, // MPT proof for LineaRollup + bytes storageProof // MPT proof for stateRootHashes[l2BlockNumber] +) +``` + +#### `verifyStorageSlot` + +**Critical**: Uses Linea's SMT verification with MiMC hashing. + +```solidity +function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) + external pure returns (address account, uint256 slot, bytes32 value) +{ + ( + address account, + uint256 slot, + uint256 accountLeafIndex, + bytes[] memory accountProof, + bytes memory accountValue, + uint256 storageLeafIndex, + bytes[] memory storageProof, + bytes32 claimedStorageValue + ) = abi.decode(input, (address, uint256, uint256, bytes[], bytes, uint256, bytes[], bytes32)); + + // Step 1: Verify account proof against L2 state root (SMT) + bool accountValid = SparseMerkleProof.verifyProof( + accountProof, accountLeafIndex, targetStateCommitment + ); + if (!accountValid) revert InvalidAccountProof(); + + // Step 2: Verify account address matches proof (MiMC hash check) + SparseMerkleProof.Leaf memory accountLeaf = + SparseMerkleProof.getLeaf(accountProof[accountProof.length - 1]); + bytes32 expectedAccountHKey = SparseMerkleProof.hashAccountKey(account); + if (accountLeaf.hKey != expectedAccountHKey) revert AccountKeyMismatch(); + + // Step 3: Verify account value matches proof + bytes32 expectedAccountHValue = SparseMerkleProof.hashAccountValue(accountValue); + if (accountLeaf.hValue != expectedAccountHValue) revert AccountValueMismatch(); + + // Step 4: Extract storage root from account value + SparseMerkleProof.Account memory accountData = + SparseMerkleProof.getAccount(accountValue); + + // Step 5: Verify storage proof against account's storage root + bool storageValid = SparseMerkleProof.verifyProof( + storageProof, storageLeafIndex, accountData.storageRoot + ); + if (!storageValid) revert InvalidStorageProof(); + + // Step 6: Verify storage slot matches proof + SparseMerkleProof.Leaf memory storageLeaf = + SparseMerkleProof.getLeaf(storageProof[storageProof.length - 1]); + bytes32 expectedStorageHKey = SparseMerkleProof.hashStorageKey(bytes32(slot)); + if (storageLeaf.hKey != expectedStorageHKey) revert StorageKeyMismatch(); + + // Step 7: Verify storage value + bytes32 expectedHValue = SparseMerkleProof.hashStorageValue(claimedStorageValue); + if (storageLeaf.hValue != expectedHValue) revert StorageValueMismatch(); + + value = claimedStorageValue; +} +``` + +**Input encoding**: +```solidity +abi.encode( + address account, // Contract address on Linea + uint256 slot, // Storage slot + uint256 accountLeafIndex, // From accountProof.leafIndex + bytes[] accountProof, // 42 sibling hashes + bytes accountValue, // 192-byte account data + uint256 storageLeafIndex, // From storageProofs[0].leafIndex + bytes[] storageProof, // 42 sibling hashes + bytes32 claimedStorageValue // Storage value to verify +) +``` + +## SparseMerkleProof Library + +The `SparseMerkleProof` library handles Linea's SMT verification: + +### Key Functions + +```solidity +library SparseMerkleProof { + // Verify a Merkle proof + function verifyProof( + bytes[] memory proof, + uint256 leafIndex, + bytes32 root + ) internal pure returns (bool); + + // Extract leaf data from the last proof element + function getLeaf(bytes memory leafData) + internal pure returns (Leaf memory); + + // Parse 192-byte account data + function getAccount(bytes memory accountValue) + internal pure returns (Account memory); + + // Hash functions using MiMC + function hashAccountKey(address account) internal pure returns (bytes32); + function hashAccountValue(bytes memory value) internal pure returns (bytes32); + function hashStorageKey(bytes32 key) internal pure returns (bytes32); + function hashStorageValue(bytes32 value) internal pure returns (bytes32); +} +``` + +### MiMC Hashing + +Linea uses MiMC (Minimal Multiplicative Complexity) hashing for ZK-friendliness: + +```solidity +library Mimc { + function hash(bytes memory data) internal pure returns (bytes32); +} +``` + +## Generating Proofs + +### Using `linea_getProof` + +```javascript +const proof = await provider.send('linea_getProof', [ + contractAddress, + [storageSlot], + blockNumber +]); + +// Result structure: +{ + accountProof: { + leafIndex: number, + proof: { + proofRelatedNodes: bytes32[42], + value: bytes // 192-byte account data + } + }, + storageProofs: [{ + leafIndex: number, + proof: { + proofRelatedNodes: bytes32[42], + value: bytes32 + } + }] +} +``` + +### Encoding for Contract + +```javascript +const encodedProof = ethers.utils.defaultAbiCoder.encode( + ['address', 'uint256', 'uint256', 'bytes[]', 'bytes', 'uint256', 'bytes[]', 'bytes32'], + [ + contractAddress, + storageSlot, + proof.accountProof.leafIndex, + proof.accountProof.proof.proofRelatedNodes, + proof.accountProof.proof.value, + proof.storageProofs[0].leafIndex, + proof.storageProofs[0].proof.proofRelatedNodes, + storageValue + ] +); +``` + +## Usage Example: Verifying Linea Message from Ethereum + +```solidity +// On Ethereum, verify a message broadcast on Linea + +// 1. Route: Ethereum → Linea +address[] memory route = new address[](1); +route[0] = lineaParentToChildPointer; + +// 2. Input: L2 block number +bytes[] memory scpInputs = new bytes[](1); +scpInputs[0] = abi.encode(l2BlockNumber); + +// 3. SMT proof for Broadcaster storage on Linea +bytes memory broadcasterProof = abi.encode( + broadcasterAddress, // Contract on Linea + messageSlot, // keccak256(message, publisher) + accountLeafIndex, // From linea_getProof + accountProofNodes, // 42 sibling hashes + accountValue, // 192-byte account data + storageLeafIndex, // From linea_getProof + storageProofNodes, // 42 sibling hashes + timestamp // Expected storage value +); + +// 4. Verify +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: broadcasterProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +## Key Considerations + +### State Root vs Block Hash + +Linea stores **state roots** (SMT roots), not block hashes, on L1. The `targetStateCommitment` returned by the prover is an SMT state root used directly for storage verification. + +### Proof Format Differences + +- **Account proofs**: Always 42 elements (fixed tree depth) +- **Storage proofs**: Always 42 elements +- **Value verification**: Requires MiMC hash comparison, not direct value comparison + +### ZK-Friendly Design + +Linea's SMT and MiMC choices are optimized for ZK circuits: +- Fewer constraints than Keccak256 +- Fixed tree structure enables efficient proving +- Trade-off: requires custom verification logic + +## Related Documentation + +- [../PROVERS.md](../PROVERS.md) - General prover architecture +- [../RECEIVER.md](../RECEIVER.md) - How routes and verification work +- [Linea Documentation](https://docs.linea.build/) diff --git a/docs/provers/OPTIMISM.md b/docs/provers/OPTIMISM.md new file mode 100644 index 0000000..caaf8c1 --- /dev/null +++ b/docs/provers/OPTIMISM.md @@ -0,0 +1,393 @@ +# Optimism Provers + +Optimism (and OP Stack chains) is an Optimistic Rollup that settles to Ethereum. The L2 has access to L1 block hashes via the `L1Block` predeploy, and L1 can verify L2 state through the `AnchorStateRegistry` and Fault Dispute Games. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Ethereum (L1) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ AnchorStateRegistry │ │ +│ │ Storage slot 3: address anchorGame │ │ +│ │ │ │ +│ │ • Points to the current valid FaultDisputeGame │ │ +│ │ • Game contains the L2 output root (rootClaim) │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ FaultDisputeGame (CWIA Proxy) │ │ +│ │ rootClaim = keccak256(OutputRootProof) │ │ +│ │ │ │ +│ │ OutputRootProof { │ │ +│ │ version, stateRoot, messagePasserStorageRoot, latestBlockhash │ │ +│ │ } │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ L2 block hash in OutputRootProof + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Optimism (L2) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ L1Block Predeploy │ │ +│ │ Address: 0x4200000000000000000000000000000000000015 │ │ +│ │ Storage slot 2: bytes32 hash (latest L1 block hash) │ │ +│ │ │ │ +│ │ • Updated by the depositor account each L2 block │ │ +│ │ • Only stores the LATEST L1 block hash (no history) │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## ChildToParentProver + +**Direction**: Optimism (L2) → Ethereum (L1) + +This prover enables verification of Ethereum state from Optimism by reading the L1 block hash from the `L1Block` predeploy. + +### Configuration + +```solidity +contract ChildToParentProver is IStateProver { + /// @dev L1Block predeploy address (same on all OP Stack chains) + address public constant l1BlockPredeploy = 0x4200000000000000000000000000000000000015; + + /// @dev Storage slot for the L1 block hash + uint256 public constant l1BlockHashSlot = 2; + + /// @dev Optimism chain ID + uint256 public immutable homeChainId; +} +``` + +### Important Limitation + +The `L1Block` predeploy only stores the **latest** L1 block hash, not historical hashes. This has operational implications: + +- Proofs must be generated **just-in-time** rather than pre-cached +- Pre-generated proofs become stale when `L1Block` updates (~every few minutes) +- If `L1Block` updates too frequently, verification calls may need to be retried + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Optimism (home chain) +**Returns**: Latest Ethereum block hash + +```solidity +function getTargetStateCommitment(bytes calldata) + external view returns (bytes32 targetStateCommitment) +{ + return IL1Block(l1BlockPredeploy).hash(); +} +``` + +**Input encoding**: Empty bytes (input is ignored) + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Optimism) +**Returns**: Ethereum block hash (proven from Optimism state) + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, bytes, bytes)); + + // Verify proof against L1Block predeploy's storage + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeBlockHash, + rlpBlockHeader, + l1BlockPredeploy, + l1BlockHashSlot, // slot 2 + accountProof, + storageProof + ); +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Optimism block header + bytes accountProof, // MPT proof for L1Block predeploy + bytes storageProof // MPT proof for slot 2 +) +``` + +#### `verifyStorageSlot` + +**Called on**: Any chain +**Verifies**: Storage slot against Ethereum block hash + +```solidity +function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) + external pure returns (address account, uint256 slot, bytes32 value) +{ + ( + bytes memory rlpBlockHeader, + address account, + uint256 slot, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, address, uint256, bytes, bytes)); + + value = ProverUtils.getSlotFromBlockHeader( + targetStateCommitment, rlpBlockHeader, account, slot, accountProof, storageProof + ); +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + address account, // Contract address on Ethereum + uint256 slot, // Storage slot + bytes accountProof, // MPT account proof + bytes storageProof // MPT storage proof +) +``` + +## ParentToChildProver + +**Direction**: Ethereum (L1) → Optimism (L2) + +This prover enables verification of Optimism state from Ethereum through the Fault Dispute Game system. + +### Configuration + +```solidity +contract ParentToChildProver is IStateProver { + /// @dev Storage slot for anchorGame in AnchorStateRegistry + uint256 public constant ANCHOR_GAME_SLOT = 3; + + /// @dev AnchorStateRegistry address on Ethereum + address public immutable anchorStateRegistry; + + /// @dev Ethereum chain ID + uint256 public immutable homeChainId; +} +``` + +### Output Root Structure + +The Fault Dispute Game stores an `OutputRootProof`: + +```solidity +struct OutputRootProof { + bytes32 version; // Version identifier + bytes32 stateRoot; // L2 state root + bytes32 messagePasserStorageRoot; // L2ToL1MessagePasser storage root + bytes32 latestBlockhash; // L2 block hash ← This is what we extract +} + +// rootClaim = keccak256(abi.encode(OutputRootProof)) +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Ethereum (home chain) +**Returns**: Optimism block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + (address gameProxy, OutputRootProof memory rootClaimPreimage) = + abi.decode(input, (address, OutputRootProof)); + + // Verify game is valid + require( + IAnchorStateRegistry(anchorStateRegistry).isGameClaimValid(gameProxy), + "Invalid game proxy" + ); + + // Verify preimage matches game's root claim + bytes32 rootClaim = IFaultDisputeGame(gameProxy).rootClaim(); + require( + rootClaim == keccak256(abi.encode(rootClaimPreimage)), + "Invalid root claim preimage" + ); + + return rootClaimPreimage.latestBlockhash; +} +``` + +**Input encoding**: +```solidity +abi.encode( + address gameProxy, // FaultDisputeGame address + OutputRootProof rootClaimPreimage // Preimage of the rootClaim +) +``` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Ethereum) +**Returns**: Optimism block hash (proven from Ethereum state) + +This function performs a complex verification: + +1. Extract the anchor game address from AnchorStateRegistry storage +2. Verify the game proxy's code hash via account proof +3. Extract the rootClaim from the game proxy bytecode (CWIA pattern) +4. Verify the rootClaim preimage +5. Return the L2 block hash from the preimage + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + bytes memory asrAccountProof, + bytes memory asrStorageProof, + bytes memory gameProxyAccountProof, + bytes memory gameProxyCode, + OutputRootProof memory rootClaimPreimage + ) = abi.decode(input, (bytes, bytes, bytes, bytes, bytes, OutputRootProof)); + + // Verify block header + require(homeBlockHash == keccak256(rlpBlockHeader), "Invalid home block header"); + bytes32 stateRoot = ProverUtils.extractStateRootFromBlockHeader(rlpBlockHeader); + + // Get anchor game address from AnchorStateRegistry + address anchorGame = address(uint160(uint256( + ProverUtils.getStorageSlotFromStateRoot( + stateRoot, asrAccountProof, asrStorageProof, + anchorStateRegistry, ANCHOR_GAME_SLOT + ) + ))); + + // Verify game proxy code hash + (bool exists, bytes memory accountValue) = + ProverUtils.getAccountDataFromStateRoot(stateRoot, gameProxyAccountProof, anchorGame); + require(exists, "Anchor game account does not exist"); + bytes32 codeHash = ProverUtils.extractCodeHashFromAccountData(accountValue); + require(keccak256(gameProxyCode) == codeHash, "Invalid game proxy code"); + + // Extract rootClaim from CWIA proxy bytecode + bytes32 rootClaim = _getRootClaimFromGameProxyCode(gameProxyCode); + + // Verify preimage + require(rootClaim == keccak256(abi.encode(rootClaimPreimage)), "Invalid root claim preimage"); + + return rootClaimPreimage.latestBlockhash; +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + bytes asrAccountProof, // MPT proof for AnchorStateRegistry + bytes asrStorageProof, // MPT proof for anchorGame slot + bytes gameProxyAccountProof, // MPT proof for game proxy account + bytes gameProxyCode, // Full bytecode of game proxy + OutputRootProof rootClaimPreimage // Preimage of the rootClaim +) +``` + +#### `verifyStorageSlot` + +Same as ChildToParentProver's `verifyStorageSlot`. + +### CWIA Proxy Bytecode Layout + +The Fault Dispute Game uses a Clone With Immutable Args (CWIA) proxy: + +``` +┌──────────────┬────────────────────────────────────┐ +│ Bytes │ Description │ +├──────────────┼────────────────────────────────────┤ +│ [0, 0x62) │ Proxy bytecode │ +│ [0x62, 0x76) │ Game creator address (20 bytes) │ +│ [0x76, 0x96) │ Root claim (32 bytes) ← Extract │ +│ [0x96, 0xB6) │ Parent block hash (32 bytes) │ +│ [0xB6, ...) │ Extra data │ +└──────────────┴────────────────────────────────────┘ +``` + +The prover extracts the root claim at offset `0x62 + 20 = 0x76`: + +```solidity +function _getRootClaimFromGameProxyCode(bytes memory bytecode) + internal pure returns (bytes32 rootClaim) +{ + return abi.decode(Bytes.slice(bytecode, 0x62 + 20, 0x62 + 52), (bytes32)); +} +``` + +## Usage Example: Verifying Ethereum Message from Optimism + +```solidity +// On Optimism, verify a message broadcast on Ethereum + +// 1. Route: Optimism → Ethereum (single hop) +address[] memory route = new address[](1); +route[0] = opChildToParentPointer; + +// 2. Input for OP C2P (empty - uses latest L1 block) +bytes[] memory scpInputs = new bytes[](1); +scpInputs[0] = bytes(""); + +// 3. Proof for Broadcaster storage on Ethereum +bytes memory broadcasterProof = abi.encode( + rlpEthBlockHeader, // Ethereum block header + broadcasterAddress, // Broadcaster contract + messageSlot, // keccak256(message, publisher) + accountProof, // Proof for Broadcaster + storageProof // Proof for message slot +); + +// 4. Verify +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: broadcasterProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +## Key Considerations + +### L1Block Staleness + +Since `L1Block` only stores the latest L1 block hash: +- Proofs must be fresh (generated just before verification) +- If the proof becomes stale, regenerate it with the new L1 block +- Consider implementing retry logic for production systems + +### Fault Dispute Games + +The ParentToChildProver relies on: +- The `AnchorStateRegistry` tracking valid games +- The game's `isGameClaimValid()` returning true +- The ability to provide the full game proxy bytecode + +### Finality + +Optimism's finality depends on the mechanism: +- **Fault proofs**: ~7 days challenge period +- **Validity proofs** (future): Near-instant finality after proof verification + +## Related Documentation + +- [../PROVERS.md](../PROVERS.md) - General prover architecture +- [../RECEIVER.md](../RECEIVER.md) - How routes and verification work +- [Optimism Documentation](https://docs.optimism.io/) diff --git a/docs/provers/SCROLL.md b/docs/provers/SCROLL.md new file mode 100644 index 0000000..f7821b7 --- /dev/null +++ b/docs/provers/SCROLL.md @@ -0,0 +1,377 @@ +# Scroll Provers + +Scroll is a ZK Rollup that settles to Ethereum. Unlike other rollups that store block hashes, Scroll stores **state roots directly** in its L1 contract, which simplifies the verification process. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Ethereum (L1) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ ScrollChain │ │ +│ │ mapping(uint256 batchIndex => bytes32 stateRoot) │ │ +│ │ finalizedStateRoots │ │ +│ │ │ │ +│ │ • Stores Scroll L2 state roots (not block hashes!) │ │ +│ │ • Indexed by batch number, not block number │ │ +│ │ • Updated after ZK proof verification │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ L2 state root (directly!) + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Scroll (L2) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Block Hash Buffer │ │ +│ │ mapping(uint256 blockNumber => bytes32 blockHash) │ │ +│ │ │ │ +│ │ • Stores L1 block hashes pushed via the bridge │ │ +│ │ • Uses standard MPT proofs │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Standard MPT State │ │ +│ │ • Scroll uses Ethereum-compatible state trie │ │ +│ │ • Proofs via eth_getProof │ │ +│ │ • Keccak256 hashing │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Key Difference: State Roots, Not Block Hashes + +**Critical distinction**: Scroll stores state roots directly, not block hashes. This means: + +1. `getTargetStateCommitment` returns a **state root** (not a block hash) +2. `verifyStorageSlot` can verify **directly against the state root** (no block header needed) +3. Storage proofs are simpler than other chains + +``` +Other Rollups: + blockHash → blockHeader → stateRoot → accountProof → storageProof + +Scroll: + stateRoot → accountProof → storageProof + (Skip the block hash and header steps!) +``` + +## ChildToParentProver + +**Direction**: Scroll (L2) → Ethereum (L1) + +This prover reads L1 block hashes from a block hash buffer contract on Scroll. + +### Configuration + +```solidity +contract ChildToParentProver is IStateProver { + /// @dev Block hash buffer address (deployment-specific) + address public immutable blockHashBuffer; + + /// @dev Storage slot for parentChainBlockHash mapping + uint256 public constant blockHashMappingSlot = 1; + + /// @dev Scroll chain ID + uint256 public immutable homeChainId; +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Scroll (home chain) +**Returns**: Ethereum block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint256 targetBlockNumber = abi.decode(input, (uint256)); + targetStateCommitment = IBuffer(blockHashBuffer).parentChainBlockHash(targetBlockNumber); +} +``` + +**Input encoding**: `abi.encode(uint256 targetBlockNumber)` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Scroll) +**Returns**: Ethereum block hash + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + uint256 targetBlockNumber, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, uint256, bytes, bytes)); + + uint256 slot = SlotDerivation.deriveMapping( + bytes32(blockHashMappingSlot), + targetBlockNumber + ); + + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeBlockHash, rlpBlockHeader, blockHashBuffer, slot, accountProof, storageProof + ); +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Scroll block header + uint256 targetBlockNumber, // L1 block number + bytes accountProof, // MPT proof for buffer contract + bytes storageProof // MPT proof for storage slot +) +``` + +#### `verifyStorageSlot` + +Standard MPT verification against Ethereum block hash. + +## ParentToChildProver + +**Direction**: Ethereum (L1) → Scroll (L2) + +This prover verifies Scroll L2 state from Ethereum by reading state roots from the ScrollChain contract. + +### Configuration + +```solidity +contract ParentToChildProver is IStateProver { + /// @dev ScrollChain address on Ethereum + address public immutable scrollChain; + + /// @dev Storage slot for finalizedStateRoots mapping + uint256 public immutable finalizedStateRootsSlot; + + /// @dev Ethereum chain ID + uint256 public immutable homeChainId; +} +``` + +**Common values**: +- Scroll Mainnet ScrollChain: `0xa13BAF47339d63B743e7Da8741db5456DAc1E556` +- Scroll Sepolia ScrollChain: `0x2D567EcE699Eabe5afCd141eDB7A4f2D0D6ce8a0` +- `finalizedStateRootsSlot`: Varies by deployment (check contract storage layout) + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Ethereum (home chain) +**Returns**: Scroll state root (NOT block hash) + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint256 batchIndex = abi.decode(input, (uint256)); + targetStateCommitment = IScrollChain(scrollChain).finalizedStateRoots(batchIndex); + + if (targetStateCommitment == bytes32(0)) { + revert StateRootNotFound(); + } +} +``` + +**Input encoding**: `abi.encode(uint256 batchIndex)` + +**Note**: Uses `batchIndex`, not block number. + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Ethereum) +**Returns**: Scroll state root + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + uint256 batchIndex, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, uint256, bytes, bytes)); + + uint256 slot = SlotDerivation.deriveMapping( + bytes32(finalizedStateRootsSlot), + batchIndex + ); + + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeBlockHash, rlpBlockHeader, scrollChain, slot, accountProof, storageProof + ); + + if (targetStateCommitment == bytes32(0)) { + revert StateRootNotFound(); + } +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + uint256 batchIndex, // Scroll batch index + bytes accountProof, // MPT proof for ScrollChain + bytes storageProof // MPT proof for finalizedStateRoots[batchIndex] +) +``` + +#### `verifyStorageSlot` + +**Key difference**: Verifies directly against state root (no block header needed). + +```solidity +function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) + external pure returns (address account, uint256 slot, bytes32 value) +{ + bytes memory accountProof; + bytes memory storageProof; + (account, slot, accountProof, storageProof) = + abi.decode(input, (address, uint256, bytes, bytes)); + + // Verify directly against state root - no block header! + value = ProverUtils.getStorageSlotFromStateRoot( + targetStateCommitment, // This IS the state root + accountProof, + storageProof, + account, + slot + ); +} +``` + +**Input encoding** (simpler than other chains): +```solidity +abi.encode( + address account, // Contract address on Scroll + uint256 slot, // Storage slot + bytes accountProof, // MPT account proof + bytes storageProof // MPT storage proof +) +``` + +**Note**: No `rlpBlockHeader` needed because `targetStateCommitment` is already the state root! + +## Usage Example: Verifying Scroll Message from Ethereum + +```solidity +// On Ethereum, verify a message broadcast on Scroll + +// 1. Route: Ethereum → Scroll +address[] memory route = new address[](1); +route[0] = scrollParentToChildPointer; + +// 2. Input: Batch index (not block number) +bytes[] memory scpInputs = new bytes[](1); +scpInputs[0] = abi.encode(batchIndex); + +// 3. Storage proof (simpler - no block header!) +bytes memory broadcasterProof = abi.encode( + broadcasterAddress, // Contract on Scroll + messageSlot, // keccak256(message, publisher) + accountProof, // MPT account proof + storageProof // MPT storage proof +); + +// 4. Verify +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: broadcasterProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +## Batch Index vs Block Number + +Scroll organizes state commitments by **batch** rather than individual blocks: + +``` +Batch 1: Blocks 1-100 +Batch 2: Blocks 101-250 +Batch 3: Blocks 251-400 +... +``` + +When generating proofs: +1. Find the block number of the transaction +2. Look up which batch contains that block +3. Use the batch index in the prover inputs +4. Generate proofs against the state root at the end of that batch + +### Finding Batch Index + +```javascript +// Use Scroll RPC or explorer to find batch for block +const batchIndex = await scrollProvider.send('scroll_getBatchIndexByBlockNumber', [blockNumber]); +``` + +## Generating Proofs + +### Standard eth_getProof + +Since Scroll uses standard MPT, use regular `eth_getProof`: + +```javascript +const proof = await scrollProvider.send('eth_getProof', [ + contractAddress, + [storageSlot], + blockNumber // Block within the batch +]); + +// For verifyStorageSlot, encode without block header: +const encodedProof = ethers.utils.defaultAbiCoder.encode( + ['address', 'uint256', 'bytes', 'bytes'], + [ + contractAddress, + storageSlot, + encodeProof(proof.accountProof), + encodeProof(proof.storageProof[0].proof) + ] +); +``` + +## Key Considerations + +### State Root Simplification + +The direct state root approach offers: +- **Simpler proofs**: No block header verification needed +- **Smaller proof size**: One fewer proof component +- **Faster verification**: Skip keccak256(blockHeader) check + +### Batch Indexing + +Unlike block-indexed systems: +- State roots are committed per batch, not per block +- Multiple blocks share the same committed state root +- Proof must be generated against the batch's final state + +### Finality + +Scroll's ZK proofs provide: +- Faster finality than optimistic rollups +- State root available once ZK proof is verified on L1 +- No challenge period + +## Related Documentation + +- [../PROVERS.md](../PROVERS.md) - General prover architecture +- [../RECEIVER.md](../RECEIVER.md) - How routes and verification work +- [Scroll Documentation](https://docs.scroll.io/) diff --git a/docs/provers/TAIKO.md b/docs/provers/TAIKO.md new file mode 100644 index 0000000..13f9560 --- /dev/null +++ b/docs/provers/TAIKO.md @@ -0,0 +1,374 @@ +# Taiko Provers + +Taiko is a Based Rollup (type-1 ZK-EVM) that settles to Ethereum. It uses the `SignalService` contract on both L1 and L2 to store cross-chain state commitments (block hashes) via checkpoints. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Ethereum (L1) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ SignalService (L1) │ │ +│ │ mapping(uint48 blockNumber => Checkpoint) checkpoints │ │ +│ │ │ │ +│ │ struct Checkpoint { │ │ +│ │ uint48 blockNumber; │ │ +│ │ bytes32 blockHash; │ │ +│ │ bytes32 stateRoot; │ │ +│ │ } │ │ +│ │ │ │ +│ │ • Stores Taiko L2 block hashes after ZK proof verification │ │ +│ │ • Indexed by L2 block number │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ L2 block hash + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Taiko (L2) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ SignalService (L2) │ │ +│ │ mapping(uint48 blockNumber => Checkpoint) checkpoints │ │ +│ │ │ │ +│ │ • Stores L1 block hashes synced from L1 │ │ +│ │ • Same checkpoint structure as L1 │ │ +│ │ • Indexed by L1 block number │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Standard MPT State │ │ +│ │ • Taiko is EVM-equivalent (type-1 ZK-EVM) │ │ +│ │ • Standard eth_getProof works │ │ +│ │ • Ethereum-identical block structure │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Checkpoint Structure + +Both L1 and L2 SignalService contracts use the same checkpoint structure: + +```solidity +interface ICheckpointStore { + struct Checkpoint { + uint48 blockNumber; // Block number this checkpoint refers to + bytes32 blockHash; // Block hash of that block + bytes32 stateRoot; // State root (may not always be populated) + } + + function getCheckpoint(uint48 _blockNumber) + external view returns (Checkpoint memory); +} +``` + +## ChildToParentProver + +**Direction**: Taiko (L2) → Ethereum (L1) + +This prover verifies Ethereum state from Taiko by reading L1 block hashes stored in the L2 SignalService. + +### Configuration + +```solidity +contract ChildToParentProver is IStateProver { + /// @dev L2 SignalService address + address public immutable signalService; + + /// @dev Storage slot for checkpoints mapping + uint256 public immutable checkpointsSlot; + + /// @dev Taiko L2 chain ID + uint256 public immutable homeChainId; +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Taiko L2 (home chain) +**Returns**: Ethereum block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint48 l1BlockNumber = abi.decode(input, (uint48)); + + Checkpoint memory checkpoint = + ICheckpointStore(signalService).getCheckpoint(l1BlockNumber); + + targetStateCommitment = checkpoint.blockHash; + + if (targetStateCommitment == bytes32(0)) { + revert TargetBlockHashNotFound(); + } +} +``` + +**Input encoding**: `abi.encode(uint48 l1BlockNumber)` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Taiko L2) +**Returns**: Ethereum block hash (proven from Taiko L2 state) + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + uint48 l1BlockNumber, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, uint48, bytes, bytes)); + + // Calculate slot for checkpoints[l1BlockNumber] + // The blockHash is stored at the base slot of the struct + uint256 slot = SlotDerivation.deriveMapping( + bytes32(checkpointsSlot), + l1BlockNumber + ); + + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeBlockHash, rlpBlockHeader, signalService, slot, accountProof, storageProof + ); + + if (targetStateCommitment == bytes32(0)) { + revert TargetBlockHashNotFound(); + } +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Taiko L2 block header + uint48 l1BlockNumber, // L1 block number to retrieve + bytes accountProof, // MPT proof for SignalService + bytes storageProof // MPT proof for checkpoints[l1BlockNumber] +) +``` + +#### `verifyStorageSlot` + +Standard MPT verification against Ethereum block hash. + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + address account, // Contract address on Ethereum + uint256 slot, // Storage slot + bytes accountProof, // MPT account proof + bytes storageProof // MPT storage proof +) +``` + +## ParentToChildProver + +**Direction**: Ethereum (L1) → Taiko (L2) + +This prover verifies Taiko L2 state from Ethereum by reading L2 block hashes from the L1 SignalService. + +### Configuration + +```solidity +contract ParentToChildProver is IStateProver { + /// @dev L1 SignalService address + address public immutable signalService; + + /// @dev Storage slot for checkpoints mapping + uint256 public immutable checkpointsSlot; + + /// @dev Ethereum L1 chain ID + uint256 public immutable homeChainId; +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Ethereum L1 (home chain) +**Returns**: Taiko L2 block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint48 l2BlockNumber = abi.decode(input, (uint48)); + + Checkpoint memory checkpoint = + ICheckpointStore(signalService).getCheckpoint(l2BlockNumber); + + targetStateCommitment = checkpoint.blockHash; + + if (targetStateCommitment == bytes32(0)) { + revert TargetBlockHashNotFound(); + } +} +``` + +**Input encoding**: `abi.encode(uint48 l2BlockNumber)` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not Ethereum L1) +**Returns**: Taiko L2 block hash + +```solidity +function verifyTargetStateCommitment(bytes32 homeBlockHash, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + uint48 l2BlockNumber, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, uint48, bytes, bytes)); + + uint256 slot = SlotDerivation.deriveMapping( + bytes32(checkpointsSlot), + l2BlockNumber + ); + + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeBlockHash, rlpBlockHeader, signalService, slot, accountProof, storageProof + ); + + if (targetStateCommitment == bytes32(0)) { + revert TargetBlockHashNotFound(); + } +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Ethereum block header + uint48 l2BlockNumber, // Taiko L2 block number + bytes accountProof, // MPT proof for L1 SignalService + bytes storageProof // MPT proof for checkpoints[l2BlockNumber] +) +``` + +#### `verifyStorageSlot` + +Standard MPT verification against Taiko L2 block hash. + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded Taiko L2 block header + address account, // Contract address on Taiko L2 + uint256 slot, // Storage slot + bytes accountProof, // MPT account proof + bytes storageProof // MPT storage proof +) +``` + +## Symmetric Design + +Taiko's prover design is notably symmetric - both directions use nearly identical logic: + +| Aspect | ChildToParentProver | ParentToChildProver | +|--------|-------------------|-------------------| +| Home Chain | Taiko L2 | Ethereum L1 | +| Target Chain | Ethereum L1 | Taiko L2 | +| SignalService | L2 contract | L1 contract | +| Checkpoints Key | L1 block number | L2 block number | +| Input Type | `uint48` | `uint48` | + +This symmetry comes from Taiko's design where both chains maintain checkpoints of each other's state. + +## Usage Example: Verifying Taiko Message from Ethereum + +```solidity +// On Ethereum, verify a message broadcast on Taiko L2 + +// 1. Route: Ethereum → Taiko +address[] memory route = new address[](1); +route[0] = taikoParentToChildPointer; + +// 2. Input: L2 block number +bytes[] memory scpInputs = new bytes[](1); +scpInputs[0] = abi.encode(uint48(l2BlockNumber)); + +// 3. Storage proof for Broadcaster on Taiko +bytes memory broadcasterProof = abi.encode( + rlpTaikoBlockHeader, // Taiko L2 block header + broadcasterAddress, // Broadcaster contract + messageSlot, // keccak256(message, publisher) + accountProof, // MPT account proof + storageProof // MPT storage proof +); + +// 4. Verify +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: broadcasterProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +## Storage Slot Calculation + +The SignalService checkpoints mapping uses standard Solidity slot derivation: + +```solidity +// For checkpoints[blockNumber]: +uint256 slot = uint256(keccak256(abi.encode(blockNumber, checkpointsSlot))); + +// The Checkpoint struct at that slot: +// slot + 0: blockNumber (packed with other small values) +// slot + 0: blockHash (first bytes32 of struct) +// slot + 1: stateRoot + +// For the prover, we only need the blockHash at the base slot +``` + +## Key Considerations + +### Block Number Types + +Taiko uses `uint48` for block numbers to save gas: +- Max value: 281,474,976,710,655 +- Sufficient for billions of years of blocks +- Ensure proper type casting when generating proofs + +### Based Rollup Benefits + +As a based rollup, Taiko: +- Inherits Ethereum's security guarantees +- Has faster finality through ZK proofs +- Uses standard Ethereum state structure (type-1 ZK-EVM) + +### SignalService Addresses + +The SignalService addresses are deployment-specific. Common patterns: +- L1: Deployed to a deterministic address via CREATE2 +- L2: Pre-deployed at a known system address + +Check the Taiko documentation or deployment artifacts for current addresses. + +### Checkpoint Availability + +Checkpoints are populated: +- L1 → L2: When L1 blocks are synced to L2 +- L2 → L1: After ZK proof verification on L1 + +There may be a delay between block production and checkpoint availability. + +## Related Documentation + +- [../PROVERS.md](../PROVERS.md) - General prover architecture +- [../RECEIVER.md](../RECEIVER.md) - How routes and verification work +- [Taiko Documentation](https://docs.taiko.xyz/) diff --git a/docs/provers/ZKSYNC.md b/docs/provers/ZKSYNC.md new file mode 100644 index 0000000..37557e1 --- /dev/null +++ b/docs/provers/ZKSYNC.md @@ -0,0 +1,468 @@ +# ZkSync Provers + +ZkSync ERA is a ZK Rollup with a unique architecture that differs significantly from other rollups. Instead of storing state roots or block hashes directly, ZkSync uses an **L2 logs system** for cross-chain communication. This requires a custom broadcaster (`ZkSyncBroadcaster`) and specialized prover logic. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Ethereum (L1) / Gateway │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ ZkChain Contract │ │ +│ │ mapping(uint256 batchNumber => bytes32 l2LogsRootHash) │ │ +│ │ │ │ +│ │ • Stores L2 logs Merkle root for each batch │ │ +│ │ • L2→L1 messages are included in these logs │ │ +│ │ • Updated after ZK proof verification │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ L2 logs root hash + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ ZkSync ERA (L2) │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Block Hash Buffer │ │ +│ │ mapping(uint256 blockNumber => bytes32 blockHash) │ │ +│ │ │ │ +│ │ • Stores L1 block hashes │ │ +│ │ • Used by ChildToParentProver │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ L1Messenger System Contract │ │ +│ │ Address: 0x0000000000000000000000000000000000008008 │ │ +│ │ │ │ +│ │ • Receives L2→L1 messages │ │ +│ │ • Messages included in batch L2 logs │ │ +│ │ • ZkSyncBroadcaster sends messages here │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ ZkSyncBroadcaster │ │ +│ │ • Stores timestamp in storage (like standard Broadcaster) │ │ +│ │ • ALSO sends L2→L1 message with (slot, timestamp) │ │ +│ │ • Messages provable via L2 logs Merkle proof │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Why ZkSync is Different + +### No Direct Storage Proofs + +ZkSync doesn't expose storage in a standard MPT format that can be proven externally. Instead: + +1. **L2→L1 Communication**: Messages are sent via the L1Messenger system contract +2. **Batched Logs**: Messages are batched and committed as a Merkle tree +3. **L2 Logs Root**: Each batch has an `l2LogsRootHash` that commits to all messages + +### ZkSyncBroadcaster + +The standard `Broadcaster` stores timestamps in storage, but ZkSync can't prove storage slots to L1. The `ZkSyncBroadcaster` solves this by: + +1. Storing the timestamp in storage (for local queries) +2. Sending an L2→L1 message containing `(slot, timestamp)` +3. The message can be proven via the L2 logs Merkle tree + +## ChildToParentProver + +**Direction**: ZkSync (L2) → Ethereum (L1) + +This prover reads L1 block hashes from a block hash buffer on ZkSync. + +### Configuration + +```solidity +contract ChildToParentProver is IStateProver { + /// @dev Block hash buffer address (deployment-specific) + address public immutable blockHashBuffer; + + /// @dev Storage slot for parentChainBlockHash mapping + uint256 public constant blockHashMappingSlot = 1; + + /// @dev ZkSync chain ID + uint256 public immutable homeChainId; +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: ZkSync (home chain) +**Returns**: Ethereum block hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint256 targetBlockNumber = abi.decode(input, (uint256)); + targetStateCommitment = IBuffer(blockHashBuffer).parentChainBlockHash(targetBlockNumber); +} +``` + +**Input encoding**: `abi.encode(uint256 targetBlockNumber)` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not ZkSync) +**Returns**: Ethereum block hash + +Standard MPT proof verification against ZkSync block hash. + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded ZkSync block header + uint256 targetBlockNumber, // L1 block number + bytes accountProof, // MPT proof for buffer contract + bytes storageProof // MPT proof for storage slot +) +``` + +#### `verifyStorageSlot` + +Standard MPT verification. + +## ParentToChildProver + +**Direction**: Ethereum/Gateway (L1) → ZkSync (L2) + +This prover verifies ZkSync L2 state by proving message inclusion in the L2 logs Merkle tree. + +### Configuration + +```solidity +contract ParentToChildProver is IStateProver { + /// @dev ZkChain contract on the gateway/L1 + IZkChain public immutable gatewayZkChain; + + /// @dev Storage slot for l2LogsRootHash mapping + uint256 public immutable l2LogsRootHashSlot; + + /// @dev Child chain ID (ZkSync L2) + uint256 public immutable childChainId; + + /// @dev Gateway chain ID (settlement layer) + uint256 public immutable gatewayChainId; + + /// @dev Home chain ID (where prover is deployed) + uint256 public immutable homeChainId; +} +``` + +### L2 Log Structure + +ZkSync L2 logs have a specific format: + +```solidity +struct L2Log { + uint8 l2ShardId; // Always 0 for now + bool isService; // True for system messages + uint16 txNumberInBatch; // Transaction index in batch + address sender; // L1Messenger address (0x8008) + bytes32 key; // Encoded message sender + bytes32 value; // Hash of message data +} +``` + +### L2 Message Structure + +```solidity +struct L2Message { + uint16 txNumberInBatch; // Transaction index + address sender; // Original L2 sender (Broadcaster) + bytes data; // Encoded (slot, timestamp) +} +``` + +### Proof Structure + +```solidity +struct ZkSyncProof { + uint256 batchNumber; // Batch containing the message + uint256 index; // Leaf index in Merkle tree + L2Message message; // The message to prove + bytes32[] proof; // Merkle proof (with metadata) +} +``` + +### Functions + +#### `getTargetStateCommitment` + +**Called on**: Gateway/L1 (home chain) +**Returns**: L2 logs root hash + +```solidity +function getTargetStateCommitment(bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + uint256 batchNumber = abi.decode(input, (uint256)); + targetStateCommitment = gatewayZkChain.l2LogsRootHash(batchNumber); + + if (targetStateCommitment == bytes32(0)) { + revert L2LogsRootHashNotFound(); + } +} +``` + +**Input encoding**: `abi.encode(uint256 batchNumber)` + +#### `verifyTargetStateCommitment` + +**Called on**: Remote chains (not home chain) +**Returns**: L2 logs root hash (proven from L1 state) + +```solidity +function verifyTargetStateCommitment(bytes32 homeStateCommitment, bytes calldata input) + external view returns (bytes32 targetStateCommitment) +{ + ( + bytes memory rlpBlockHeader, + uint256 batchNumber, + bytes memory accountProof, + bytes memory storageProof + ) = abi.decode(input, (bytes, uint256, bytes, bytes)); + + uint256 slot = SlotDerivation.deriveMapping( + bytes32(l2LogsRootHashSlot), + batchNumber + ); + + targetStateCommitment = ProverUtils.getSlotFromBlockHeader( + homeStateCommitment, rlpBlockHeader, + address(gatewayZkChain), slot, + accountProof, storageProof + ); +} +``` + +**Input encoding**: +```solidity +abi.encode( + bytes rlpBlockHeader, // RLP-encoded L1 block header + uint256 batchNumber, // ZkSync batch number + bytes accountProof, // MPT proof for ZkChain contract + bytes storageProof // MPT proof for l2LogsRootHash[batchNumber] +) +``` + +#### `verifyStorageSlot` + +**Key difference**: Uses L2 logs Merkle proof instead of storage proof. + +```solidity +function verifyStorageSlot(bytes32 targetStateCommitment, bytes calldata input) + external view returns (address account, uint256 slot, bytes32 value) +{ + ( + ZkSyncProof memory proof, + address senderAccount, + bytes32 message + ) = abi.decode(input, (ZkSyncProof, address, bytes32)); + + account = senderAccount; + + // Convert L2Message to L2Log format + L2Log memory log = _l2MessageToLog(proof.message); + + // Hash the log + bytes32 hashedLog = keccak256(abi.encodePacked( + log.l2ShardId, + log.isService, + log.txNumberInBatch, + log.sender, + log.key, + log.value + )); + + // Verify Merkle proof + if (!_proveL2LeafInclusion({ + _chainId: childChainId, + _blockOrBatchNumber: proof.batchNumber, + _leafProofMask: proof.index, + _leaf: hashedLog, + _proof: proof.proof, + _targetBatchRoot: targetStateCommitment + })) { + revert BatchSettlementRootMismatch(); + } + + // Extract slot and timestamp from message data + (bytes32 slotSent, bytes32 timestamp) = abi.decode( + proof.message.data, + (bytes32, bytes32) + ); + + // Verify slot matches expected + bytes32 expectedSlot = keccak256(abi.encode(message, account)); + if (slotSent != expectedSlot) { + revert SlotMismatch(); + } + + slot = uint256(slotSent); + value = timestamp; +} +``` + +**Input encoding**: +```solidity +abi.encode( + ZkSyncProof proof, // Merkle proof structure + address senderAccount, // Publisher address + bytes32 message // The broadcast message +) +``` + +### Merkle Proof Verification + +ZkSync uses a custom Merkle tree structure with metadata: + +```solidity +// Proof metadata (first element): +// - Byte 0: Version (0x01 for new format) +// - Byte 1: Log leaf proof length +// - Byte 2: Batch leaf proof length +// - Byte 3: Is final proof node + +function _proveL2LeafInclusion( + uint256 _chainId, + uint256 _blockOrBatchNumber, + uint256 _leafProofMask, + bytes32 _leaf, + bytes32[] memory _proof, + bytes32 _targetBatchRoot +) internal view returns (bool); +``` + +The verification may be recursive for chains using the Gateway as a settlement layer. + +## Message Conversion + +The L2Message from the Broadcaster is converted to L2Log format: + +```solidity +function _l2MessageToLog(L2Message memory _message) + internal pure returns (L2Log memory) +{ + return L2Log({ + l2ShardId: 0, + isService: true, + txNumberInBatch: _message.txNumberInBatch, + sender: 0x0000000000000000000000000000000000008008, // L1Messenger + key: bytes32(uint256(uint160(_message.sender))), + value: keccak256(_message.data) + }); +} +``` + +## Usage Example: Verifying ZkSync Message from Ethereum + +```solidity +// On Ethereum, verify a message broadcast on ZkSync + +// 1. Route: Ethereum → ZkSync (single hop) +address[] memory route = new address[](1); +route[0] = zkSyncParentToChildPointer; + +// 2. Input: Batch number +bytes[] memory scpInputs = new bytes[](1); +scpInputs[0] = abi.encode(batchNumber); + +// 3. ZkSync proof structure +bytes32[] memory merkleProof = new bytes32[](36); +// ... populate with actual proof data + +ZkSyncProof memory proof = ZkSyncProof({ + batchNumber: batchNumber, + index: leafIndex, + message: L2Message({ + txNumberInBatch: txNumber, + sender: broadcasterAddress, + data: abi.encode(messageSlot, timestamp) + }), + proof: merkleProof +}); + +bytes memory broadcasterProof = abi.encode( + proof, + publisherAddress, + message +); + +// 4. Verify +IReceiver.RemoteReadArgs memory args = IReceiver.RemoteReadArgs({ + route: route, + scpInputs: scpInputs, + proof: broadcasterProof +}); + +(bytes32 broadcasterId, uint256 timestamp) = receiver.verifyBroadcastMessage( + args, + message, + publisher +); +``` + +## Generating Proofs + +### Getting the L2 Log Proof + +Use the ZkSync API to get the Merkle proof: + +```javascript +// Using ZkSync SDK +const receipt = await provider.getTransactionReceipt(txHash); + +// Get the L2→L1 log proof +const proof = await provider.getLogProof(txHash, logIndex); + +// Structure the ZkSyncProof +const zkSyncProof = { + batchNumber: receipt.l1BatchNumber, + index: proof.id, + message: { + txNumberInBatch: receipt.l1BatchTxIndex, + sender: broadcasterAddress, + data: encodedData // abi.encode(slot, timestamp) + }, + proof: proof.proof +}; +``` + +## Key Considerations + +### ZkSyncBroadcaster Required + +The standard `Broadcaster` won't work on ZkSync because storage proofs can't be verified. Always use `ZkSyncBroadcaster` which sends L2→L1 messages. + +### Gateway Architecture + +ZkSync ERA may use a Gateway chain as an intermediate settlement layer: +- L2 → Gateway → L1 +- The prover handles this via recursive proof verification +- `gatewayChainId` identifies the settlement layer + +### Batch Finality + +Messages are only provable after: +1. The batch containing the message is committed +2. The ZK proof for that batch is verified +3. The `l2LogsRootHash` is written to the ZkChain contract + +### Message Format + +The `ZkSyncBroadcaster` sends: `abi.encode(slot, timestamp)` +- `slot`: `keccak256(abi.encode(message, publisher))` +- `timestamp`: `block.timestamp` when broadcast + +This matches the storage layout of the standard Broadcaster, enabling consistent verification. + +## Related Documentation + +- [../BROADCASTER.md](../BROADCASTER.md) - ZkSyncBroadcaster details +- [../PROVERS.md](../PROVERS.md) - General prover architecture +- [../RECEIVER.md](../RECEIVER.md) - How routes and verification work +- [ZkSync Documentation](https://docs.zksync.io/)