# EEZ Quickstarts > Seventeen interactive walkthroughs of the Ethereum Economic Zone, each built on real > protocol source cited at a pinned commit rather than on pseudocode. Covers writing > contracts that call across rollups, running the stack, and how Rollup0 settles and proves. Every code panel on this site is rendered from JavaScript at runtime, so fetching a page gives you the shell and not the content. This file is the whole thing in one document: each walkthrough's narration and code, the full compilable Solidity behind the Solidity ones, and the runnable Foundry tests. Two things worth knowing before you use any of it. EEZ is pre-mainnet, so treat capability claims as design intent rather than shipped behaviour; the proof system in the public source is dev-grade ECDSA, not production ZK. And every citation here is pinned to a commit SHA, so a link tells you exactly which revision a claim was true of. --- # Dapp developers Writing contracts that call across rollups. ## How to read another rollup's state from your contract Learn how the answer is written down before your transaction runs, instead of being fetched while you wait. - Page: https://eez-demos.vercel.app/dapp-developers/q8-read-a-remote-contracts-state.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:89 · _fallback` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/CrossChainProxy.sol#L89 ### Step 1 — A CONTRACT ON THIS CHAIN The proxy is an ordinary contract, deployed on this chain. Every opcode that asks the EVM about an address answers about it — not about the contract it stands for. ```solidity address proxy = eez.computeCrossChainProxyAddress( remote, remoteRollupId ); // a contract deployed on THIS chain, with its // own code and its own balance ``` ### Step 2 — FOUR READS, NO REVERT balance, extcodesize, extcodecopy and delegatecall describe the proxy; block-state opcodes describe the chain you are executing on. Each read succeeds and returns a real number about the wrong contract. ```diff address proxy = eez.computeCrossChainProxyAddress( remote, remoteRollupId ); // a contract deployed on THIS chain, with its // own code and its own balance -uint256 bal = proxy.balance; // PROXY's ether -uint256 size = proxy.code.length; // PROXY's code -bool sameChain = - block.chainid == remoteChainId; // THIS chain ``` ### Step 3 — ASK IT, DON'T INSPECT IT Call through the proxy instead — the read is routed, and resolves against an entry written before your transaction ran. ```diff address proxy = eez.computeCrossChainProxyAddress( remote, remoteRollupId ); -uint256 bal = proxy.balance; // PROXY's ether +uint256 bal = IRemote(proxy).balanceOf(user); // the proxy is an IDENTITY, not a mirror: // ask it, don't inspect it ``` ### Full compilable source — snippets/q8-remote-reads.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {IEEZ} from "./lib/IEEZ.sol"; // Panel source for: dapp-developers/q8-read-a-remote-contracts-state.html // Mirrors eez-core-protocol/src/base/CrossChainProxy.sol:89 // and eez-core-protocol/docs/CAVEATS.md, "Opcodes that differ on cross-chain // proxies" and "Indistinguishable revert reasons when calling a proxy" // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // This is the caveat with no compiler help. q3's mistake reverts, so you find // it the first time a call arrives through a proxy. The reads below SUCCEED and // return a real number about the wrong contract, so they ship. // // WHY: a CrossChainProxy is an ordinary contract, deployed on THIS chain, with // its own code and its own balance. An opcode that asks the EVM about an // address answers about that local contract. Only a CALL is routed: the proxy's // fallback (CrossChainProxy.sol:89, `_fallback`) forwards to the manager, which // resolves an outcome that was written down before your transaction ran. So // inspection is local, invocation is resolved. // // The four that answer about the proxy, per CAVEATS.md: `balance`, // `extcodesize`, `extcodecopy`, `delegatecall`. Plus every block-state opcode // (`number`, `blockhash`, `chainid`, `coinbase`, `gaslimit`), which describes // the chain currently executing, not the source chain — so the same logical // action reads differently on L1 and on L2. // // `delegatecall` deserves its own line: delegatecalling a proxy runs the // PROXY's code in your storage context. It does not reach the remote contract. // // WHEN A ROUTED READ RESOLVES — the rule is side-specific, so do not learn one // half of it and carry it to the other chain: // // L1 has NO block gate on the read path. `staticCrossChainCall` // (EEZ.sol:1266-1328) falls through to // `verificationByRollup[destRid].staticEntryQueue` (:1309-1310) and matches on // `proxyEntryHash`, `destinationRollupId` and `_stateRootsMatch(...)` // (:1317-1319). Upstream's own comment at :1308: "Note that static calls do // not obsolete after a block passes. As long as the state roots matches it can // be execute". // // L2 DOES gate it. `EEZL2.sol:634` is // `if (lastLoadBlock != block.number) revert ExecutionNotInCurrentBlock();`, // and the docstring at :588-589 gives the reason: "no pins on L2 — the block // gate bounds staleness". // // Mutating calls are block-gated on both sides. On L1 that is EEZ.sol:794-797, // `revert ExecutionNotInCurrentBlock(destRid)`. // // A read with nothing to resolve against reverts `ExecutionNotFound()` — // EEZ.sol:1327 and EEZL2.sol:644. That is the string to search for. // // AND YOU CANNOT TELL WHY IT FAILED. CAVEATS.md, "Indistinguishable revert // reasons when calling a proxy": a caller cannot differentiate a call reverting // because no matching entry existed from the destination call actually // reverting. Both bubble up as a revert from the proxy, because // CrossChainProxy.sol:109-113 forwards the raw revert data unmodified. Do not // write a `try/catch` that claims to know which one happened. interface IRemote { function balanceOf(address account) external view returns (uint256); } contract RemoteReader { IEEZ internal immutable eez; address internal immutable remote; uint64 internal immutable remoteRollupId; constructor(IEEZ eez_, address remote_, uint64 remoteRollupId_) { eez = eez_; remote = remote_; remoteRollupId = remoteRollupId_; } /// @dev The trap. Compiles, runs, returns real numbers about the proxy. /// Returns are intentionally unnamed: the panel lines below declare /// `bal`, `size` and `sameChain` verbatim, and named returns would /// shadow them. function inspectionLies(uint256 remoteChainId) external view returns (uint256, uint256, bool) { address proxy = eez.computeCrossChainProxyAddress( remote, remoteRollupId ); // a contract deployed on THIS chain, with its // own code and its own balance uint256 bal = proxy.balance; // PROXY's ether uint256 size = proxy.code.length; // PROXY's code bool sameChain = block.chainid == remoteChainId; // THIS chain return (bal, size, sameChain); } /// @dev The fix. A view call through the proxy is routed, and resolves /// against an entry the composer already wrote — so what comes back is /// the remote contract's state, not the proxy's. /// /// When it resolves is side-specific: on L1 a static entry stays valid /// as long as the state roots still match (EEZ.sol:1308-1319), on L2 it /// is gated on the current block (EEZL2.sol:634). With no matching /// entry on either side, this reverts `ExecutionNotFound()` — and that /// revert is indistinguishable from the destination reverting. function readRemoteBalance(address user) external view returns (uint256) { address proxy = eez.computeCrossChainProxyAddress( remote, remoteRollupId ); uint256 bal = IRemote(proxy).balanceOf(user); // the proxy is an IDENTITY, not a mirror: // ask it, don't inspect it return bal; } /// @dev A live proxy says nothing about the remote target. Code at the proxy /// address means the manager will route through it — not that anything /// answers on the other side. See q4 for reading the registry. function proxyExistsButProvesNothing() external view returns (bool) { address proxy = eez.computeCrossChainProxyAddress(remote, remoteRollupId); return proxy.code.length != 0; } } ``` ### Runnable test — snippets/test/Q8RemoteReads.t.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {IEEZ} from "../lib/IEEZ.sol"; import {RemoteReader, IRemote} from "../q8-remote-reads.sol"; // Runnable proof of the q8 caveat. Paste into any Foundry project and run: // // forge test --match-contract Q8RemoteReads -vv // // Depends on nothing but solc — no forge-std, no submodules. // // The first three cases are the whole hazard: NOTHING REVERTS. Each is a // successful read returning a real number about the wrong contract. Unlike the // q3 owner check, there is no failure to notice. // // WHAT IS FAKED HERE, AND WHAT IS NOT. Q6ManagerDirect.t.sol refuses to fake // protocol state and says so in its header; the same line is held here. // `EntryTable` below is not an EEZ manager and does not pretend to be one. It // is a local model of the one property the routed read rests on: the outcome // was written into a table BEFORE the transaction ran, it is matched by a hash // of the call, and a miss reverts `ExecutionNotFound()`. That is the shape of // EEZ resolution — the opposite of fetch-on-demand messaging, where the answer // is gone and fetched while you wait. // // What this establishes: the reader in ../q8-remote-reads.sol behaves correctly // against a resolver of that shape, including both failure modes a builder will // actually meet. What it does NOT establish: that upstream resolves this way. // The citations on the page and scripts/verify-citations.py are the check for // that, and nothing in a local table could stand in for it. // // Deliberately not modelled, because a local table cannot: the composer, the // rolling hash, state roots, the entry queues, and the side-specific rule for // when a static entry is still valid (L1 matches on state roots with no block // gate, EEZ.sol:1308-1319; L2 gates on the block, EEZL2.sol:634). The key below // is a REDUCED stand-in for `computeCrossChainCallHash` — q5 has the real // eight-field preimage. What is under test is lookup-then-revert, not the // field list. /// @dev Minimal cheatcode surface. Same address forge injects. interface Vm { function deal(address, uint256) external; function label(address, string calldata) external; function expectRevert(bytes4) external; } /// @dev Named exactly as upstream names it (EEZ.sol:1327, EEZL2.sol:644), so a /// reader who hits it in production can search for the same string. error ExecutionNotFound(); /// @dev The destination's own error, for the case where an entry exists and /// records that the destination call reverted. error RemoteBalanceUnavailable(); // @stand-in a local table, not a manager: it holds outcomes keyed by a reduced // call hash so the test can exercise hit, miss and destination-revert // without faking EEZ storage. See the header. contract EntryTable { struct Entry { bool present; bool success; bytes data; } mapping(bytes32 => Entry) private entries; address private immutable destAddress; uint64 private immutable destRollupId; constructor(address destAddress_, uint64 destRollupId_) { destAddress = destAddress_; destRollupId = destRollupId_; } /// @dev Reduced stand-in for computeCrossChainCallHash. The real preimage is /// eight fields in a fixed order (q5); the point here is only that the /// entry is addressed by the content of the call. function keyFor(address sourceAddress, bytes memory callData) public view returns (bytes32) { return keccak256(abi.encode(true, sourceAddress, destAddress, destRollupId, callData)); } /// @dev The composer's job, done by hand. In production nothing in your /// transaction writes this table. function commit(address sourceAddress, bytes calldata callData, bool success, bytes calldata data) external { entries[keyFor(sourceAddress, callData)] = Entry(true, success, data); } /// @dev Same name and signature as the manager's read entry point, so the /// shape of the call the proxy makes is the real one. function staticCrossChainCall(address sourceAddress, bytes calldata callData) external view returns (bytes memory) { Entry storage entry = entries[keyFor(sourceAddress, callData)]; if (!entry.present) revert ExecutionNotFound(); if (!entry.success) { bytes memory reason = entry.data; assembly { revert(add(reason, 0x20), mload(reason)) } } return entry.data; } } // @stand-in a two-line model of CrossChainProxy's read path: a real local // contract with its own code and balance, whose fallback forwards to // the manager's view entry point and hands back exactly what comes // out — including raw revert data (CrossChainProxy.sol:104-113). contract StubProxy { address internal immutable manager; constructor(address manager_) { manager = manager_; } fallback() external payable { (bool ok, bytes memory result) = manager.staticcall(abi.encodeCall(EntryTable.staticCrossChainCall, (msg.sender, msg.data))); if (ok) { // The manager returns `bytes`, so the raw result is double-encoded. result = abi.decode(result, (bytes)); } assembly { switch ok case 0 { revert(add(result, 0x20), mload(result)) } default { return(add(result, 0x20), mload(result)) } } } receive() external payable {} } // @stand-in an address book, nothing more: the reader asks for its proxy and // gets the stub back. It does not derive anything — q7 is the page // for the real CREATE2 derivation. contract MockEEZ { address private immutable proxyAddress; constructor(address proxyAddress_) { proxyAddress = proxyAddress_; } function computeCrossChainProxyAddress(address, uint64) external view returns (address) { return proxyAddress; } } // @stand-in the contract being read, as it exists on the OTHER rollup. It is // local here only so the test can hold two different numbers at once. // The routed read must never touch it, which is what `calls` proves. contract Counterpart { uint256 public calls; mapping(address => uint256) private balances; function setBalance(address who, uint256 amount) external { balances[who] = amount; } function balanceOf(address who) external returns (uint256) { calls++; return balances[who]; } } contract Q8RemoteReadsTest { Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); address internal constant USER = address(0xA11CE); address internal constant STRANGER = address(0xB0B); uint64 internal constant REMOTE_ROLLUP_ID = 1; uint256 internal constant COMMITTED_BALANCE = 500 ether; uint256 internal constant PROXY_BALANCE = 1 wei; uint256 internal constant COUNTERPART_BALANCE = 7 ether; Counterpart internal counterpart; EntryTable internal table; StubProxy internal proxy; RemoteReader internal reader; function setUp() public { counterpart = new Counterpart(); counterpart.setBalance(USER, COMMITTED_BALANCE); vm.deal(address(counterpart), COUNTERPART_BALANCE); table = new EntryTable(address(counterpart), REMOTE_ROLLUP_ID); proxy = new StubProxy(address(table)); vm.deal(address(proxy), PROXY_BALANCE); reader = new RemoteReader( IEEZ(address(new MockEEZ(address(proxy)))), address(counterpart), REMOTE_ROLLUP_ID ); // The entry for the one read this test expects to resolve. Written // before any of it runs, which is the whole point. table.commit( address(reader), abi.encodeCall(IRemote.balanceOf, (USER)), true, abi.encode(COMMITTED_BALANCE) ); vm.label(address(proxy), "proxy(counterpart)"); vm.label(address(counterpart), "counterpart"); vm.label(address(table), "entryTable"); } /// @dev The trap, part one. `.balance` answers about the proxy. No revert. function test_balanceReadsTheProxyNotTheCounterpart() public view { (uint256 bal,,) = reader.inspectionLies(block.chainid + 1); require(bal == PROXY_BALANCE, "expected the proxy's own balance"); require(bal != COUNTERPART_BALANCE, "must not be the counterpart's balance"); } /// @dev The trap, part two. The proxy has code, so an `extcodesize`-style /// "is this a contract" check passes — and proves nothing about whether /// anything exists on the other rollup. function test_codeSizeIsTheProxysOwn() public view { (, uint256 size,) = reader.inspectionLies(block.chainid + 1); require(size == address(proxy).code.length, "the proxy's own code size"); require( keccak256(address(proxy).code) != keccak256(address(counterpart).code), "proxy code is not the counterpart's code" ); require(reader.proxyExistsButProvesNothing(), "code at the proxy address proves only that"); } /// @dev The trap, part three. Block context is local. A reader comparing /// against the counterparty's chain id silently takes the wrong branch. function test_blockContextIsLocal() public view { (,, bool sameChain) = reader.inspectionLies(block.chainid + 1); require(!sameChain, "block.chainid is this chain's, never the remote one"); } /// @dev The fix. Calling THROUGH the proxy returns the remote contract's /// state, because a call is routed and an inspection is not — and the /// value comes out of the entry, not out of a live fetch. `calls == 0` /// is that second half: the counterpart is never touched. function test_routedReadReturnsThePreCommittedEntry() public view { uint256 seen = reader.readRemoteBalance(USER); require(seen == COMMITTED_BALANCE, "routed read returns the committed outcome"); require(counterpart.calls() == 0, "resolution never calls the counterpart"); } /// @dev The honest limit. With no matching entry the routed read reverts /// `ExecutionNotFound()` rather than returning zero. In production that /// is a read whose static entry is not in scope: on L2 because the block /// moved on (EEZL2.sol:634), on L1 because no entry in the queue matches /// the call and the live state roots (EEZ.sol:1308-1319). function test_unresolvedReadRevertsExecutionNotFound() public { vm.expectRevert(ExecutionNotFound.selector); reader.readRemoteBalance(STRANGER); } /// @dev CAVEATS.md, "Indistinguishable revert reasons when calling a proxy". /// The proxy forwards raw revert data and adds nothing of its own, so /// the caller has no field saying which layer failed — only the bytes it /// was handed. Both cases below hand back the same bytes. function test_missAndDestinationRevertAreIndistinguishable() public { bytes memory callData = abi.encodeCall(IRemote.balanceOf, (STRANGER)); // A: nothing committed for STRANGER, so the table itself reverts. (bool okMiss, bytes memory fromMiss) = address(proxy).staticcall(callData); // B: an entry EXISTS and records that the destination call reverted, // carrying the destination's own revert bytes. table.commit(address(this), callData, false, abi.encodeWithSelector(ExecutionNotFound.selector)); (bool okReverted, bytes memory fromReverted) = address(proxy).staticcall(callData); require(!okMiss && !okReverted, "both fail"); require(keccak256(fromMiss) == keccak256(fromReverted), "the caller cannot tell them apart"); // And a destination that reverts with its own error is no more legible: // it is still just bytes arriving from the proxy. table.commit(address(this), callData, false, abi.encodeWithSelector(RemoteBalanceUnavailable.selector)); (bool okOther, bytes memory fromOther) = address(proxy).staticcall(callData); require(!okOther, "still a revert from the proxy"); require(fromOther.length == 4, "four bytes of someone else's error, and no attribution"); } } ``` ## How to use another chain's answer in the same transaction Learn how the value that comes back decides what the rest of your transaction does — and why that removes the callback, the timeout and the retry you would otherwise have to write. - Page: https://eez-demos.vercel.app/dapp-developers/q2-send-a-cross-chain-call.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:89 · _fallback` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/CrossChainProxy.sol#L89 ### Step 1 — ENCODE IT LIKE ANY CALL Encode a normal call — no cross-chain-specific ABI. ```solidity bytes memory data = abi.encodeCall( IRemote.balanceOf, (user) ); ``` ### Step 2 — STATICCALL, NOT CALL Send it to the proxy as a staticcall. The proxy keys off your frame, not the callee's view — that is what routes a read to its pre-computed static entry. ```solidity bytes memory data = abi.encodeCall( IRemote.balanceOf, (user) ); // STATICCALL, not CALL: the proxy keys off this // frame, not off `view`. (bool ok, bytes memory ret) = proxyAddress.staticcall(data); if (!ok) revert CrossChainCallFailed(); // CrossChainProxy.sol fallback() external payable { _fallback(); } ``` ### Step 3 — DECODE IT AND BRANCH The answer arrives as ordinary return data. Decode it and branch — a value from another rollup just decided what this transaction did. ```solidity uint256 have = abi.decode(ret, (uint256)); if (have < need) { shortfall[user] = need - have; return false; } credited[user] += need; return true; // CrossChainProxy.sol if (!success) { (success, result) = EEZ.staticcall( abi.encodeCall( IEEZ.staticCrossChainCall, (msg.sender, msg.data))); } ``` ### Full compilable source — snippets/q2-cross-chain-call.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {IEEZ} from "./lib/IEEZ.sol"; // Panel source for: dapp-developers/q2-send-a-cross-chain-call.html // Mirrors eez-core-protocol/src/base/CrossChainProxy.sol:39 and :89 // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // Caller side is ordinary: encode a call, send it to the proxy address like // any other contract. The proxy has no matching function, so everything // lands in fallback() and gets forwarded to the manager. // // A read is the same code with one difference that matters: STATICCALL // instead of CALL. That is what the proxy's probe detects, and it is what // decides whether the manager resolves an ExecutionEntry or a // StaticExecutionEntry. See `settleIfFunded` below. interface IRemote { function setValue(uint256 v) external; function balanceOf(address account) external view returns (uint256); } /// @dev A cross-chain call fails for reasons an ordinary same-chain call /// cannot, and the proxy forwards raw revert data either way /// (CrossChainProxy.sol:109-113). So `ok == false` tells you the call /// did not happen — never why. No matching entry in this block and a /// genuine revert in the destination contract are indistinguishable /// here; see docs/CAVEATS.md "Indistinguishable revert reasons". error CrossChainCallFailed(); /// @dev Raised on a value that came back from another rollup. The point of the /// mechanism is not that the call succeeded — it is that the answer /// decides what the rest of this transaction does. error InsufficientRemoteBalance(uint256 have, uint256 need); /// @dev Caller side — this is all a dapp has to do. contract Caller { address public proxyAddress; mapping(address account => uint256 amount) public credited; mapping(address account => uint256 amount) public shortfall; constructor(address proxyAddress_) { proxyAddress = proxyAddress_; } /// @dev The write path. Nothing comes back worth reading, so `ok` is the /// whole result and the call is an ordinary CALL. function send() external { bytes memory data = abi.encodeCall( IRemote.setValue, (42) ); (bool ok, ) = proxyAddress.call(data); if (!ok) revert CrossChainCallFailed(); } /// @dev The read path — a value from another rollup deciding a branch here. /// /// The one thing to get right: the proxy picks static-vs-mutable from /// the CALLER'S frame, not from `view`. Its probe does a `tstore`, and /// a `tstore` only halts under STATICCALL (CrossChainProxy.sol:71). A /// plain `.call(data)` here would therefore take the mutable path and /// demand an ExecutionEntry no composer wrote for a read — on L2 that /// is `EntryNotFound(hash, callGas)` (EEZL2.sol:441). STATICCALL is /// what routes it to `staticCrossChainCall` (EEZL2.sol:594) and a /// `StaticExecutionEntry`. function settleIfFunded(address user, uint256 need) external returns (bool) { bytes memory data = abi.encodeCall( IRemote.balanceOf, (user) ); // STATICCALL, not CALL: the proxy keys off this // frame, not off `view`. (bool ok, bytes memory ret) = proxyAddress.staticcall(data); if (!ok) revert CrossChainCallFailed(); // The proxy returns the destination call's raw // return data, so decode it exactly as you would // a same-chain read — then branch on the value. uint256 have = abi.decode(ret, (uint256)); if (have < need) { shortfall[user] = need - have; return false; } credited[user] += need; return true; } } /// @dev Proxy side — how the forward actually happens. contract CrossChainProxySnippet { address internal immutable EEZ; /// @dev Upstream value, from CrossChainProxy.sol:23. The cap IS the cost: in a /// static context the tstore is an exceptional halt, which consumes every /// unit forwarded to it, so the probe must be given only what you are /// willing to burn on every call. The mutable path spends ~300 of it. uint256 internal constant STATIC_CHECK_GAS = 1_000; constructor(address eez_) { EEZ = eez_; } /// @dev tstore reverts in a STATICCALL context, tload does not. A self-call /// isolates the tstore so the revert can be caught instead of bubbling. /// /// STAND-IN: upstream declares a named `uint256 transient _staticDetector` /// (CrossChainProxy.sol:18) and writes it in staticCheck() (:71). Raw slot 0 /// is used here only to keep the probe to one readable line. Do not copy it: /// raw slot 0 is not reserved for you, so in a real contract that also uses /// transient storage this probe is a collision risk. Declare a named /// `transient` variable, as upstream does, and let the compiler place it. function staticCheck() external { assembly { tstore(0, 1) } } // CrossChainProxy.sol fallback() external payable { _fallback(); } function _fallback() internal { (bool success,) = address(this).call{ gas: STATIC_CHECK_GAS }(abi.encodeCall(this.staticCheck, ())); bytes memory result; if (!success) { (success, result) = EEZ.staticcall( abi.encodeCall( IEEZ.staticCrossChainCall, (msg.sender, msg.data))); } else { (success, result) = EEZ.call{value: msg.value}( abi.encodeCall( IEEZ.executeCrossChainCall, (msg.sender, msg.data))); } if (success) result = abi.decode(result, (bytes)); assembly { switch success case 0 { revert(add(result, 0x20), mload(result)) } default { return(add(result, 0x20), mload(result)) } } } } ``` ### Runnable test — snippets/test/Q2CrossChainCall.t.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {Caller, IRemote, CrossChainCallFailed} from "../q2-cross-chain-call.sol"; // Runnable proof of the one rule q2 turns on. Paste into any Foundry project: // // forge test --match-contract Q2CrossChainCall -vv // // Depends on nothing but solc — no forge-std, no submodules. // // The claim under test is not "a cross-chain call works." It is narrower and it // is the thing a builder gets wrong: THE CALLER'S FRAME PICKS THE PATH. The // proxy probes itself with a `tstore`, a `tstore` is an exceptional halt inside // a STATICCALL (EIP-1153), and that halt is the entire signal. `view` on the // callee has nothing to do with it. So `staticcall` reaches the read path and a // pre-committed static entry; a plain `call` reaches the execution path and // demands an entry no composer wrote for a read. // // That halt is real here, not simulated: `ProbeProxy` does the actual `tstore` // and solc/revm enforce the static-context rule. This is the one part of the // mechanism a local test CAN establish honestly. // // WHAT IS FAKED, AND WHAT IS NOT. `EntryTable` is not an EEZ manager and does // not pretend to be one, the same line Q6ManagerDirect and Q8RemoteReads hold. // It models one property the whole design rests on: the outcome was written // BEFORE this transaction ran, it is addressed by the content of the call, and // a miss reverts. Deliberately not modelled, because a local table cannot: the // composer, the rolling hash, state roots, the entry queues, the block gate, // and the side-specific rule for when a static entry is still valid (L1 matches // on state roots, EEZ.sol:1308-1319; L2 gates on the block, EEZL2.sol:634). // // The key below is a REDUCED stand-in for `computeCrossChainCallHash` — the // real preimage is eight fields in a fixed order (q5). What is under test is // routing and the branch, not the field list. /// @dev Minimal cheatcode surface. Same address forge injects. interface Vm { function expectRevert(bytes4) external; function label(address, string calldata) external; } /// @dev Named as upstream names them, so a reader who hits one in production can /// search for the same string. The read path reverts `ExecutionNotFound` on /// either side (EEZ.sol:1327, EEZL2.sol:644); the L2 execution path reverts /// `EntryNotFound` and carries the observed `callGas` in the payload so the /// entry-builder can reproduce the key (EEZL2.sol:98, :441). error ExecutionNotFound(); error EntryNotFound(bytes32 crossChainCallHash, uint64 callGas); /// @dev The destination's own error, for the case where an entry exists and /// records that the destination call reverted. error RemoteBalanceUnavailable(); // @stand-in a local table, not a manager. Two pools, because the point of this // test is that the two paths are separate: a static entry cannot // satisfy an execution, and vice versa. contract EntryTable { struct Entry { bool present; bool success; bytes data; } mapping(bytes32 => Entry) private staticEntries; mapping(bytes32 => Entry) private execEntries; address private immutable destAddress; uint64 private immutable destRollupId; constructor(address destAddress_, uint64 destRollupId_) { destAddress = destAddress_; destRollupId = destRollupId_; } /// @dev Reduced stand-in for computeCrossChainCallHash. `isStatic` is folded /// in because upstream folds it in (EEZBase.sol:197) — it is what makes /// a read hash distinctly from an otherwise-identical write. function keyFor(bool isStatic, address sourceAddress, bytes memory callData) public view returns (bytes32) { return keccak256(abi.encode(isStatic, sourceAddress, destAddress, destRollupId, callData)); } /// @dev The composer's job, done by hand. In production nothing in your /// transaction writes either table. function commitStatic(address sourceAddress, bytes calldata callData, bool success, bytes calldata data) external { staticEntries[keyFor(true, sourceAddress, callData)] = Entry(true, success, data); } /// @dev Same name and signature as the manager's read entry point. function staticCrossChainCall(address sourceAddress, bytes calldata callData) external view returns (bytes memory) { Entry storage entry = staticEntries[keyFor(true, sourceAddress, callData)]; if (!entry.present) revert ExecutionNotFound(); if (!entry.success) { bytes memory reason = entry.data; assembly { revert(add(reason, 0x20), mload(reason)) } } return entry.data; } /// @dev Same name and signature as the manager's execution entry point. The /// execution pool is left empty on purpose: no composer writes an /// ExecutionEntry for a read, which is exactly why a plain `call` for a /// read fails, and fails with the OTHER error name. function executeCrossChainCall(address sourceAddress, bytes calldata callData) external payable returns (bytes memory) { bytes32 key = keyFor(false, sourceAddress, callData); Entry storage entry = execEntries[key]; if (!entry.present) revert EntryNotFound(key, 0); return entry.data; } } // @stand-in a model of CrossChainProxy's routing decision, and the only part of // the protocol this test reproduces for real: the self-probe. The // `tstore` and the gas cap are upstream's (CrossChainProxy.sol:23, // :71, :89). Unlike the page snippet this declares a NAMED transient // variable rather than writing raw slot 0 — which is what upstream // does and what the snippet's STAND-IN note tells you to do. contract ProbeProxy { address internal immutable manager; uint256 transient _staticDetector; constructor(address manager_) { manager = manager_; } function staticCheck() external { _staticDetector = 1; } fallback() external payable { (bool probeOk,) = address(this).call{gas: 1_000}(abi.encodeCall(this.staticCheck, ())); bool ok; bytes memory result; if (!probeOk) { // The probe halted, so this frame is static: route the read. (ok, result) = manager.staticcall( abi.encodeCall(EntryTable.staticCrossChainCall, (msg.sender, msg.data)) ); } else { (ok, result) = manager.call{value: msg.value}( abi.encodeCall(EntryTable.executeCrossChainCall, (msg.sender, msg.data)) ); } if (ok) { // The manager returns `bytes`, so the raw result is double-encoded. result = abi.decode(result, (bytes)); } assembly { switch ok case 0 { revert(add(result, 0x20), mload(result)) } default { return(add(result, 0x20), mload(result)) } } } receive() external payable {} } contract Q2CrossChainCallTest { Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); address internal constant REMOTE = address(0xBEEF); uint64 internal constant REMOTE_ROLLUP = 42; address internal constant USER = address(0xA11CE); EntryTable internal table; ProbeProxy internal proxy; Caller internal caller; bytes internal readData; function setUp() public { table = new EntryTable(REMOTE, REMOTE_ROLLUP); proxy = new ProbeProxy(address(table)); caller = new Caller(address(proxy)); readData = abi.encodeCall(IRemote.balanceOf, (USER)); vm.label(address(proxy), "CrossChainProxy(stand-in)"); } function _commitBalance(uint256 balance) internal { table.commitStatic(address(caller), readData, true, abi.encode(balance)); } // ── The payoff: a value from another rollup decides the branch ── function test_fundedBranchCreditsFromARemoteValue() public { _commitBalance(100); bool funded = caller.settleIfFunded(USER, 40); require(funded, "expected the funded branch"); require(caller.credited(USER) == 40, "credited should record the need"); require(caller.shortfall(USER) == 0, "shortfall should be untouched"); } function test_shortBranchRecordsTheGapFromTheSameCall() public { _commitBalance(10); bool funded = caller.settleIfFunded(USER, 40); require(!funded, "expected the short branch"); require(caller.shortfall(USER) == 30, "shortfall should be need - have"); require(caller.credited(USER) == 0, "credited should be untouched"); } /// @dev The decode is not decorative: the same call with a different /// committed value takes a different branch and nothing else changes. function test_theBranchTracksTheValueNotTheSuccessFlag() public { _commitBalance(40); require(caller.settleIfFunded(USER, 40), "have == need is funded"); } // ── The rule: the frame picks the path ── /// @dev A plain CALL with the very same calldata does NOT reach the read /// path. It reaches `executeCrossChainCall` and misses, because no /// composer writes an ExecutionEntry for a read — and it misses under /// the other error name. This is the failure a `.call(data)` read /// produces in production. function test_plainCallTakesTheExecutionPathAndMisses() public { _commitBalance(100); (bool ok, bytes memory ret) = address(proxy).call(readData); require(!ok, "a plain call must not resolve the static entry"); require(bytes4(ret) == EntryNotFound.selector, "expected EntryNotFound, not ExecutionNotFound"); } /// @dev And the staticcall reaches the read path even though the committed /// static entry is the ONLY entry that exists. Committed for THIS /// contract, because the entry is keyed by the source address — /// `sourceAddress` is the second field of the real preimage too /// (EEZBase.sol:197), so the same call from a different caller is a /// different entry. function test_staticcallTakesTheReadPath() public { table.commitStatic(address(this), readData, true, abi.encode(uint256(100))); (bool ok, bytes memory ret) = address(proxy).staticcall(readData); require(ok, "the static entry should resolve"); require(abi.decode(ret, (uint256)) == 100, "the remote value should come back"); } /// @dev The corollary, worth its own case because it is a real footgun: an /// entry committed for one caller does not resolve for another. function test_anEntryIsKeyedByTheSourceAddress() public { _commitBalance(100); (bool ok, bytes memory ret) = address(proxy).staticcall(readData); require(!ok, "another caller's entry must not resolve"); require(bytes4(ret) == ExecutionNotFound.selector, "expected ExecutionNotFound"); } // ── Both failure modes, and why they are the same failure to a caller ── function test_missRevertsCrossChainCallFailed() public { vm.expectRevert(CrossChainCallFailed.selector); caller.settleIfFunded(USER, 40); } /// @dev An entry exists and records that the destination reverted. The /// caller sees the identical outcome as the miss above — this is /// docs/CAVEATS.md "Indistinguishable revert reasons", executable. function test_destinationRevertIsIndistinguishableFromAMiss() public { table.commitStatic( address(caller), readData, false, abi.encodeWithSelector(RemoteBalanceUnavailable.selector) ); vm.expectRevert(CrossChainCallFailed.selector); caller.settleIfFunded(USER, 40); } } ``` ## Why msg.sender isn't your contract on the other side Learn why every onlyOwner, allowlist and approve on the far side keys to a proxy address instead of your contract — and why nothing reverts when it does. - Page: https://eez-demos.vercel.app/dapp-developers/q3-fix-the-msg-sender-gotcha.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:39 · fallback` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/CrossChainProxy.sol#L39 ### Step 1 — WHO ACTUALLY CALLS Cross-chain, the destination sees the proxy — not your EOA. ```solidity // CrossChainProxy.sol — the manager forwards; // destination sees msg.sender = proxy(owner). ``` ### Step 2 — THE TRAP A same-chain owner check passes locally, reverts every time cross-chain. ```diff // same-chain owner check — reverts cross-chain: -if (msg.sender != owner) revert NotOwner(); ``` ### Step 3 — THE FIX Whitelist the proxy itself instead. ```diff // whitelist the proxy, not the owner: address proxy = eez.computeCrossChainProxyAddress( owner, originRollupId ); -if (msg.sender != owner) revert NotOwner(); +if (msg.sender != proxy) revert NotProxy(); ``` ### Full compilable source — snippets/q3-msg-sender.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {IEEZ} from "./lib/IEEZ.sol"; // Panel source for: dapp-developers/q3-fix-the-msg-sender-gotcha.html // Mirrors eez-core-protocol/src/base/CrossChainProxy.sol:39 // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // Cross-chain, the destination never sees your EOA. It sees the proxy the // manager forwards through. So an owner check that passes on the same chain // reverts every time it is reached cross-chain. // // WHY WHITELISTING THE PROXY IS SAFE, and not just a way to silence the revert: // the proxy address is CREATE2-derived from exactly (owner, originRollupId) // and the manager's own address, so one owner on one rollup maps to one // address and nothing else can occupy it. Only the manager can forward // through that proxy. Authorizing it therefore authorizes that one owner on // that one rollup — not "anyone who can reach the manager". /// @dev Upstream uses custom errors throughout (EEZ.sol reverts /// `UnauthorizedProxy()`), and they have been the idiom since 0.8.4. /// A bare require here would be teaching an idiom the protocol itself /// does not use. error NotOwner(); error NotProxy(); contract OwnerGated { IEEZ internal immutable eez; address internal immutable owner; uint64 internal immutable originRollupId; constructor(IEEZ eez_, address owner_, uint64 originRollupId_) { eez = eez_; owner = owner_; originRollupId = originRollupId_; } /// @dev The trap. Correct on one chain, unreachable across chains. function sameChainOnly() external view { // same-chain owner check — reverts cross-chain: if (msg.sender != owner) revert NotOwner(); } /// @dev The fix. function crossChainSafe() external view { // whitelist the proxy, not the owner: address proxy = eez.computeCrossChainProxyAddress( owner, originRollupId ); if (msg.sender != proxy) revert NotProxy(); } } ``` ### Runnable test — snippets/test/Q3MsgSender.t.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {IEEZ} from "../lib/IEEZ.sol"; import {OwnerGated} from "../q3-msg-sender.sol"; // Runnable proof of the q3 gotcha. Paste into any Foundry project and run: // // forge test --match-contract Q3MsgSenderTest -vv // // Deliberately depends on nothing but solc — no forge-std, no submodules — so // it drops into an existing test suite without dragging in a dependency tree. // // The point of the test is the FIRST case: test_sameChainCheck_passesOnSameChain // is why this bug ships. The owner check is not obviously wrong. It passes // locally, it passes in unit tests, and it only fails once a call actually // arrives through a proxy — which is exactly the path a same-chain test never // exercises. /// @dev Minimal cheatcode surface. Same address forge injects. interface Vm { function prank(address) external; function expectRevert() external; function label(address, string calldata) external; } /// @dev Stands in for the manager. Uses the real derivation so the proxy /// address in the test is the one the protocol would actually produce. contract MockEEZ { function computeCrossChainProxyAddress(address originalAddress, uint64 originalRollupId) external view returns (address) { bytes32 salt = keccak256(abi.encodePacked(originalRollupId, originalAddress)); bytes32 bytecodeHash = keccak256(abi.encodePacked(type(Dummy).creationCode, abi.encode(address(this)))); return address( uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, bytecodeHash)))) ); } } contract Dummy { address public immutable manager; constructor(address manager_) { manager = manager_; } } contract Q3MsgSenderTest { Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); uint64 internal constant ORIGIN_RID = 100; address internal alice = address(0xA11CE); MockEEZ internal eez; OwnerGated internal gated; address internal aliceProxy; function setUp() public { eez = new MockEEZ(); gated = new OwnerGated(IEEZ(address(eez)), alice, ORIGIN_RID); aliceProxy = eez.computeCrossChainProxyAddress(alice, ORIGIN_RID); vm.label(alice, "alice(EOA)"); vm.label(aliceProxy, "proxy(alice)"); } /// @dev Why the bug ships: called directly by the owner, the check is fine. function test_sameChainCheck_passesOnSameChain() public { vm.prank(alice); gated.sameChainOnly(); } /// @dev The bug: the same call arriving through the proxy reverts, because /// msg.sender is proxy(alice) and never alice. function test_sameChainCheck_revertsWhenCalledViaProxy() public { vm.prank(aliceProxy); vm.expectRevert(); gated.sameChainOnly(); } /// @dev The fix: whitelist the derived proxy instead of the EOA. function test_proxyCheck_passesWhenCalledViaProxy() public { vm.prank(aliceProxy); gated.crossChainSafe(); } /// @dev And the fix is not a blanket opening: an unrelated caller still fails. function test_proxyCheck_revertsForStranger() public { vm.prank(address(0xBEEF)); vm.expectRevert(); gated.crossChainSafe(); } } ``` ## How to get your contract's address on another rollup Learn how to read it, or deploy it. - Page: https://eez-demos.vercel.app/dapp-developers/q7-your-cross-chain-address.html - Source: `eez-core-protocol/src/base/EEZBase.sol:156 · createCrossChainProxy` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/EEZBase.sol#L156 ### Step 1 — CALL IT INTO EXISTENCE One call deploys the proxy and registers it — you get the address back. ```solidity function createCrossChainProxy( address originalAddress, uint64 originalRollupId ) external returns (address proxy); ``` ### Step 2 — OR READ IT FIRST Or read the address before anything is deployed. It is a view call. ```solidity function createCrossChainProxy( address originalAddress, uint64 originalRollupId ) external returns (address proxy); function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) external view returns (address); ``` ### Step 3 — THE ONE RULE Remote addresses only. A proxy for your own network reverts. ```solidity function createCrossChainProxy( address originalAddress, uint64 originalRollupId ) external returns (address proxy); function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) external view returns (address); // A proxy stands in for a REMOTE address. if (originalRollupId == _getRollupId()) revert SameNetworkProxy(originalRollupId); ``` ### Full compilable source — snippets/q7-create-proxy.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; // Panel source for: dapp-developers/q7-your-cross-chain-address.html // Mirrors eez-core-protocol/src/base/EEZBase.sol:156, :166 and :176 // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // The two calls a dapp developer *can* make. Neither is usually required: // the protocol creates the proxy itself on the first inbound call, at // eez-core-protocol/src/EEZ.sol:1139-1142. Neither call requires knowing // how the address is derived — that is protocol-researcher territory. // // Signatures and bodies are hand-wrapped to the code panel's column, the // same way snippets/q1-compute-address.sol is, so that every line the panel // shows exists verbatim here. scripts/check-panel-drift.py asserts that. /// @dev Stand-in for the real proxy. Only `type(...).creationCode` matters here. contract CrossChainProxy { address public immutable manager; constructor(address manager_) { manager = manager_; } } /// @dev The two entry points, as a dapp codes against them. interface IEEZProxyFactory { function createCrossChainProxy( address originalAddress, uint64 originalRollupId ) external returns (address proxy); function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) external view returns (address); } contract CreateProxySnippet { /// @notice Error when a proxy is requested for an address on THIS /// manager's own network. error SameNetworkProxy(uint64 rollupId); mapping(address => bool) public authorizedProxies; event CrossChainProxyCreated( address indexed proxy, address indexed originalAddress, uint64 originalRollupId ); function _getRollupId() internal pure returns (uint64) { return 0; } function createCrossChainProxy( address originalAddress, uint64 originalRollupId ) external returns (address proxy) { // A proxy stands in for a REMOTE address. if (originalRollupId == _getRollupId()) revert SameNetworkProxy(originalRollupId); bytes32 salt = keccak256( abi.encodePacked(originalRollupId, originalAddress) ); proxy = address(new CrossChainProxy{salt: salt}(address(this))); authorizedProxies[proxy] = true; emit CrossChainProxyCreated( proxy, originalAddress, originalRollupId ); } function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) public view returns (address) { bytes32 salt = keccak256( abi.encodePacked(originalRollupId, originalAddress) ); bytes32 bytecodeHash = keccak256(abi.encodePacked( type(CrossChainProxy).creationCode, abi.encode(address(this)) )); return address(uint160(uint256(keccak256( abi.encodePacked( bytes1(0xff), address(this), salt, bytecodeHash ) )))); } } ``` ## How to check an address is a real proxy Learn how to read the on-chain registry instead of guessing. - Page: https://eez-demos.vercel.app/dapp-developers/q4-check-if-an-address-is-a-proxy.html - Source: `eez-core-protocol/src/base/EEZBase.sol:60 · authorizedProxies` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/EEZBase.sol#L60 ### Step 1 — ONE PUBLIC MAPPING Every proxy is recorded when it's created. ```solidity mapping(address proxy => ProxyInfo info) public authorizedProxies; ``` ### Step 2 — READ, DON'T GUESS Read the mapping directly — no simulation, no guessing. ```solidity (bool isProxy, address origAddr, uint64 origRid) = eez.authorizedProxies(someAddress); ``` ### Step 3 — isProxy IS THE FLAG isProxy true means the other two fields are real. ```solidity struct ProxyInfo { bool isProxy; address originalAddress; uint64 originalRollupId; } if (isProxy) { /* real proxy for origAddr@origRid */ } ``` ### Full compilable source — snippets/q4-proxy-registry.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {IEEZManager} from "./lib/IEEZManager.sol"; // Panel source for: dapp-developers/q4-check-if-an-address-is-a-proxy.html // Mirrors eez-core-protocol/src/base/EEZBase.sol:60 // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // The registry is a public mapping, so anyone can read it directly — no // events to replay, no indexer required. Because it maps to a struct, the // auto-generated getter returns the struct's members flattened. struct ProxyInfo { bool isProxy; address originalAddress; uint64 originalRollupId; } /// @dev Manager side — the storage the walkthrough points at. contract RegistryStorage { mapping(address proxy => ProxyInfo info) public authorizedProxies; } /// @dev Reader side — anyone can do this. contract RegistryReader { IEEZManager internal immutable eez; constructor(IEEZManager eez_) { eez = eez_; } function check(address someAddress) external view returns (bool) { (bool isProxy, address origAddr, uint64 origRid) = eez.authorizedProxies(someAddress); if (isProxy) { /* real proxy for origAddr@origRid */ } origAddr; origRid; return isProxy; } } ``` ## How the guard makes sure only proxy calls reach execution Learn how UnauthorizedProxy() stops any call that isn't proxy-routed. - Page: https://eez-demos.vercel.app/dapp-developers/q6-why-you-cant-call-the-manager-directly.html - Source: `eez-core-protocol/src/EEZ.sol:780 · executeCrossChainCall` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/EEZ.sol#L780 ### Step 1 — ONLY A REAL PROXY Anyone can try calling the manager's entry point directly. ```solidity function executeCrossChainCall( address sourceAddress, bytes calldata callData ) external payable returns (bytes memory) { ``` ### Step 2 — THE GUARD It reverts — UnauthorizedProxy(). Only a real proxy gets through. ```solidity ProxyInfo storage proxyInfo = authorizedProxies[msg.sender]; if (!proxyInfo.isProxy) revert UnauthorizedProxy(); ``` ### Step 3 — NO WAY PAST IT Both proxy entry points open with this check, so no proxy-routed call reaches execution without it. ```solidity ProxyInfo storage proxyInfo = authorizedProxies[msg.sender]; if (!proxyInfo.isProxy) revert UnauthorizedProxy(); // msg.sender must be a real proxy — // never the caller's own address ``` ### Full compilable source — snippets/q6-manager-direct.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; // Panel source for: dapp-developers/q6-why-you-cant-call-the-manager-directly.html // Mirrors eez-core-protocol/src/EEZ.sol:780 // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // The registry check is the first thing the entry point does. There is no // path into execution that skips it, which is why calling the manager // yourself cannot work no matter how the calldata is shaped. struct ProxyInfo { bool isProxy; address originalAddress; uint64 originalRollupId; } error UnauthorizedProxy(); contract ManagerEntrySnippet { mapping(address proxy => ProxyInfo info) public authorizedProxies; function executeCrossChainCall( address sourceAddress, bytes calldata callData ) external payable returns (bytes memory) { ProxyInfo storage proxyInfo = authorizedProxies[msg.sender]; if (!proxyInfo.isProxy) revert UnauthorizedProxy(); // msg.sender must be a real proxy — // never the caller's own address sourceAddress; callData; return ""; } } ``` ### Runnable test — snippets/test/Q6ManagerDirect.t.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {ManagerEntrySnippet, UnauthorizedProxy} from "../q6-manager-direct.sol"; // Runnable proof that the manager's proxy entry point cannot be called // directly. Paste into any Foundry project and run: // // forge test --match-contract Q6ManagerDirectTest -vv // // Depends on nothing but solc — no forge-std, no submodules. // // These are all negative cases on purpose. The claim the walkthrough makes is // that there is no way to shape a direct call that gets through, so the useful // test is the one that tries several shapes and gets the same revert each time. // // The positive case — a registered proxy succeeding — is deliberately absent. // authorizedProxies is written only by the manager's own CREATE2 deploy path, // so faking an entry here would mean either adding a setter that the real // contract does not have, or poking storage with vm.store. Both would be // testing the fake rather than the protocol. interface Vm { function prank(address) external; function expectRevert(bytes4) external; } contract Q6ManagerDirectTest { Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); ManagerEntrySnippet internal manager; function setUp() public { manager = new ManagerEntrySnippet(); } /// @dev An ordinary EOA calling the entry point directly. function test_directCallFromEoaReverts() public { vm.prank(address(0xBEEF)); vm.expectRevert(UnauthorizedProxy.selector); manager.executeCrossChainCall(address(0xBEEF), hex"a9059cbb"); } /// @dev A contract calling it is no different: the check is on msg.sender /// being a registered proxy, not on being an EOA. function test_directCallFromContractReverts() public { vm.expectRevert(UnauthorizedProxy.selector); manager.executeCrossChainCall(address(this), hex"a9059cbb"); } /// @dev Passing someone else's address as sourceAddress does not help. /// sourceAddress is data; msg.sender is what is checked. function test_spoofingSourceAddressDoesNotHelp() public { vm.prank(address(0xBEEF)); vm.expectRevert(UnauthorizedProxy.selector); manager.executeCrossChainCall(address(0xCAFE), hex"a9059cbb"); } /// @dev Empty calldata reverts the same way. The registry check runs before /// anything looks at the payload, so payload shape is irrelevant. function test_emptyCallDataRevertsIdentically() public { vm.prank(address(0xBEEF)); vm.expectRevert(UnauthorizedProxy.selector); manager.executeCrossChainCall(address(0xBEEF), ""); } } ``` ## How to encode a call's content hash Learn how to hash eight fields in the one order that works. - Page: https://eez-demos.vercel.app/dapp-developers/q5-encode-a-calls-content-hash.html - Source: `eez-core-protocol/src/base/EEZBase.sol:198` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/EEZBase.sol#L198 ### Step 1 — 8 FIELDS, FIXED ORDER Eight fields describe a cross-chain call, in this exact order. ```solidity function computeCrossChainCallHash( bool isStatic, address sourceAddress, uint64 sourceRollupId, address targetAddress, uint64 targetRollupId, uint256 value, uint64 callGas, bytes memory data ) public pure returns (bytes32) { ``` ### Step 2 — ONE HASH OUT Hash them together — one bytes32 identifies the call. ```solidity return keccak256(abi.encode( isStatic, sourceAddress, sourceRollupId, targetAddress, targetRollupId, value, callGas, data )); ``` ### Step 3 — ORDER IS LOAD-BEARING Reorder them and you get a different hash. Don't. ```solidity return keccak256(abi.encode( isStatic, sourceAddress, sourceRollupId, targetAddress, targetRollupId, value, callGas, data )); // ^ same order the protocol hashes on-chain ``` ### Full compilable source — snippets/q5-content-hash.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; // Panel source for: dapp-developers/q5-encode-a-calls-content-hash.html // Mirrors eez-core-protocol/src/base/EEZBase.sol:198 // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // Field order is load-bearing. abi.encode (not encodePacked) 32-byte pads // every static field and length-prefixes `data`, so reordering two fields // produces a different hash that still looks perfectly valid. contract ContentHashSnippet { function computeCrossChainCallHash( bool isStatic, address sourceAddress, uint64 sourceRollupId, address targetAddress, uint64 targetRollupId, uint256 value, uint64 callGas, bytes memory data ) public pure returns (bytes32) { return keccak256(abi.encode( isStatic, sourceAddress, sourceRollupId, targetAddress, targetRollupId, value, callGas, data )); // ^ same order the protocol hashes on-chain } } ``` ### Runnable test — snippets/test/Q5ContentHash.t.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {ContentHashSnippet} from "../q5-content-hash.sol"; // Runnable proof that the field order in computeCrossChainCallHash is // load-bearing. Paste into any Foundry project and run: // // forge test --match-contract Q5ContentHashTest -vv // // Depends on nothing but solc — no forge-std, no submodules. // // The expected digests below were derived OUTSIDE the EVM, with pycryptodome's // keccak-256 and a hand-built ABI encoder, then pasted here. So these tests do // not merely check that solc agrees with itself: they pin solc's output against // an independent implementation. If a future refactor reorders the encode call, // this fails even though the contract still compiles and still "hashes". contract Q5ContentHashTest { ContentHashSnippet internal h; address internal constant SRC = 0x1111111111111111111111111111111111111111; address internal constant TGT = 0x2222222222222222222222222222222222222222; uint64 internal constant SRC_RID = 1; uint64 internal constant TGT_RID = 100; function setUp() public { h = new ContentHashSnippet(); } function _hash(bool isStatic, address src, uint64 srid, address tgt, uint64 trid) internal view returns (bytes32) { return h.computeCrossChainCallHash(isStatic, src, srid, tgt, trid, 0, 0, hex"a9059cbb"); } /// @dev Baseline, pinned to the externally-derived digest. function test_matchesIndependentlyDerivedDigest() public view { bytes32 got = _hash(false, SRC, SRC_RID, TGT, TGT_RID); require( got == 0x6f907866fb077719ec239f745e2de52832f0d8507473768dbaa2d3b45cf8fbe1, "digest drifted from the externally-derived value" ); } /// @dev The whole lesson of the walkthrough: nothing added, nothing removed, /// only source and target exchanged, and the hash is completely different. function test_swappingSourceAndTargetChangesTheHash() public view { bytes32 base = _hash(false, SRC, SRC_RID, TGT, TGT_RID); bytes32 swapped = _hash(false, TGT, TGT_RID, SRC, SRC_RID); require(base != swapped, "reordering the pairs must change the hash"); require( swapped == 0x9876a218b9c58a69bb5b01481b628f13d5c2eb8d72b0f80d942e9eb0962dc19f, "swapped digest drifted from the externally-derived value" ); } /// @dev isStatic is part of the preimage, so a read-only call hashes /// distinctly from an otherwise-identical state-changing one. function test_isStaticIsPartOfTheHash() public view { bytes32 stateChanging = _hash(false, SRC, SRC_RID, TGT, TGT_RID); bytes32 readOnly = _hash(true, SRC, SRC_RID, TGT, TGT_RID); require(stateChanging != readOnly, "isStatic must affect the hash"); require( readOnly == 0xb3d426ceaa48ca006739dd0799a05e675999732e2a63a4021089512c489daa1d, "isStatic digest drifted from the externally-derived value" ); } /// @dev abi.encode is used rather than encodePacked, so `data` is length- /// prefixed. Two different payloads can never collide by concatenation. function test_dataLengthIsEncoded() public view { bytes32 a = h.computeCrossChainCallHash(false, SRC, SRC_RID, TGT, TGT_RID, 0, 0, hex"1122"); bytes32 b = h.computeCrossChainCallHash(false, SRC, SRC_RID, TGT, TGT_RID, 0, 0, hex"112200"); require(a != b, "trailing zero byte must change the hash"); } /// @dev Same inputs, same digest. Nothing in here reads state or time. function test_isDeterministic() public view { require( _hash(false, SRC, SRC_RID, TGT, TGT_RID) == _hash(false, SRC, SRC_RID, TGT, TGT_RID), "must be deterministic" ); } } ``` --- # Rollup operators Standing the stack up and driving it. ## How to run the whole stack locally Learn how to bring up the node, proof-signer and the L1 in a disposable network. - Page: https://eez-demos.vercel.app/rollup-operators/ro1-run-the-devnet-with-kurtosis.html - Source: `testing/kurtosis/README.md` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/testing/kurtosis/README.md ### Step 1 — PREREQS + SUBMODULE A Linux host with Docker, all local tooling, plus the real contract source pulled in via submodule. ```bash # disposable local devnet # requires a Linux host with Docker running $ git submodule update --init --recursive \ eez-core-protocol ``` ### Step 2 — START THE ENGINE KURTOSIS_ENCLAVE names the disposable network and KURTOSIS_ARGS_FILE points at the real ci-args.yaml — which carries deterministic private-network keys. ```bash # disposable local devnet # requires a Linux host with Docker running $ git submodule update --init --recursive \ eez-core-protocol $ kurtosis engine start $ export KURTOSIS_ENCLAVE=eez-dev $ export KURTOSIS_ARGS_FILE=\ "$PWD/testing/kurtosis/ci-args.yaml" # ci-args.yaml carries deterministic private- # network keys. Never use them on a public # network or fund them with real assets. ``` ### Step 3 — ONE SCRIPT → FULL STACK One script deploys contracts, generates the L2 genesis, and boots the L1, MEV-builder, node and proof-signer together — several minutes on the first run. ```bash $ kurtosis engine start $ export KURTOSIS_ENCLAVE=eez-dev $ export KURTOSIS_ARGS_FILE=\ "$PWD/testing/kurtosis/ci-args.yaml" $ bash testing/kurtosis/start.sh \ "$KURTOSIS_ARGS_FILE" # the first start builds three images and # pulls the client images: several minutes ``` ## How to deploy the protocol Learn what lands on L1 and L2, and in what order, once the network is up. - Page: https://eez-demos.vercel.app/rollup-operators/ro2-deploy-the-protocol.html - Source: `Makefile:47-65 · scripts/deploy.sh` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/Makefile#L47-L65 ### Step 1 — ONE COMMAND With `.env` in place, `make deploy-protocol` runs `./scripts/deploy.sh` through its five steps: DeployEEZ, DeployECDSAProofSystem, DeployRollup, RegisterRollup, DeployBridgeL1. ```bash # Reads .env (gitignored). Copy .env.example first. $ cp .env.example .env # EEZ_L1_RPC_URL EEZ_L1_POSTER_KEY # EEZ_PROOF_SIGNER_KEY EEZ_L2_SYSTEM_KEY # Runs the 5-step deploy sequence: # 1. DeployEEZ 2. DeployECDSAProofSystem # 3. DeployRollup 4. RegisterRollup # 5. DeployBridgeL1 ``` ### Step 2 — OUTPUTS LAND IN DEPLOYMENTS.ENV Every address deploy.sh produces lands in a gitignored `deployments.env` file, not your notes — 19 keys in all. ```bash # Reads .env (gitignored). Copy .env.example first. $ cp .env.example .env # EEZ_L1_RPC_URL EEZ_L1_POSTER_KEY # EEZ_PROOF_SIGNER_KEY EEZ_L2_SYSTEM_KEY # Runs the 5-step deploy sequence: # 1. DeployEEZ 2. DeployECDSAProofSystem # 3. DeployRollup 4. RegisterRollup # 5. DeployBridgeL1 # Outputs land in deployments.env (gitignored). # eez-node loads it alongside .env at startup, so # a successful deploy means `make run-node` Just # Works with no paste-the-address-into-.env step. deploy-protocol: @./scripts/deploy.sh ``` ### Step 3 — RUN-NODE JUST WORKS `make run-node` loads `deployments.env` alongside `.env` at startup, so there's no paste-the-address-into-.env step. ```bash # Reads .env (gitignored). Copy .env.example first. $ cp .env.example .env # EEZ_L1_RPC_URL EEZ_L1_POSTER_KEY # EEZ_PROOF_SIGNER_KEY EEZ_L2_SYSTEM_KEY # Runs the 5-step deploy sequence: # 1. DeployEEZ 2. DeployECDSAProofSystem # 3. DeployRollup 4. RegisterRollup # 5. DeployBridgeL1 # Outputs land in deployments.env (gitignored). # eez-node loads it alongside .env at startup, so # a successful deploy means `make run-node` Just # Works with no paste-the-address-into-.env step. deploy-protocol: @./scripts/deploy.sh ``` ## How to run against a real testnet Learn what changes when the L1 underneath you is Chiado rather than a disposable devnet. - Page: https://eez-demos.vercel.app/rollup-operators/ro3-run-a-real-chiado-l2.html - Source: `README.md · Run a chiado L2 (Docker)` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/README.md ### Step 1 — ONE-TIME SETUP One-time setup: the contracts submodule, local build tooling, plus everything the embedded L1 needs before it can sync. ```bash # one-time setup $ git submodule update --init --recursive $ docker build -t eez-node:local . $ docker build -f Dockerfile.signer \ -t eez-proof-signer:local . $ docker run --rm -v "$PWD/data/chiado-l1:/data" \ ghcr.io/gnosischain/reth_gnosis:v2.0.0 \ download --chain chiado --minimal \ --datadir /data $ openssl rand -hex 32 > data/jwt.hex $ git clone \ https://github.com/gnosischain/configs.git \ /tmp/gnosis-configs $ mkdir -p configs && \ cp -r /tmp/gnosis-configs/chiado configs/chiado ``` ### Step 2 — DEPLOY, THEN START deploy-protocol writes deployments.env and genesis.json before compose starts the stack — and the proof signer must be the same key the deploy used. ```bash $ cp .env.example .env # set EEZ_L1_RPC_URL, EEZ_L1_POSTER_KEY, # EEZ_PROOF_SIGNER_KEY, EEZ_L2_SYSTEM_KEY $ EEZ_DEPLOY_SKIP_SIMULATION=1 \ make deploy-protocol # -> writes deployments.env # -> writes datadir/genesis.json # (deploy runs before up -- # no separate genesis step) $ cp .env.chiado.example .env.chiado # host paths + funded keys + bundler URL # EEZ_PROOF_SIGNER_KEY = the deploy-time key $ docker compose --env-file .env.chiado \ -f docker-compose.chiado-node.yml up ``` ### Step 3 — HEALTH CHECK cast block-number against both ports confirms the embedded L1 is climbing and the L2 is producing. ```bash $ cp .env.chiado.example .env.chiado $ docker compose --env-file .env.chiado \ -f docker-compose.chiado-node.yml up # ~5 min: lighthouse checkpoint-syncs L1, # catches up past the deploy block $ cast block-number \ --rpc-url http://localhost:18645 $ cast block-number \ --rpc-url http://localhost:18688 ``` ## How to send and test a cross-chain call Learn how to drive traffic through the stack you just brought up, and how to tell that it worked. - Page: https://eez-demos.vercel.app/rollup-operators/ro4-send-and-test-cross-chain-calls.html - Source: `README.md · scripts/xchain-test.sh` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/README.md ### Step 1 — TWO FRONTS, ONE JOB Two transparent proxy fronts sit in front of L1 and L2: sendRawTransaction is held for the next Sync block, everything else passes straight through. ```bash # README.md · Endpoints (chiado compose stack) # L2 RPC :18688 # Embedded chiado L1 RPC :18645 # L1→L2 front (Inbound) :18999 # L2→L1 front (Outbound) :18998 # (Kurtosis devnet: # source testing/kurtosis/ports.sh) # both fronts: eth_sendRawTransaction is held # + composed into the next Sync block; every # other eth_* call is forwarded to the front's # source-chain RPC. ``` ### Step 2 — MIND THE BUNDLE SIZE The cap defaults to 3 and rbuilder-chiado silently drops anything past it — that drop is also the limit of atomicity: one bundle, one L1 block, taken whole. ```bash # both fronts: eth_sendRawTransaction is held # + composed into the next Sync block; every # other eth_* call is forwarded to the front's # source-chain RPC. # compose mode caps bundle size: EEZ_MAX_USER_TXS_PER_BUNDLE=3 # default # ⚠ rbuilder-chiado silently drops txs beyond # ~3 per bundle — raise only against a # builder proven to include larger bundles # atomically. Measure before bumping. ``` ### Step 3 — EXERCISE IT xchain-test.sh drives both fronts through the full op × direction × direct/wrapper matrix and prints a RESULTS block of pipeline metrics — the box shows that block's format, not a captured run. ```bash # compose mode caps bundle size: EEZ_MAX_USER_TXS_PER_BUNDLE=3 # default # ⚠ rbuilder-chiado silently drops txs beyond # ~3 per bundle — raise only against a # builder proven to include larger bundles # atomically. Measure before bumping. # bring the node up first: scripts/chiado-up.sh $ EEZ_WAVE_COUNT=5 bash scripts/xchain-test.sh # prints a RESULTS block: hit-rate, consecutive # / gapped postBatch blocks, drops, evictions, # divergence (a count) and reconcile PASS/FAIL ``` --- # Protocol researchers How Rollup0 actually settles and proves. ## How the four components divide the work Learn how sequencer, composer, proof-signer and deriver each fit. - Page: https://eez-demos.vercel.app/protocol-researchers/pr1-the-four-components-of-rollup0.html - Source: `crates/eez-driver/src/sequencer.rs:159 · crates/eez-composer/src/composer.rs:515 · crates/eez-proof-signer/src/attest.rs:52 / :134 · crates/eez-deriver/src/deriver.rs:45` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/crates/eez-driver/src/sequencer.rs#L159 ### Step 1 — THE LIVE PATH Ordinary blocks are the sequencer alone. One Sync block per L1 block adds the composer — and that block cannot be emitted until the proof-signer returns an attestation. ```rust pub struct Sequencer where T: PayloadTypes< PayloadAttributes = EthPayloadAttributes> { ... } pub struct Composer { ... } ``` ### Step 2 — ATTESTATION Once Composer finishes, the Attester signs a hash of the result so the block can be trusted downstream. ```rust pub struct Sequencer where T: PayloadTypes< PayloadAttributes = EthPayloadAttributes> { ... } pub struct Composer { ... } pub(crate) struct Attester { ... } pub(crate) fn sign(&self, public_inputs_hash: AttestablePublicInputsHash, ) -> Result { self.signer.sign_prehash( public_inputs_hash.into_inner()) } ``` ### Step 3 — THE INDEPENDENT BACKSTOP Deriver never trusts any of the three — it independently re-derives the same state from L1 alone. ```rust pub struct Sequencer where T: PayloadTypes< PayloadAttributes = EthPayloadAttributes> { ... } pub struct Composer { ... } pub(crate) struct Attester { ... } pub(crate) fn sign(&self, public_inputs_hash: AttestablePublicInputsHash, ) -> Result { self.signer.sign_prehash( public_inputs_hash.into_inner()) } pub struct Deriver where L2: BlockReader { ... } ``` ## How L1 stays the source of truth Learn why the L2 block is added immediately and rolled back if L1 never confirms it. - Page: https://eez-demos.vercel.app/protocol-researchers/pr2-commit-first-repair-if-needed.html - Source: `crates/eez-composer/src/optimistic.rs` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/crates/eez-composer/src/optimistic.rs ### Step 1 — THE LEDGER The committer commits the Sync block; begin() records it in the ledger at its sync_height as Pending. Two adjacent acts. ```rust // optimistic.rs:67 — private; variant docs omitted enum Resolution { Pending, Settled, Failed, } // optimistic.rs:101 pub struct OptimisticallyIncluded { by_sync_height: Mutex>, } // optimistic.rs:117 pub fn begin( // params omitted — signature verified, body not ) { ``` ### Step 2 — TWO ORACLES, THREE STATES Two oracles resolve an entry: the observer's log scan, and the Deriver's L1-derived cursor. The cursor is the stronger one — it overrides a Failed verdict. ```rust // optimistic.rs:177 — observer verdict pub fn mark_settled(&self, sync_height: u64) { // observer verdict: the bundle settled on L1 } // optimistic.rs:191 — observer verdict pub fn mark_failed(&self, sync_height: u64, slot_skipped: bool) { // observer verdict: the bundle didn't land } // optimistic.rs:160 — the Deriver's cursor wins pub fn resolve_below_cursor(&self, cursor: u64) -> Vec { // flips Pending AND Failed ≤ cursor to Settled } ``` ### Step 3 — SETTLED IS NOT PERMANENT Settled is not permanent. An L1 reorg takes rolled-out Settled entries back out; entries leave clean only at finality, and only after their postBatch receipt is re-checked on L1. ```rust // optimistic.rs:247 pub fn take_rolled_out(&self, new_l2_cursor: u64) -> Vec { // L1 reorg: removes Settled entries ABOVE // the retreated cursor and returns their // txs. Pending entries stay. } // optimistic.rs:270 — wrapped; one line in source pub fn take_finalized(&self, finalized_l2: u64) -> Vec<(u64, TxHash, Vec)> { // the only clean exit — and the caller // still checks each postBatch receipt // exists on L1 before discarding } ``` ## How the composer attests a batch Learn what is actually checked before a batch is accepted, and by whom. - Page: https://eez-demos.vercel.app/protocol-researchers/pr4-how-the-composer-proves-a-batch.html - Source: `crates/eez-control-rpc/proto/prove.proto · prove.v1.Prover` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/crates/eez-control-rpc/proto/prove.proto ### Step 1 — ONE STREAM, ONE JOB The composer opens one gRPC stream to the proof-signer and sends a ProveHeader first — blocks always follow in order. ```protobuf service Prover { // Header MUST be first; blocks follow in order. rpc Prove(stream ProveChunk) returns (ProveResponse); } message ProveHeader { uint64 rollup_id = 1; uint64 from_block = 2; // posted + 1 uint64 to_block = 3; // sync_height PostBatch post_batch = 4; } message PostBatch { bytes abi_calldata = 1; bytes public_inputs_hash = 2; bytes l1_block_hash = 3; } ``` ### Step 2 — THE PROVER RE-EXECUTES For each BlockWitness, the prover — the proof-signer, from step 1 — decodes PostBatch's calldata and replays the block to independently recompute the hash. ```protobuf message BlockWitness { uint64 number = 1; bytes hash = 2; // composer-sealed bytes parent_hash = 3; bytes rlp = 4; // consensus RLP ExecutionWitness witness = 5; } message ExecutionWitness { repeated bytes state = 1; // trie nodes repeated bytes codes = 2; // bytecodes repeated bytes keys = 3; // preimages repeated bytes headers = 4; // BLOCKHASH } ``` ### Step 3 — SIGN & RETURN The prover signs the recomputed hash and returns it — the composer only trusts it once the signature recovers to the registered attester. ```protobuf message ProveResponse { bytes public_inputs_hash = 1; // 32B bytes signature = 2; // 65B } // eez-control-rpc/src/lib.rs:19 // — Rust, not the proto pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024 * 1024; // eez-proof-signer/src/service.rs:26, :29 // — the server const MAX_DECODING_MESSAGE_BYTES: usize = 256 * 1024 * 1024; const MAX_ENCODING_MESSAGE_BYTES: usize = 1024; ``` ## How the deriver rebuilds L2 from L1 Learn how the whole chain is reconstructed from L1 data alone. - Page: https://eez-demos.vercel.app/protocol-researchers/pr3-the-deriver.html - Source: `crates/eez-deriver/src/deriver.rs · Deriver` - Pinned at commit: `a4b9b2f` - Verify: https://github.com/eez-association/eez-rollup0/blob/a4b9b2f1da1208c0f4f9c5b2ff4e45d6281ad1d2/crates/eez-deriver/src/deriver.rs ### Step 1 — WATCH L1 The deriver watches L1 for BatchPosted events instead of trusting any node's live output. The log is the trigger; the payload is the posting transaction's calldata. ```rust // crates/eez-protocol/src/abi.rs:105 event BatchPosted(uint256 indexed rollupCount); // ^ the count of rollups settled in THIS batch, // not a batch ordinal (EEZ.sol:442 emits // batch.rollupIdsWithProofSystems.length) // deriver.rs:45 pub struct Deriver ``` ### Step 2 — DECODE THE CALLDATA handle_event routes the event to on_batch_posted, which decodes that calldata and replays the blocks through execute_block. ```rust // deriver.rs:786 async fn handle_event(&self, event: L1Event) -> DeriverResult<()> { // deriver.rs:836 async fn on_batch_posted( // params omitted — signature verified, body not ) -> DeriverResult<()> { // deriver.rs:849 — the log is the trigger, // this is the data let decoded = eez_payload_codec::decode(call_data.as_ref())?; // deriver.rs:516 pub fn execute_block( ``` ### Step 3 — REVALIDATE, THEN SCAN FORWARD catch_up() first re-checks the batches it already indexed and drops any whose L1 block is no longer canonical, then scans forward from that anchor — or from the deployment block on a cold start. ```rust // deriver.rs:169 pub async fn catch_up(&self) -> DeriverResult<()> { // deriver.rs:257 — phase 1: walk the index tail // BACKWARD, drop batches whose recorded L1 hash // is stale; return the highest canonical anchor async fn revalidate_index_tail(&self) -> DeriverResult> { // deriver.rs:246-249 — phase 2, arms wrapped match anchor { Some(anchor_l1_block) => self.sync_batches_inner( anchor_l1_block, cursor).await, // cold start: deploy_block, NOT L1 genesis None => self.sync_batches_inner( self.inner.deploy_block, 0).await, } ``` ## How the address is derived Learn how two values become a salt and a CREATE2 address. - Page: https://eez-demos.vercel.app/protocol-researchers/pr5-how-the-address-is-derived.html - Source: `eez-core-protocol/src/base/EEZBase.sol:176` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/EEZBase.sol#L176 ### Step 1 — TWO INPUTS Two values, packed as bytes — that's the whole identity. ```solidity function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) public view returns (address) { ``` ### Step 2 — PACKED & HASHED → SALT keccak256 turns them into a 32-byte salt. ```solidity function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) public view returns (address) { bytes32 salt = keccak256( abi.encodePacked( originalRollupId, originalAddress ) ); ``` ### Step 3 — CREATE2 → ONE ADDRESS CREATE2 turns (manager, salt, bytecodeHash) into an address — before deployment. ```solidity bytes32 bytecodeHash = keccak256( abi.encodePacked( type(CrossChainProxy).creationCode, abi.encode(address(this)) ) ); return address(uint160(uint256(keccak256( abi.encodePacked( bytes1(0xff), address(this), salt, bytecodeHash ) )))); } ``` ### Full compilable source — snippets/q1-compute-address.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; // Panel source for: protocol-researchers/pr5-how-the-address-is-derived.html // Mirrors eez-core-protocol/src/base/EEZBase.sol:176 // @ 9735f53abbb6b9f5e863f405ad4555b4701b7fda // // The address is a pure function of two values. No nonce, no deployment // order, no registry lookup — so you can compute it before anything is // deployed, on any chain running the manager. /// @dev Stand-in for the real proxy. Only `type(...).creationCode` matters here. contract CrossChainProxy { address public immutable manager; constructor(address manager_) { manager = manager_; } } contract ComputeAddressSnippet { function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) public view returns (address) { bytes32 salt = keccak256( abi.encodePacked( originalRollupId, originalAddress ) ); bytes32 bytecodeHash = keccak256( abi.encodePacked( type(CrossChainProxy).creationCode, abi.encode(address(this)) ) ); return address(uint160(uint256(keccak256( abi.encodePacked( bytes1(0xff), address(this), salt, bytecodeHash ) )))); } } ``` ## The rolling hash Learn how one bytes32 commits to a whole execution tree — and why no call index is folded in. - Page: https://eez-demos.vercel.app/protocol-researchers/pr6-the-rolling-hash.html - Source: `eez-core-protocol/src/base/EEZBase.sol:290 · _rollingHashEntryBegin` - Pinned at commit: `9735f53` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9735f53abbb6b9f5e863f405ad4555b4701b7fda/src/base/EEZBase.sol#L290 ### Step 1 — ONE VALUE, WHOLE TREE An entry states what should happen; the rolling hash commits to what did. Six distinct classes of divergence — wrong return data, wrong success flag, missing or extra calls, a skipped or extra reentrant frame, reordering, an unexpected reentrant call — all collapse into one equality check. ```solidity require(_rollingHash == entry.rollingHash); // reverts RollingHashMismatch // EEZBase.sol:107 declares the error; // the check runs after every call and // every nesting in the entry completes. ``` ### Step 2 — SEEDED WITH STATE It is seeded, not zeroed: the entry's starting state context closed with its own identity, so the hash binds what the world looked like as well as what happened. It must be clear beforehand, or _rollingHashEntryBegin reverts RollingHashNotCleared. ```solidity // EEZBase.sol:290 _rollingHashEntryBegin if (_rollingHash != bytes32(0)) revert RollingHashNotCleared(); bytes32 statesHash; for (i in deltas) statesHash = keccak256(abi.encodePacked( statesHash, deltas[i].rollupId, deltas[i].currentState)); _rollingHash = keccak256(abi.encodePacked( statesHash, proxyEntryHash)); ``` ### Step 3 — FIVE TAGGED EVENTS Five events, each folded with its own domain byte, so one set of inputs cannot mean two things. CALL_BEGIN folds the call's full identity hash — a substituted call with an identical return value still diverges. ```solidity // EEZBase.sol:39-43 — the domain bytes CALL_BEGIN = 1; CALL_END = 2; NESTED_BEGIN = 3; NESTED_END = 4; CALL_NOT_FOUND = 5; // :303 keccak256(abi.encodePacked( _rollingHash, CALL_BEGIN, crossChainCallHash)); // :309 keccak256(abi.encodePacked( _rollingHash, CALL_END, success, retData)); ``` ### Step 4 — NO INDEX IS FOLDED IN No call index is folded in. Because each fold consumes the prior hash, order, count and nesting are already bound by the chain, and an explicit counter would add nothing. Dropping it is what lets a revert span be reprocessed as a 0-based sub-slice and still hash like a continuous run. ```solidity // each fold consumes the previous value _rollingHash = keccak256(abi.encodePacked( _rollingHash, // <- the chain TAG, ...)); // so this is NOT folded anywhere: // uint256 callIndex // and that omission is what lets a // revertNextNCalls span run as a // 0-based sub-slice without diverging // from a continuous run. ``` ### Step 5 — A NO-MATCH CANNOT BE FORGED A reentrant call that finds no matching row folds CALL_NOT_FOUND, not CALL_END(true, ""). Sharing a tag would let a silent no-match be presented as a successful empty return; the separate domain byte makes those two histories hash differently. ```solidity // a normal empty return (:309) keccak256(abi.encodePacked( _rollingHash, CALL_END, true, "")); // a no-match (:331) — different tag keccak256(abi.encodePacked( _rollingHash, CALL_NOT_FOUND, crossChainCallHash)); // one tag for both would let the first // stand in for the second. ``` ### Step 6 — A TRIPLE COLLAPSED TO ONE The reentrant table's position key was (hash, rollingHash, isStatic) and is now keccak256(crossChainCallHash, rollingHash). isStatic did not stop mattering — it is already the first of the eight fields inside crossChainCallHash, so carrying it again committed to the same bit twice. ```solidity // EEZBase.sol:246 function _computeExpectedL1toL2Hash( bytes32 crossChainCallHash, bytes32 rollingHash ) internal pure returns (bytes32) { return keccak256(abi.encodePacked( crossChainCallHash, rollingHash)); } // isStatic is already field 1 of the // eight inside crossChainCallHash. ``` ### Step 7 — THE UNTAGGED EXCEPTION Static sub-hashes drop the tags entirely, into a separate accumulator. That is safe because the surrounding key already pins what the tags disambiguate — an asymmetry with a stated reason, not an oversight. ```solidity // EEZBase.sol:339 _rollingHashStaticResult // pure: never touches _rollingHash return keccak256(abi.encodePacked( prev, success, retData)); // EEZL2.sol:674 _processNStaticCalls computedHash = bytes32(0); for (cc in calls) { (success, retData) = sourceProxy.staticcall(...); computedHash = _rollingHashStaticResult( computedHash, success, retData); } ```