# EEZ Quickstarts > The Ethereum Economic Zone lets a contract on Ethereum or a rollup call a contract on > another chain and use the answer in the same transaction, with no bridge message and no > callback. Twenty-four interactive walkthroughs show how, 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 RollupX settles and proves. New here? Dapp developers start with [Run Your First Round Trip](https://build.eez.io/dapp-developers/qs1-run-your-first-round-trip.html). Rollup operators and protocol researchers get a reading order on [Start here](https://build.eez.io/start-here.html). 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. 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; interfaces may move after it. --- # Dapp developers Writing contracts that call across rollups. ## One Call, Two Chains, One Transaction Your L1 contract gets an L2 contract's return value back in the same transaction, with no bridge message and no callback. - Page: https://build.eez.io/dapp-developers/qs0-one-call-two-rollups.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:113 · _fallback` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/CrossChainProxy.sol#L113 ### Step 1 — YOUR CONTRACT CALLS A PROXY. CounterAndProxy is your L1 caller contract. You call incrementProxy(); it calls increment() on Counter's L1 proxy and saves the returned value in targetCounter. The proxy's fallback passes the call to the EEZ manager. ```solidity fallback() external payable { // ⋯ manager + self-probe branches _fallback(); } function _fallback() internal { // ⋯ static probe; normal call path: // Call the EEZ manager (success, result) = EEZ.call{value: msg.value}(abi.encodeCall(IEEZ.executeCrossChainCall, (msg.sender, msg.data))); if (success) { result = abi.decode(result, (bytes)); } // ⋯ return(result) to your contract } ``` ### Step 2 — THE ANSWER IS ALREADY THERE. The composer — the service that orders cross-chain calls and posts their proofs — gets its batch and your transaction into one block as one bundle, batch first. The batch is verified and its entries (one record per call) are queued before your call runs. executeCrossChainCall works only in that block: it hashes your call and takes the first queued entry whose hash, destination rollup and expected starting roots (each chain's state before the call) all match. ```solidity // tx 1, composer: postAndVerifyBatch // ⋯ step 4, _markVerifiedBlockAndResetQueues: rec.lastVerifiedBlock = uint64(block.number); // ⋯ step 8, back in postAndVerifyBatch: _saveRemainderEntries(batch); // tx 2, you: the proxy calls function executeCrossChainCall( address sourceAddress, bytes calldata callData // ⋯ ), then _validateProxyAndGetDestinationInfo: _requireVerifiedThisBlock(destRid); // ⋯ then _consumeAndExecuteEntry() scans: idx = _findMatchingEntry( // ⋯ queue, cursor, call hash, rollup ); entry = rec.entryQueue[idx]; ``` ### Step 3 — SAME TRANSACTION. The manager applies the entry, checks its roots and rolling hash against what the batch committed to, and returns its returnData. The proxy passes those bytes back, so your contract's next line already has the result from Counter on L2. No callback, and no second transaction from you. ```solidity // _consumeAndExecuteEntry, entry found bytes memory returnData = entry.returnData; _executeEntry( // ⋯ roots, calls, rollingHash ); // reverts RollingHashMismatch on divergence return returnData; // ⋯ executeCrossChainCall returns it, // the proxy decodes it and return()s it: // your contract has the answer. ``` ## Run Your First Round Trip Two scenarios pass on local anvil chains: one call starts on L1 and gets back the value L2's execution produces, and one makes the same call from L2. Locally, that value is precomputed in the entry. - Page: https://build.eez.io/dapp-developers/qs1-run-your-first-round-trip.html - Source: `eez-core-protocol/script/e2e/run/local/parallel.sh:17 · parallel.sh` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/script/e2e/run/local/parallel.sh#L17 **What this proves:** Both directions pass on local anvil chains, two per scenario: a call from L1 to L2 and a call from L2 to L1 each get the other side's return value back inside the calling transaction. **What it doesn't:** It does not use real proofs or a live node. The script does the composer's job: it precomputes the return value into an entry, posts that entry before your call, and the batch is accepted by an accept-all proof system. ### Step 1 — CLONE AT A FIXED COMMIT. Clone at a fixed commit so your output matches these cards. You need Foundry 1.7.1, with forge, cast and anvil on PATH. You also need Git, jq and bash 4 or newer; no Docker. macOS includes bash 3.2, and brew install bash puts bash 5 first on PATH. Step 2 builds and runs it. ```text # needs Foundry 1.7.1 (forge, cast, anvil on PATH), # Git, jq, and bash 4+. No Docker. # Foundry: https://book.getfoundry.sh/getting-started/installation # macOS includes bash 3.2; this puts bash 5 first on PATH: # brew install bash # jq: recent macOS includes it, or brew/apt install jq $ git clone https://github.com/eez-association/eez-core-protocol.git $ cd eez-core-protocol $ git checkout -q 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f $ git submodule update --init --recursive ``` ### Step 2 — BUILD AND RUN BOTH DIRECTIONS. One build, then both scenarios at once. About 5 minutes on an Apple M4 with Foundry 1.7.1 and solc already cached; the first build is most of it. ```text # the first build is most of the time $ forge build $ bash script/e2e/run/local/parallel.sh counter counterL2 [counterL2] starting (L1=18547 chain=41339, L2=18548 chain=41340) [counter] starting (L1=18545 chain=41337, L2=18546 chain=41338) [counterL2] PASS [counter] PASS ===== RESULT: 2 passed, 0 failed, 0 skipped ===== ``` ### Step 3 — WHAT THAT PASS PROVED. Locally, the scenario script did the composer's job. Card 03 (Who Answered?) shows what it did. ```text # counter: L1 called L2, got 1 back # counterL2: L2 called L1, got 1 back [counterL2] PASS [counter] PASS ===== RESULT: 2 passed, 0 failed, 0 skipped ===== ``` ## Follow One Call There and Back Your contract makes an ordinary call to a proxy, the proxy hands it to the L1 manager, and the 1 that Counter returns on L2 comes back as data that was posted before your call. - Page: https://build.eez.io/dapp-developers/qs2-read-your-side-of-it.html - Source: `eez-core-protocol/script/e2e/scenarios/one_way/L1_to_L2/counter/E2ECounter.s.sol:157 · DeployL2` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/script/e2e/scenarios/one_way/L1_to_L2/counter/E2ECounter.s.sol#L157 ### Step 1 — SET UP ONCE. Counter is deployed on L2 only. On L1, the scenario asks the L1 manager for Counter's proxy, then builds CounterAndProxy with that proxy as its target. Neither contract imports anything from EEZ. ```solidity // DeployL2, broadcast on L2: Counter counterL2 = new Counter(); // Deploy, broadcast on L1: the L1 manager // creates Counter@L2's proxy if it is missing address counterProxy = getOrCreateProxy(IEEZ(address(rollups)), counterL2Addr, L2_ROLLUP_ID); // your contract gets the proxy as its target CounterAndProxy cap = new CounterAndProxy(Counter(counterProxy)); ``` ### Step 2 — AN ORDINARY CALL. incrementProxy() calls target.increment() like any other contract call. target is counterProxy, a contract on L1, so that is where the call goes. The TEST tab runs this same contract against a local Counter. ```solidity contract CounterAndProxy { Counter public target; uint256 public targetCounter; uint256 public counter; constructor(Counter _target) { target = _target; } function incrementProxy() external { targetCounter = target.increment{gas: PROXY_CALL_GAS}(); counter++; } } ``` ### Step 3 — THE PROXY HANDS IT ON. CrossChainProxy has no increment(). Its fallback checks that this isn't a static call, then calls executeCrossChainCall on the L1 manager with who called it, CounterAndProxy, and the exact calldata, increment(). ```solidity fallback() external payable { // ⋯ manager and self-probe branches _fallback(); } function _fallback() internal { // ⋯ probe: is this call a STATICCALL? if (!success) { // ⋯ read path: staticCrossChainCall } else { (success, result) = EEZ.call{value: msg.value}(abi.encodeCall(IEEZ.executeCrossChainCall, (msg.sender, msg.data))); } // ⋯ step 5: decode it, return it } ``` ### Step 4 — THE ANSWER WAS POSTED FIRST. L1 never calls out to L2 mid-transaction. Counter runs on L2, from the L2 entry: it stores counter = 1 and returns 1. The L1 entry, posted before your call, already holds that 1. Locally, the scenario script plays composer and writes both. ```solidity // on L2: Counter, run by the L2 entry's call function increment() external returns (uint256) { counter++; return counter; } // on L1: the entry posted before your call, // by the batch (E2ECounter.s.sol, _l1Entries) entries[0] = ExecutionEntry({ ⋯ success: true, returnData: abi.encode(uint256(1)) }); ``` ### Step 5 — BACK IN YOUR CONTRACT. The manager returns the entry's returnData, the proxy unwraps it and returns the bytes, and Solidity decodes the uint256 that increment() declares. targetCounter is now 1. CounterAndProxy's own counter++ is a separate write on L1. ```solidity // EEZ, the L1 manager (_consumeAndExecuteEntry) bytes memory returnData = entry.returnData; // ⋯ _executeEntry checks roots + rolling hash return returnData; // CrossChainProxy (_fallback) result = abi.decode(result, (bytes)); // ⋯ return(result) hands the bytes back // CounterAndProxy: Solidity decodes the uint256 function incrementProxy() external { targetCounter = target.increment{gas: PROXY_CALL_GAS}(); counter++; } ``` ### Full compilable source — snippets/qs2-counter-and-proxy.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; // Panel source for: dapp-developers/qs2-read-your-side-of-it.html // Mirrors eez-core-protocol/test/mocks/CounterContracts.sol:4-23 and :53-66 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // These are the contracts the card 01 round trip runs. Copied verbatim: Counter // lives on L2, CounterAndProxy on L1. Nothing in CounterAndProxy knows it is // calling across chains. `target` just holds an address, and on the scenario // that address is Counter's cross-chain proxy on L1. /// @dev Explicit gas attached to the cross-chain proxy call sites below. L2 folds the gas left /// after its pre-hash checks into the outgoing call hash, so these sites attach a fixed amount /// that a test-side probe with the same attach reproduces (equals `BaseL2.CALL_GAS`). uint256 constant PROXY_CALL_GAS = 5_000_000; /// @notice View surface of `Counter`. Gives call sites a compile-checked handle on the /// auto-generated `counter()` getter (`Counter.counter.selector` is not reachable /// through the contract type). interface ICounterView { function counter() external view returns (uint256); } contract Counter is ICounterView { uint256 public counter; function increment() external returns (uint256) { counter++; return counter; } } contract CounterAndProxy { Counter public target; uint256 public targetCounter; uint256 public counter; constructor(Counter _target) { target = _target; } function incrementProxy() external { targetCounter = target.increment{gas: PROXY_CALL_GAS}(); counter++; } } ``` ### Runnable test — snippets/test/QS2CounterAndProxy.t.sol ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {Counter, CounterAndProxy} from "../qs2-counter-and-proxy.sol"; // The same CounterAndProxy, pointed at a same-chain Counter: the call site is // identical whether `target` is a local contract or a cross-chain proxy. contract QS2CounterAndProxyTest { function test_sameCallSiteWorksAgainstALocalCounter() public { Counter target = new Counter(); CounterAndProxy cap = new CounterAndProxy(target); cap.incrementProxy(); require(target.counter() == 1, "target not incremented"); require(cap.targetCounter() == 1, "return value not used"); require(cap.counter() == 1, "local state not updated"); } } ``` ## Who Answered? Follow the call hash from its calculation to the matching entry, then read the success flag and the return data that gives your contract 1. - Page: https://build.eez.io/dapp-developers/qs3-who-answered.html - Source: `eez-core-protocol/script/e2e/scenarios/one_way/L1_to_L2/counter/E2ECounter.s.sol:90 · _callHash` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/script/e2e/scenarios/one_way/L1_to_L2/counter/E2ECounter.s.sol#L90 ### Step 1 — COMPUTE THE CALL HASH. proxyEntryHash is the hash of the cross-chain request stored in an entry. The scenario computes it with _callHash(counterL2, counterAndProxy), using the caller on L1, the target Counter on L2 and the other call inputs. It identifies the request, not the returned value. ```solidity // Scenario: compute the entry's call hash bytes32 proxyEntryHash = _callHash(counterL2, counterAndProxy); // _callHash(counterL2, counterAndProxy) return crossChainCallHash( false, counterAndProxy, MAINNET_ROLLUP_ID, counterL2, L2_ROLLUP_ID, 0, _incrementCallData() ); // _incrementCallData(): increment() return abi.encodeWithSelector(Counter.increment.selector); ``` ### Step 2 — MATCH THE CALL TO ITS ENTRY. EEZ computes crossChainCallHash from the call forwarded by the proxy. _entryMatches compares it with entry.proxyEntryHash and rejects a different hash. The destination rollup and every starting root must match too. The queue scan takes the first full match or reverts with ExecutionNotFound. ```solidity // EEZ hashes the call received from the proxy bytes32 crossChainCallHash = computeCrossChainCallHash( NOT_STATIC_CALL, sourceAddress, MAINNET_ROLLUP_ID, destAddress, destRid, msg.value, ZERO_CALL_GAS, callData ); // _entryMatches: reject a different call if (entry.proxyEntryHash != crossChainCallHash) return false; if (entry.destinationRollupId != destRid) return false; // ⋯ live starting-root checks must also pass return true; ``` ### Step 3 — READ SUCCESS AND RETURN DATA. The entry was posted before the call. Here success is true and returnData encodes 1. After validating the entry, EEZ returns those bytes through the proxy; CounterAndProxy decodes and saves 1. The call hash identifies the request; batch verification covers the result. Local tests use permissive proofs. ```solidity // Scenario: the L1 entry already posted entries[0] = ExecutionEntry({ rollupUpdates: deltas, proxyEntryHash: proxyEntryHash, destinationRollupId: L2_ROLLUP_ID, // ⋯ calls and rollingHash success: true, returnData: abi.encode(uint256(1)) }); // EEZ: load the matched entry's result bytes memory returnData = entry.returnData; // ⋯ _executeEntry validates before returning return returnData; ``` ## Two Gotchas Before You Build Two cross-chain gotchas to check before you build: msg.sender is the source's proxy, and a state-changing proxy call needs an entry verified in the same block. - Page: https://build.eez.io/dapp-developers/qs5-three-gotchas-before-you-build.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:67 · fallback` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/CrossChainProxy.sol#L67 ### Step 1 — MSG.SENDER IS THE PROXY. Cross-chain, your contract is called by the source's proxy, not the source. Check msg.sender against the proxy address, or an owner check reverts every time. ```solidity // CrossChainProxy.sol · fallback() // The manager (EEZ on L1, EEZL2 on L2) has // the source's proxy make your call: fallback() external payable { // sender is EEZ, sig is executeOnBehalf // decodes the destination and call data ⋯ ? destination.call{value: msg.value}(data) ⋯ // The proxy is the caller. In your contract, // msg.sender == proxy(source), not source. ``` ### Step 2 — AN ENTRY COUNTS ONLY IN ITS BLOCK. Every proxy call checks that its rollup was verified in the current block. An entry posted in block N is dead in block N+1, and the call reverts with ExecutionNotInCurrentBlock. ```solidity // EEZ.sol · every proxy call on L1 goes // through _validateProxyAndGetDestinationInfo: _requireVerifiedThisBlock(destRid); // _requireVerifiedThisBlock (:1578): unless // lastVerifiedBlock == block.number, :1580 revert ExecutionNotInCurrentBlock(rollupId); // postAndVerifyBatch reaches // _markVerifiedBlockAndResetQueues (:804), // which sets lastVerifiedBlock to this block // (:806). Next block, the same entry is out // of reach. // L2: lastLoadBlock != block.number reverts // too (EEZL2.sol:791). ``` ## Cross-Chain Flash Loan Borrowed tokens go to an L2 and back before the repayment check runs, all inside one L1 transaction. - Page: https://build.eez.io/dapp-developers/qs7-cross-chain-flash-loan.html - Source: `eez-core-protocol/script/e2e/run/local/parallel.sh:17 · parallel.sh` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/script/e2e/run/local/parallel.sh#L17 **What this proves:** On two local anvil chains, one L1 transaction borrows 10,000 tokens, sends them to L2 and back, and repays the pool before the repayment check runs. **What it doesn't:** It does not use real proofs or a live node. The script does the composer's job and posts the entries in a separate transaction first, and the batch is accepted by an accept-all proof system. ### Step 1 — RUN IT. One L1 transaction borrows 10,000 tokens, sends them to L2 and back, and repays the pool. Locally, the scenario script does the composer's job and posts the entries in a separate transaction first. ```text # needs Git, jq, Foundry 1.7.1 (forge, cast, anvil on PATH) # and bash 4+; macOS ships 3.2, so: brew install bash # jq: recent macOS ships it, or brew/apt install jq # fresh clone at the pinned commit $ git clone https://github.com/eez-association/eez-core-protocol.git $ cd eez-core-protocol $ git checkout -q 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f $ git submodule update --init --recursive $ forge build # local anvil pair; the script plays composer $ bash script/e2e/run/local/parallel.sh flash-loan [flash-loan] starting (L1=18545 chain=41337, L2=18546 chain=41338) [flash-loan] PASS ===== RESULT: 1 passed, 0 failed, 0 skipped ===== ``` ### Step 2 — BORROW ON L1, USE IT ON L2. onFlashLoan sends the loan to executorL2 through Bridge.sol, then calls executorL2 through its proxy. On L2 it claims the NFT, and its return leg releases the escrow on L1 inside that same call. ```text // L1 · FlashLoanBridgeExecutor.onFlashLoan IERC20(_token).approve(address(bridge), amount); bridge.bridgeTokens(_token, amount, l2RollupId, executorL2); (bool success,) = executorL2Proxy.call( ⋯ abi.encodeCall(this.claimAndBridgeBack, (⋯)) ); require(success, "Cross-chain call failed"); // L2 · claimAndBridgeBack, reached through the proxy FlashLoanersNFT(nftContract).claim(); uint256 balance = IERC20(wrappedToken).balanceOf(address(this)); // then bridgeTokens(wrappedToken, balance, ⋯): back to L1 ``` ### Step 3 — REPAID BEFORE THE TRANSACTION ENDS. The pool checks its balance after onFlashLoan returns. It is whole only because the return leg already released the escrow on L1; otherwise the transaction reverts. ```text // L1 · FlashLoan.flashLoan, the pool uint256 balanceBefore = IERC20(token).balanceOf(address(this)); IERC20(token).safeTransfer(msg.sender, amount); IFlashLoanReceiver(msg.sender).onFlashLoan(token, amount); uint256 balanceAfter = IERC20(token).balanceOf(address(this)); require(balanceAfter >= balanceBefore, "Flash loan not repaid"); // last line of onFlashLoan, after the return leg // 3. Repay (tokens are back from bridge release) IERC20(_token).safeTransfer(msg.sender, amount); ``` ## Bridge Tokens Both Ways Run one ETH scenario per direction, L1 to L2 and L2 to L1, each on its own pair of chains, then run the test that sends an ERC-20 through the Bridge.sol test fixture and back. - Page: https://build.eez.io/dapp-developers/qs8-bridge-tokens-both-ways.html - Source: `eez-core-protocol/script/e2e/run/local/parallel.sh:17 · parallel.sh` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/script/e2e/run/local/parallel.sh#L17 **What this proves:** ETH crosses in both directions on local anvil chains, and an ERC-20 makes the lock, mint, burn and release round trip through the Bridge.sol test fixture. **What it doesn't:** Real proving, a live node, or a production bridge: Bridge.sol is a test fixture. The scenarios use an accept-all proof system with the script playing composer, and the token test uses a mock proof system. ### Step 1 — RUN IT. The scenarios move ETH across: bridge from L1 to L2, bridgeL2 from L2 to L1, with the script playing composer. The test then runs Bridge.sol's token round trip: lock and mint, burn and release. ```text # needs Git, jq, Foundry 1.7.1 (forge, cast, anvil on PATH) # and bash 4+; macOS ships 3.2, so: brew install bash # jq: recent macOS ships it, or brew/apt install jq # fresh clone at the pinned commit, then build $ git clone https://github.com/eez-association/eez-core-protocol.git $ cd eez-core-protocol $ git checkout -q 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f $ git submodule update --init --recursive $ forge build # one scenario per direction, run side by side $ bash script/e2e/run/local/parallel.sh bridge bridgeL2 [bridge] starting (L1=18545 chain=41337, L2=18546 chain=41338) [bridgeL2] starting (L1=18547 chain=41339, L2=18548 chain=41340) [bridge] PASS [bridgeL2] PASS ===== RESULT: 2 passed, 0 failed, 0 skipped ===== # token path in Bridge.sol: lock/mint, burn/release $ forge test --match-test test_BridgeTokens_Roundtrip ⋯ [PASS] test_BridgeTokens_Roundtrip() (gas: 2236711) Suite result: ok. 1 passed; 0 failed; 0 skipped; … ``` ### Step 2 — LOCK AND MINT. Bridge.sol makes the same kind of proxy call for ERC-20s. The origin bridge locks the token and calls the proxy that stands for the other bridge. When the other chain runs that entry, receiveTokens mints a wrapped copy, but only for the counterpart's proxy. ```text // ORIGIN CHAIN · bridgeTokens(token, amount, …) // Native token: lock in this contract IERC20(token).safeTransferFrom(msg.sender, address(this), amount); // payload = receiveTokens(token, rollupId, to, amount, …) (bool success, bytes memory reason) = bridgeProxy.call(payload); if (!success) revert ProxyCallFailed(reason); // OTHER CHAIN · receiveTokens, via onlyBridgeProxy if (msg.sender != expectedProxy) revert UnauthorizedCaller(); // Token is foreign → mint wrapped tokens // wrapped = _getOrDeployWrapped(originalToken, …) IBridgeToken(wrapped).mint(to, amount); emit WrappedTokensMinted(wrapped, to, amount); ``` ### Step 3 — BURN AND RELEASE. Going back, the bridge burns the wrapped copy and calls the proxy that stands for the origin bridge. When the origin chain runs that entry, receiveTokens releases the locked token. The ETH scenarios skip Bridge.sol; the step 1 test runs this path. ```text // OTHER CHAIN · bridgeTokens(wrapped, amount, …) TokenInfo memory info = wrappedTokenInfo[token]; if (info.originalToken != address(0)) { // Wrapped token: burn and trace back to original IBridgeToken(token).burn(msg.sender, amount); // payload names info.originalToken, info.originalRollupId ⋯ (bool success, bytes memory reason) = bridgeProxy.call(payload); // ORIGIN CHAIN · receiveTokens, via onlyBridgeProxy if (originalRollupId == rollupId) { // Token is native to this chain → release locked tokens IERC20(originalToken).safeTransfer(to, amount); emit TokensReleased(originalToken, to, amount); ``` ## How to get your contract's address on another rollup Learn how to read it, or deploy it. - Page: https://build.eez.io/dapp-developers/q7-your-cross-chain-address.html - Source: `eez-core-protocol/src/base/EEZBase.sol:204 · createCrossChainProxy` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/EEZBase.sol#L204 ### 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:204, :239 and :250 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // The two calls this page teaches. Neither is usually required: the protocol // creates the proxy itself on the first inbound call, through the public // getOrCreateCrossChainProxy (eez-core-protocol/src/base/EEZBase.sol:214), // called at eez-core-protocol/src/EEZ.sol:1169 and src/L2/EEZL2.sol:596. // 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; like upstream, the constructor takes no arguments and records /// its deployer (the manager) as msg.sender. contract CrossChainProxy { address internal immutable EEZ; constructor() { EEZ = msg.sender; } } /// @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); } struct ProxyInfo { bool isProxy; address originalAddress; uint64 originalRollupId; } contract CreateProxySnippet { /// @notice Error when a proxy is requested for an address on THIS /// manager's own network. error SameNetworkProxy(uint64 rollupId); bytes32 public immutable PROXY_INIT_CODE_HASH; mapping(address proxy => ProxyInfo info) public authorizedProxies; event CrossChainProxyCreated( address indexed proxy, address indexed originalAddress, uint64 originalRollupId ); constructor() { PROXY_INIT_CODE_HASH = keccak256(type(CrossChainProxy).creationCode); } function _getRollupId() internal pure returns (uint64) { return 0; } function createCrossChainProxy( address originalAddress, uint64 originalRollupId ) external returns (address proxy) { return _createCrossChainProxyInternal(originalAddress, originalRollupId); } function _createCrossChainProxyInternal( address originalAddress, uint64 originalRollupId ) internal 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}()); authorizedProxies[proxy] = ProxyInfo(true, originalAddress, originalRollupId); emit CrossChainProxyCreated( proxy, originalAddress, originalRollupId ); } function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) public view returns (address) { bytes32 salt = keccak256( abi.encodePacked(originalRollupId, originalAddress) ); return address(uint160(uint256(keccak256( abi.encodePacked( bytes1(0xff), address(this), salt, PROXY_INIT_CODE_HASH ) )))); } } ``` ## How to call a contract on another rollup Learn how to encode a read like any normal call, send it to the proxy as a staticcall so it stays read-only, then decode the answer and branch on it. - Page: https://build.eez.io/dapp-developers/q2-send-a-cross-chain-call.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:113 · _fallback` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/CrossChainProxy.sol#L113 ### 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 FOR READS 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 for a read: 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 { // ⋯ manager + self-probe branches _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:67 and :113 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // 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, unless the caller is the manager or the proxy // itself, 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. `ok == false` alone does not say why. The proxy forwards the /// raw revert data (CrossChainProxy.sol:137-141), which usually does /// (ExecutionNotFound / EntryNotFound vs the destination's own error), but /// a destination can emit the same selector, so don't branch on it. This /// Caller discards it; see docs/CAVEATS.md "Missing entries and /// destination reverts". 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:89). A /// plain `.call(data)` here would take the mutable path; the composer /// would then build an ExecutionEntry for it, hashed with /// `isStatic = false`, instead of a static one. STATICCALL routes it to /// `staticCrossChainCall` (EEZL2.sol:660) and a static entry, which is /// read-only: it cannot change state on either side. function settleIfFunded(address user, uint256 need) external returns (bool) { bytes memory data = abi.encodeCall( IRemote.balanceOf, (user) ); // STATICCALL for a read: 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 The manager-only forwarding ABI the proxy dispatches by selector. /// Copied from eez-core-protocol/src/interfaces/ICrossChainProxy.sol:13. interface ICrossChainProxy { function executeOnBehalf(address destination, uint64 callGas, bytes calldata data) external payable; } /// @dev Proxy side — how the forward actually happens. Upstream has no /// `staticCheck()` function and no hardcoded gas cap any more: both the /// manager's `executeOnBehalf` and the self-probe are served by selector /// inside fallback(), and the probe's cap is read from the manager /// (EEZBase.sol:65) so an upgraded manager can retune it without /// redeploying a single proxy. contract CrossChainProxySnippet { address internal immutable EEZ; /// @dev Upstream's detector variable (CrossChainProxy.sol:30). Writing it /// is a TSTORE, which halts in a static context. uint256 transient _staticDetector; bytes4 private constant STATIC_CHECK_SELECTOR = bytes4(keccak256("staticCheck()")); /// @dev The deploying manager is the only caller that can forward through /// this proxy. Upstream takes no constructor argument for it. constructor() { EEZ = msg.sender; } // CrossChainProxy.sol fallback() external payable { if (msg.sender == EEZ && msg.sig == ICrossChainProxy.executeOnBehalf.selector) { (address destination, uint64 callGas, bytes memory data) = abi.decode(msg.data[4:], (address, uint64, bytes)); (bool ok, bytes memory ret) = callGas == 0 ? destination.call{value: msg.value}(data) : destination.call{value: msg.value, gas: callGas}(data); assembly { switch ok case 0 { revert(add(ret, 0x20), mload(ret)) } default { return(add(ret, 0x20), mload(ret)) } } } if (msg.sender == address(this) && msg.sig == STATIC_CHECK_SELECTOR) { _staticDetector = 0; return; } _fallback(); } function _fallback() internal { uint256 staticCheckGas = IEEZ(EEZ).STATIC_CHECK_GAS(); (bool success,) = address(this).call{gas: staticCheckGas}( abi.encodeWithSelector(STATIC_CHECK_SELECTOR)); 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 // needs an ExecutionEntry instead. In production the composer reads the same // frame and builds whichever entry the frame asks for, so a plain-call read // still works; staticcall is what makes it a read-only static entry. This // table commits only static entries, so here a plain call misses. // // 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 // both sides apply (EEZ.sol:1580, EEZL2.sol:791), and L1's extra rule that a // static entry's state-root pins must still match (EEZ.sol:1352). // // 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 it, so a reader who hits one in production can /// search for the same string. This table models the L2 side, where BOTH /// paths miss with `EntryNotFound` and carry the call's hash and observed /// `callGas` in the payload (EEZL2.sol:173; read path :714, execution path /// :501). L1 misses with a bare `ExecutionNotFound` on both paths /// (EEZ.sol:1289, :1448). So the error NAME never tells you which path ran; /// on L2 the hash in the payload does, because it folds `isStatic`. 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:300) — 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 EntryNotFound(keyFor(true, sourceAddress, callData), 0); 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, so the test can show that a /// plain `call` lands here and not on the static entry: it misses /// under the same error name, with the non-static key. (In production /// the composer would have built an ExecutionEntry for that call.) 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`, the selector-keyed self-probe and the 1_000 gas cap are // upstream's (CrossChainProxy.sol:87-91, :119-120; EEZBase.sol:65). // Upstream reads the cap from the manager on every call; this table // has no such getter, so it is inlined here. contract ProbeProxy { address internal immutable manager; uint256 transient _staticDetector; bytes4 private constant STATIC_CHECK_SELECTOR = bytes4(keccak256("staticCheck()")); constructor(address manager_) { manager = manager_; } fallback() external payable { if (msg.sender == address(this) && msg.sig == STATIC_CHECK_SELECTOR) { _staticDetector = 0; return; } (bool probeOk,) = address(this).call{gas: 1_000}(abi.encodeWithSelector(STATIC_CHECK_SELECTOR)); 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`, which looks for an /// ExecutionEntry keyed `isStatic = false`, so the committed static /// entry can never satisfy it. Only a static entry exists here, so it /// misses. The error name is the same one a read miss uses; the key in /// the payload is what shows the path. (In production the composer /// sees the plain CALL and builds that ExecutionEntry instead.) 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(keccak256(ret) == keccak256(abi.encodeWithSelector( EntryNotFound.selector, table.keyFor(false, address(this), readData), uint64(0) )), "expected EntryNotFound keyed isStatic = false"); } /// @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:300), 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) == EntryNotFound.selector, "expected EntryNotFound"); } // ── 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, because Caller /// replaces the forwarded revert data with CrossChainCallFailed — this /// is docs/CAVEATS.md "Missing entries and destination reverts", executable. function test_destinationRevertIsIndistinguishableFromAMiss() public { table.commitStatic( address(caller), readData, false, abi.encodeWithSelector(RemoteBalanceUnavailable.selector) ); vm.expectRevert(CrossChainCallFailed.selector); caller.settleIfFunded(USER, 40); } } ``` ## How to handle msg.sender across chains Learn how to whitelist the proxy instead of the address you expected. - Page: https://build.eez.io/dapp-developers/q3-fix-the-msg-sender-gotcha.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:67 · fallback` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/CrossChainProxy.sol#L67 ### 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:67 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // 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 shape /// (eez-core-protocol EEZBase.sol:250 @ 9a1f815): CREATE2 over the manager's /// own address, the (rollupId, address) salt, and the hash of the /// argument-free proxy creation code. `Dummy` replaces the real proxy, so /// the address differs from production; the shape does not. contract MockEEZ { function computeCrossChainProxyAddress(address originalAddress, uint64 originalRollupId) external view returns (address) { bytes32 salt = keccak256(abi.encodePacked(originalRollupId, originalAddress)); bytes32 initCodeHash = keccak256(type(Dummy).creationCode); return address( uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash)))) ); } } /// @dev Like upstream's proxy, takes no constructor argument: the deploying /// manager is captured from msg.sender. contract Dummy { address public immutable manager; constructor() { manager = msg.sender; } } 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 check an address is a real proxy Learn how to read the on-chain registry instead of guessing. - Page: https://build.eez.io/dapp-developers/q4-check-if-an-address-is-a-proxy.html - Source: `eez-core-protocol/src/base/EEZBase.sol:85 · authorizedProxies` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/EEZBase.sol#L85 ### 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:85 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // 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() rejects any caller of the cross-chain entry points that isn't a registered proxy. - Page: https://build.eez.io/dapp-developers/q6-why-you-cant-call-the-manager-directly.html - Source: `eez-core-protocol/src/EEZ.sol:855 · executeCrossChainCall` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/EEZ.sol#L855 ### 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 result) { (address destAddress, uint64 destRid) = _validateProxyAndGetDestinationInfo(); ``` ### Step 2 — THE GUARD It reverts — UnauthorizedProxy(). Only a real proxy gets through. ```solidity function _validateProxyAndGetDestinationInfo() internal view returns (address, uint64) { ProxyInfo storage proxyInfo = authorizedProxies[msg.sender]; if (!proxyInfo.isProxy) revert UnauthorizedProxy(); ``` ### Step 3 — ON BOTH PROXY ENTRY POINTS Both proxy entry points open with this check. It checks who is calling, not where the call goes: at 9a1f815 a second check, EEZDestinationForbidden (EEZBase.sol:404), also stops a cross-chain call from targeting the manager itself. The EEZ-X mainnet deployment (855fe06) predates that check. ```solidity function _validateProxyAndGetDestinationInfo() internal view returns (address, uint64) { 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:855 and :1561 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // The registry check is the first thing the entry point does: its first line // calls _validateProxyAndGetDestinationInfo, whose first check is the registry // lookup. staticCrossChainCall opens with the same helper (EEZ.sol:1380). // 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. // // Omitted here: the helper's second check, _requireVerifiedThisBlock(destRid) // (EEZ.sol:1571), which only runs once the caller is already a proxy. 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 result) { (address destAddress, uint64 destRid) = _validateProxyAndGetDestinationInfo(); sourceAddress; callData; destAddress; destRid; return ""; } function _validateProxyAndGetDestinationInfo() internal view returns (address, uint64) { ProxyInfo storage proxyInfo = authorizedProxies[msg.sender]; if (!proxyInfo.isProxy) revert UnauthorizedProxy(); // msg.sender must be a real proxy — // never the caller's own address address destAddress = proxyInfo.originalAddress; uint64 destRid = proxyInfo.originalRollupId; return (destAddress, destRid); } } ``` ### 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://build.eez.io/dapp-developers/q5-encode-a-calls-content-hash.html - Source: `eez-core-protocol/src/base/EEZBase.sol:285` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/EEZBase.sol#L285 ### 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:285 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // 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" ); } } ``` ## How to read a remote contract's state Learn why balance and code size answer about the proxy — a real number about the wrong contract, with no revert. - Page: https://build.eez.io/dapp-developers/q8-read-a-remote-contracts-state.html - Source: `eez-core-protocol/src/base/CrossChainProxy.sol:113 · _fallback` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/CrossChainProxy.sol#L113 ### Step 1 — A CONTRACT ON THIS CHAIN Once created, the proxy is an ordinary contract 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 ); // once created, a contract on THIS chain with // its own code and its own balance ``` ### Step 2 — THREE READS, NO REVERT balance, extcodesize and extcodecopy describe the proxy, so each succeeds and returns a real number about the wrong contract; block-state opcodes describe the chain you are executing on, so they return a real number about the wrong chain. A delegatecall into a proxy runs the proxy's code in your context and reverts (UnauthorizedProxy) — it never reaches the remote contract. ```diff address proxy = eez.computeCrossChainProxyAddress( remote, remoteRollupId ); // once created, a contract 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:113 // and eez-core-protocol/docs/CAVEATS.md, "Opcodes that differ on cross-chain // proxies" and "Missing entries and destination reverts" // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // This is the caveat with no compiler help. The msg.sender 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: once created, a CrossChainProxy is an ordinary contract 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:113, `_fallback`) forwards to the manager, which // resolves an outcome that was written down before your transaction ran. So // inspection is local, invocation is resolved. // // CAVEATS.md lists four opcodes that answer about the proxy: `balance`, // `extcodesize`, `extcodecopy`, `delegatecall`. The first three succeed and // return a number about the proxy. 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` is the exception: it does not succeed. Delegatecalling a proxy // runs the PROXY's code in your context, so its fallback forwards to the manager // with YOUR contract as msg.sender, and the manager reverts `UnauthorizedProxy()` // (EEZ.sol:1564, EEZL2.sol:790). It never reaches the remote contract. // // WHEN A ROUTED READ RESOLVES — both sides gate on the block, but what else // must match differs, so do not learn one half and carry it to the other chain: // // L1: `staticCrossChainCall` (EEZ.sol:1379-1449) opens with // `_validateProxyAndGetDestinationInfo()` (:1380), which reverts // `ExecutionNotInCurrentBlock(destRid)` unless that rollup was verified in // this block (:1578-1580). A top-level read then scans `staticEntryQueue` // (:1420), or the batch's transient pool while one is mid-flight, and matches // on `proxyEntryHash`, `destinationRollupId` and `_rootsMatch(...)` // (:1437-1438). A nested read (inside an execution) is matched by the rolling // hash instead (:1390-1410). // Upstream's own comment: "Static entries require both current-block // verification (checked above) and matching root pins." // // L2: `staticCrossChainCall` (EEZL2.sol:660) opens with the same-named helper, // whose gate at EEZL2.sol:791 is // `if (lastLoadBlock != block.number) revert ExecutionNotInCurrentBlock();`. // A top-level read matches on the call hash and the live entry cursor (:705); // a nested read is matched by the rolling hash (:681-699). // // Mutating calls go through the same helper on both sides, so they are // block-gated too (EEZ.sol:863, EEZL2.sol:272). // // A read with nothing to resolve against reverts — `ExecutionNotFound()` on L1 // (EEZ.sol:1410, :1448), `EntryNotFound(bytes32 crossChainCallHash, uint64 // callGas)` on L2 (EEZL2.sol:699, :714). Those are the strings to search for. // // THE REVERT DATA USUALLY TELLS A MISS FROM A DESTINATION REVERT, BUT DON'T BRANCH // ON IT. CAVEATS.md, "Missing entries and destination reverts": a static lookup // miss reverts at the proxy, as does a matched reverting destination, and a // destination can emit the same selector — CrossChainProxy.sol:138-139 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 ); // once created, a contract 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. /// /// Both sides resolve it only in the block the entries were loaded: on /// L1 the rollup must be verified this block and, for a top-level read, /// the entry's root pins must match (EEZ.sol:1578-1580, :1437-1438); on /// L2 the table must be loaded this block (EEZL2.sol:791). With no matching entry this /// reverts — `ExecutionNotFound()` on L1, `EntryNotFound(...)` on L2 — /// and the forwarded revert data usually tells that from the /// destination reverting, but don't branch on it. 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. The fourth opcode CAVEATS.md // lists, `delegatecall`, is the opposite case: it reverts, and // test_delegatecallIntoTheProxyReverts shows why. // // 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 rule for when a static // entry is still valid (both sides gate on the block, EEZ.sol:1578-1580 and // EEZL2.sol:791; L1 also requires the entry's root pins to match the live // roots, EEZ.sol:1437-1438). 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 L1 names it (EEZ.sol:1410, :1448), so a reader who /// hits it in production can search for the same string. L2 reports its /// miss as `EntryNotFound(bytes32 crossChainCallHash, uint64 callGas)` /// instead (EEZL2.sol:173), which this local model does not reproduce. error ExecutionNotFound(); /// @dev The destination's own error, for the case where an entry exists and /// records that the destination call reverted. error RemoteBalanceUnavailable(); /// @dev Named as upstream names it (EEZ.sol:1564, EEZL2.sol:790): the manager /// only serves callers registered as proxies. error UnauthorizedProxy(); // @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; /// @dev The one caller allowed in, standing in for upstream's /// `authorizedProxies[msg.sender].isProxy` check. address public authorizedProxy; 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 Upstream registers a proxy when the manager creates it. function authorize(address proxy_) external { authorizedProxy = proxy_; } /// @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) { if (msg.sender != authorizedProxy) revert UnauthorizedProxy(); 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:113-142). 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)); table.authorize(address(proxy)); 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 Not a fourth silent read. A delegatecall runs the proxy's fallback /// in THIS contract's context, so the forward reaches the manager with /// this contract, not the proxy, as msg.sender, and the manager's proxy /// check reverts `UnauthorizedProxy()`. The real proxy's self-probe /// (not modelled here) does not change that: both of its paths call /// the manager from the delegating contract. function test_delegatecallIntoTheProxyReverts() public { (bool ok, bytes memory ret) = address(proxy).delegatecall(abi.encodeCall(IRemote.balanceOf, (USER))); require(!ok, "a delegatecall into the proxy must revert"); require( keccak256(ret) == keccak256(abi.encodeWithSelector(UnauthorizedProxy.selector)), "expected UnauthorizedProxy()" ); require(counterpart.calls() == 0, "it never reaches the counterpart"); } /// @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 an L1 read whose static entry is not in scope: no entry in the /// queue matches the call and the live root pins (EEZ.sol:1437-1438). /// A read after the block moved on fails earlier, with /// `ExecutionNotInCurrentBlock` (EEZ.sol:1580, EEZL2.sol:791), and an L2 /// miss reverts `EntryNotFound(...)` instead (EEZL2.sol:699, :714). function test_unresolvedReadRevertsExecutionNotFound() public { vm.expectRevert(ExecutionNotFound.selector); reader.readRemoteBalance(STRANGER); } /// @dev CAVEATS.md, "Missing entries and destination reverts": revert data /// alone is not a reliable way to tell a lookup miss from a matched /// reverting destination. /// 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"); } } ``` --- # 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://build.eez.io/rollup-operators/ro1-run-the-devnet-with-kurtosis.html - Source: `testing/kurtosis/README.md` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/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 and the standalone eez-rollup-ui repo. ```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, proof-signer and protocol playground together — several minutes on the first run. ports.sh then prints and exports every endpoint. ```bash $ 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 four images and # pulls the client images: several minutes $ source testing/kurtosis/ports.sh # L1 cross-chain front: http://127.0.0.1: # L2 cross-chain front: http://127.0.0.1: # Protocol playground: http://127.0.0.1: ``` ## How to deploy the protocol Learn what gets deployed on L1 and L2, and in what order, once the network is up. - Page: https://build.eez.io/rollup-operators/ro2-deploy-the-protocol.html - Source: `Makefile:50-64 · scripts/deploy.sh` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/Makefile#L50-L64 ### Step 1 — ONE COMMAND With `.env` in place, `make deploy-protocol` runs `./scripts/deploy.sh` through its four steps: DeployEEZ, DeployECDSAProofSystem, DeployRollup, RegisterRollup. ```bash # Reads .env (gitignored). Copy .env.example first. $ cp .env.example .env # EEZ_L1_RPC_URL EEZ_L1_POSTER_KEY # EEZ_PROOF_SIGNER_KEY # Runs the 4-step deploy sequence: # 1. DeployEEZ 2. DeployECDSAProofSystem # 3. DeployRollup 4. RegisterRollup ``` ### Step 2 — OUTPUTS LAND IN DEPLOYMENTS.ENV Every address deploy.sh produces lands in a gitignored `deployments.env` file, not your notes — 24 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 # Runs the 4-step deploy sequence: # 1. DeployEEZ 2. DeployECDSAProofSystem # 3. DeployRollup 4. RegisterRollup # 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 # Runs the 4-step deploy sequence: # 1. DeployEEZ 2. DeployECDSAProofSystem # 3. DeployRollup 4. RegisterRollup # 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://build.eez.io/rollup-operators/ro3-run-a-real-chiado-l2.html - Source: `docs/runbooks/chiado-quickstart.md` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/docs/runbooks/chiado-quickstart.md ### Step 1 — CONFIGURE ONCE Configure once: .env from the Chiado example with a synchronized RPC and dedicated poster and proof-signer keys, plus the L1 snapshot and JWT the embedded L1 needs to sync. ```bash # 1. Configure $ cp .env.chiado.example .env $ chmod 600 .env # set EEZ_L1_RPC_URL, EEZ_L1_POSTER_KEY, # EEZ_PROOF_SIGNER_KEY # 2. Prepare chain data — once $ mkdir -p data $ 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 ``` ### Step 2 — DEPLOY ONCE Deploy once with the published deploy image: it writes deployments.env and the genesis files, and it broadcasts transactions — never rerun it blindly, and never as a restart. ```bash # 3. Deploy contracts — once $ source .env $ mkdir -p datadir contracts/broadcast $ docker run --rm --pull always \ --platform linux/amd64 --network host \ -v "$PWD:$PWD" \ -v "$PWD/.env:/repo/.env:ro" \ -v "$PWD/contracts/broadcast:\ /repo/contracts/broadcast" \ -e EEZ_DEPLOYMENTS_FILE=\ "$PWD/deployments.env" \ -e EEZ_GENESIS_OUT="$PWD/datadir/genesis.json" \ -e EEZ_DEPLOY_SKIP_SIMULATION=1 \ "$EEZ_DEPLOY_IMAGE" ``` ### Step 3 — START AND CHECK chiado-up.sh starts eez-node, eez-proof-signer and Lighthouse, then reports chiado L2 is UP only after L2 production, an L1 settlement and retrievable blobs. ```bash # 4. Start and check $ bash scripts/chiado-up.sh # success: chiado L2 is UP + a settlement # transaction hash # L2 RPC :18688 # Embedded chiado L1 RPC :18645 # L1→L2 front (Inbound) :18999 # L2→L1 front (Outbound) :18998 # a timeout leaves containers running: $ docker compose logs --tail=100 \ eez-node eez-proof-signer lighthouse ``` ## 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://build.eez.io/rollup-operators/ro4-send-and-test-cross-chain-calls.html - Source: `README.md · scripts/xchain-test.sh` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/README.md ### Step 1 — TWO FRONTS, ONE JOB Two transparent proxy fronts sit in front of L1 and L2: a signed tx that enters the EEZ dispatcher is held for the next Sync block, ordinary traffic 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: a signed tx that enters the # source chain's EEZ cross-chain dispatcher is # held + composed into the next Sync block; # ordinary txs go to the source-chain mempool. ``` ### Step 2 — MIND THE BUNDLE SIZE On Chiado the cap is auto: overflow stays queued for a later Sync block, and a tx that fails settlement three times is evicted. Atomicity is one bundle, one L1 block, taken whole. ```bash # both fronts: a signed tx that enters the # source chain's EEZ cross-chain dispatcher is # held + composed into the next Sync block; # ordinary txs go to the source-chain mempool. # Maximum held user cross-chain txs, combined # across both directions, offered to one Sync # block. Auto adapts to measured preparation # time and actual gas/blob capacity. Overflow # stays queued. EEZ_MAX_USER_TXS_PER_BUNDLE=auto ``` ### 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 # Maximum held user cross-chain txs, combined # across both directions, offered to one Sync # block. Auto adapts to measured preparation # time and actual gas/blob capacity. Overflow # stays queued. EEZ_MAX_USER_TXS_PER_BUNDLE=auto # 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 RollupX actually settles and proves. ## How the four components divide the work Learn how sequencer, composer, proof-signer and deriver each fit. - Page: https://build.eez.io/protocol-researchers/pr1-the-four-components-of-rollup0.html - Source: `crates/eez-driver/src/sequencer.rs:1068 · crates/eez-composer/src/composer.rs:1339 · crates/eez-proof-signer/src/attest.rs:52 / :136 · crates/eez-deriver/src/deriver.rs:55` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/crates/eez-driver/src/sequencer.rs#L1068 ### Step 1 — THE LIVE PATH Ordinary blocks are the sequencer alone. One Sync block per L1 block adds the composer — and its batch cannot be posted to L1 until the proof-signer returns an attestation. ```rust pub struct Sequencer where T: PayloadTypes, { ... } pub struct Composer { ... } ``` ### Step 2 — ATTESTATION The proof-signer re-executes the batch itself and validates its settlement effects; only then does its Attester sign the recomputed public-inputs hash. ```rust pub struct Sequencer where T: PayloadTypes, { ... } pub struct Composer { ... } pub(crate) struct Attester { ... } pub(crate) fn sign( &self, public_inputs_hash: AttestablePublicInputsHash, ) -> Result { // #[cfg(test)] signing-call counter elided 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, { ... } pub struct Composer { ... } pub(crate) struct Attester { ... } pub(crate) fn sign( &self, public_inputs_hash: AttestablePublicInputsHash, ) -> Result { // #[cfg(test)] signing-call counter elided 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 what happens to it if L1 never confirms it. - Page: https://build.eez.io/protocol-researchers/pr2-commit-first-repair-if-needed.html - Source: `crates/eez-composer/src/optimistic.rs` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/crates/eez-composer/src/optimistic.rs ### Step 1 — THE LEDGER The composer builds the attested Sync block; begin() registers the attempt with every user AwaitingEvidence, and the block is committed to L2 before L1 confirms anything. ```rust // optimistic.rs:86 — variant docs omitted pub enum UserLocation { Queued, Composing, AwaitingEvidence, Terminal { reason: TerminalReason, canonical: Option, }, } // optimistic.rs:167 pub struct OptimisticallyIncluded { state: Mutex, } // optimistic.rs:199 pub fn begin( // params omitted — signature verified, body not ) -> Result { ``` ### Step 2 — OBSERVATION ≠ EVIDENCE The observer only records what happened to the submission. Users move only when resolve() applies canonical source outcomes; if the bundle never applied, the composer swaps its own Sync block for an empty one. ```rust // optimistic.rs:435 — observer callback pub fn observe( &self, id: AttemptId, observation: SubmissionObservation, ) -> Result { // records the submission's fate on the attempt; // never moves a user out of AwaitingEvidence } // optimistic.rs:467 — canonical evidence pub fn resolve( &self, id: AttemptId, as_of: L1BlockRef, outcomes: &[SourceOutcome], charge: AttemptCharge, attempt_limit: u32, ) -> Result, String> { // Terminal outcome -> Terminal; Retryable -> Queued } ``` ### Step 3 — TERMINAL IS NOT PERMANENT Terminal is not permanent. An L1 reorg reopens every canonical outcome above the common ancestor; attempts leave clean only at finality, after their L1 block is re-checked. ```rust // optimistic.rs:563 — wrapped; one line in source pub fn invalidate_after_l1(&self, ancestor: u64) -> Result<(), String> { // Terminal users with canonical evidence above // `ancestor` go back to AwaitingEvidence } // optimistic.rs:600 — wrapped; one line in source pub fn finalization_candidates(&self, finalized_l1: u64) -> Vec<(AttemptId, L1BlockRef)> { // optimistic.rs:625 pub fn forget_finalized( &self, id: AttemptId, audited: L1BlockRef, ) -> Result, String> { // the caller re-checks the L1 block hash first } ``` ## How the composer attests a batch Learn what is actually checked before a batch is accepted, and by whom. - Page: https://build.eez.io/protocol-researchers/pr4-how-the-composer-proves-a-batch.html - Source: `crates/eez-control-rpc/proto/prove.proto · prove.v1.Prover` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/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. The request carries calldata, blobs and provenance, but no claimed hash. ```protobuf service Prover { // Capabilities, PrepareOrdinary, RetainOrdinaryRanges omitted // 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; bytes expected_validation_identity = 5; ParentProvenance parent_provenance = 6; } message PostBatch { bytes abi_calldata = 1; reserved 2, 3; // claimed hash removed repeated bytes blobs = 4; L1PostingContext posting_context = 5; } ``` ### Step 2 — THE PROVER RE-EXECUTES The prover — the proof-signer, from step 1 — decodes PostBatch's calldata, derives KZG hashes from its blobs, replays every BlockWitness and runs its settlement gates to recompute the hash itself. ```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 and the hash matches the one it computes from its own request. ```protobuf message ProveResponse { bytes public_inputs_hash = 1; // 32B bytes signature = 2; // 65B } // eez-control-rpc/src/lib.rs:22 // — Rust, not the proto pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024 * 1024; // eez-proof-signer/src/service.rs:29, :32 // — 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://build.eez.io/protocol-researchers/pr3-the-deriver.html - Source: `crates/eez-deriver/src/deriver.rs · Deriver` - Pinned at commit: `2c19e36` - Verify: https://github.com/eez-association/eez-rollupX/blob/2c19e36bed2720c00f89841ccfca47ec7c921e06/crates/eez-deriver/src/deriver.rs ### Step 1 — WATCH L1 The deriver watches L1 for BatchPosted events naming its rollup, instead of trusting any node's live output. The log is the trigger; the payload is the posting transaction's blobs. ```rust // crates/eez-protocol/src/abi.rs:121 — wrapped event BatchPosted(bytes32 sharedPublicInput, uint64[] rollupIds); // ^ nothing indexed: rollupIds ride in the log // data (EEZ.sol:475 emits it); the scanner // skips logs without our rollup id // deriver.rs:55 pub struct Deriver ``` ### Step 2 — DECODE THE BLOBS The blobs are fetched and checked against their KZG versioned hashes; handle_event routes to on_batch_posted, which decodes that payload and replays the blocks through execute_block. ```rust // deriver.rs:1406 — wrapped async fn handle_event(&self, event: L1Event) -> DeriverResult<()> { // deriver.rs:1471 async fn on_batch_posted( // params omitted — signature verified, body not ) -> DeriverResult<()> { // deriver.rs:1487 — the log is the trigger, // the blob-carried payload is the data let payload = Payload::decode(operations_payload.as_ref())?; // deriver.rs:1142 — reached via replay_block 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 with no persisted seed. ```rust // deriver.rs:449 pub async fn catch_up(&self) -> DeriverResult<()> { // deriver.rs:666 — 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:648-654 — phase 2, arms wrapped match anchor { Some(anchor_l1_block) => self.sync_batches_inner(anchor_l1_block, cursor).await, None => match self.correlation_seed().await? { Some((l1_block, l2_cursor)) => self.sync_batches_inner(l1_block, l2_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://build.eez.io/protocol-researchers/pr5-how-the-address-is-derived.html - Source: `eez-core-protocol/src/base/EEZBase.sol:250 · computeCrossChainProxyAddress` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/EEZBase.sol#L250 ### 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, PROXY_INIT_CODE_HASH) into an address — before deployment. ```solidity // constructor: hashed once, cached PROXY_INIT_CODE_HASH = keccak256( type(CrossChainProxy).creationCode ); // computeCrossChainProxyAddress, continued return address(uint160(uint256(keccak256( abi.encodePacked( bytes1(0xff), address(this), salt, PROXY_INIT_CODE_HASH ) )))); } ``` ### 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:250 and :188 // @ 9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f // // 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. /// Like upstream, it takes no constructor argument and records its deployer, /// so the creation code alone is hashed. contract CrossChainProxy { address public immutable manager; constructor() { manager = msg.sender; } } contract ComputeAddressSnippet { bytes32 public immutable PROXY_INIT_CODE_HASH; constructor() { PROXY_INIT_CODE_HASH = keccak256( type(CrossChainProxy).creationCode ); } function computeCrossChainProxyAddress( address originalAddress, uint64 originalRollupId ) public view returns (address) { bytes32 salt = keccak256( abi.encodePacked( originalRollupId, originalAddress ) ); return address(uint160(uint256(keccak256( abi.encodePacked( bytes1(0xff), address(this), salt, PROXY_INIT_CODE_HASH ) )))); } } ``` ## The rolling hash Learn how one bytes32 commits to a whole execution tree — and why no call index is folded in. - Page: https://build.eez.io/protocol-researchers/pr6-the-rolling-hash.html - Source: `eez-core-protocol/src/base/EEZBase.sol:391 · _rollingHashEntryBegin` - Pinned at commit: `9a1f815` - Verify: https://github.com/eez-association/eez-core-protocol/blob/9a1f815336c20d28e5ec5cecbf195c41fbd5ab0f/src/base/EEZBase.sol#L391 ### 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:129 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:391 _rollingHashEntryBegin if (_rollingHash != bytes32(0)) revert RollingHashNotCleared(); bytes32 _rollupRootsHash; for (i in rollupUpdates) _rollupRootsHash = keccak256( abi.encodePacked( _rollupRootsHash, rollupUpdates[i].rollupId, rollupUpdates[i].currentRoot)); _rollingHash = keccak256(abi.encodePacked( _rollupRootsHash, proxyEntryHash)); ``` ### Step 3 — SIX TAGGED EVENTS Six 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:40-45 — the domain bytes CALL_BEGIN = 1; CALL_END = 2; NESTED_BEGIN = 3; NESTED_END = 4; CALL_NOT_FOUND = 5; CALL_INSUFFICIENT_GAS = 6; // :438 keccak256(abi.encodePacked( _rollingHash, CALL_BEGIN, crossChainCallHash)); // :446 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 (:446) keccak256(abi.encodePacked( _rollingHash, CALL_END, true, "")); // a no-match (:467) — 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:338 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:488 _rollingHashStaticResult // pure: never touches _rollingHash return keccak256(abi.encodePacked( prev, success, retData)); // EEZL2.sol:754 _processStaticIncomingCalls computedHash = bytes32(0); for (cc in calls) { (success, retData) = sourceProxy.staticcall(...); computedHash = _rollingHashStaticResult( computedHash, success, retData); } ```