Multi-Prover Specification
EEZ verifies batches with multiple proof systems. Each rollup's manager defines its accepted verifiers, verification keys and threshold.
Architecture
- EEZ stores roots, ETH balances and per-rollup queues. It queries each manager's policy, calls proof verifiers, and executes the accepted entries.
- Rollup managers authorize registration and provide verification keys and block context. The reference Rollup also exposes owner-controlled policy and root updates.
- Proof systems implement
verify(proof, publicInputsHash). Every proof listed in a batch must pass before the registry changes state.
Files
| Path | Role |
|---|---|
src/EEZ.sol | Central registry: roots, queues, postAndVerifyBatch flow |
src/base/EEZBase.sol | Shared base for L1+L2: rolling-hash machinery, proxy registry, computeCrossChainCallHash, the ContextResult transport, etc. |
src/L2/EEZL2.sol | L2 manager — inherits EEZBase; system-driven table loading (loadExecutionTable) and inbound delivery (executeIncomingCrossChainCall) |
src/rollupContract/Rollup.sol | Reference per-rollup manager (PS membership, vkeys, threshold, owner, getCustomData) |
src/interfaces/IRollup.sol | Declares IRollupContract — interface the registry calls back into |
src/interfaces/IProofSystem.sol | Interface for proof-verifying contracts |
src/interfaces/IEEZ.sol | Shared ProxyInfo + IEEZ interface, plus the L1 execution structs (RollupUpdate, ExecutionEntry, StaticExecutionEntry, L2ToL1Call, ExpectedL1ToL2Call, the batch structs, …) |
src/interfaces/IEEZL2.sol | L2 execution structs with self-relative directional names (CrossChainCall, ExpectedOutgoingCrossChainCall, ExecutionEntry, StaticExecutionEntryL2) — leaner than L1's (no RollupUpdate / destinationRollupId / ExpectedRootPerRollup) |
src/interfaces/IMetaCrossChainReceiver.sol | Callback fired on postAndVerifyBatch's sender to drive the transient stream |
src/base/CrossChainProxy.sol | CREATE2-deployed proxy per (originalAddress, originalRollupId); immutable EEZ points at the manager |
src/base/ExpectedL1ToL2CallTransient.sol | EIP-1153 transient-storage implementation for the expected reentrant-call table of the leading inline L2Tx entry currently executing; meta-hook and deferred entries use their stored tables |
Multi-prover model
ProofSystemBatchPerVerificationEntries
Each postAndVerifyBatch call carries a single batch struct:
struct ProofSystemBatchPerVerificationEntries {
ExpectedRootPerRollup[] expectedRootPerRollup; // optional composer assertions — checked first
ExecutionEntry[] entries;
StaticExecutionEntry[] staticEntries; // top-level static (read-only) entries
uint256 immediateEntryCount; // leading prefix executed this tx (not queued)
uint256 immediateStaticEntryCount; // leading static entries resolvable this tx via the meta hook
address[] proofSystems; // strictly increasing, no address(0), no duplicates
RollupIdWithProofSystems[] rollupIdsWithProofSystems; // strictly ascending by rollupId
uint256[] blobIndices; // selects which tx-level 4844 blobs the batch consumes
bytes callData; // batch-scoped application data
bytes[] proofs; // parallel to proofSystems — one proof per PS
uint64 blockNumber; // single L1 block the batch binds to (0 = no context, uint64.max = latest)
bool bindMsgSenderInPublicInput; // true = fold msg.sender into the public input (front-run protection)
}
struct RollupIdWithProofSystems {
uint64 rollupId;
uint64[] proofSystemIndexes; // indices into proofSystems[], strictly ascending; nonempty; reference manager additionally requires len >= threshold
}
struct ExpectedRootPerRollup {
uint64 rollupId;
bytes32 root;
}Counting rule: the batch verifies exactly proofSystems.length proofs — one per PS in
the global list — and all proofs must verify atomically (one revert reverts the whole call).
Per-rollup PS subset (explicit): each rollup R lists proofSystemIndexes[] —
strictly-ascending indices into the batch's proofSystems[]. The rollup's manager is
handed the resolved subset via IRollupContract.checkProofSystemsAndGetVkeys(subset) and
applies its own policy. The reference Rollup enforces (a) every PS is known with a non-zero vkey for R, (b) subset.length >= threshold. EEZ independently requires a nonempty subset even if the reference threshold is zero.
The registry never reads threshold separately — single external call per rollup. The registry
additionally requires the manager to return exactly one vkey per subset entry
(InvalidProofSystemConfig otherwise). Every globally listed proof system is verified, including one selected by no rollup; its per-system rollup/key accumulator remains zero.
Composer root pins: expectedRootPerRollup is an optional set of assertions
checked before anything else — each pin must equal the live rollups[rid].root or the
whole call reverts ExpectedRootMismatch(rid). Lets a batch composer refuse to land on
an unexpected state. These batch-level assertions are not folded into the public input; the root fields inside each full entry are.
Unproven dispatch params: immediateEntryCount / immediateStaticEntryCount are NOT
folded into the public input, so the immediate/persistent split can be re-tuned without
re-proving.
Threshold lives on the manager
IRollupContract.checkProofSystemsAndGetVkeys(address[] subset) does TWO things atomically:
- Returns the vkey row (one vkey per PS in
subset). - Reverts
ThresholdNotMetifsubset.length < threshold, orProofSystemNotAllowedif any PS isn't allowed for this rollup (unknown / zero vkey, non-strictly-increasing input). Seesrc/rollupContract/Rollup.sol.
Single external call per rollup, no TOCTOU between two reads, no central threshold semantics. Custom managers can use any threshold model they like (fixed M-of-N, governance-driven, time-weighted, etc.) — the registry just consumes the returned vkeys.
Per-PS publicInputsHash (two-stage)
for each rollup r in rollupIdsWithProofSystems (rollupId-ascending):
customDataHashes[r] = keccak256(abi.encode(rollupId_r, customData_r))
boundSender = batch.bindMsgSenderInPublicInput ? msg.sender : address(0)
sharedPublicInput = keccak256(abi.encodePacked(
abi.encode(entryHashes),
abi.encode(staticEntryHashes),
abi.encode(blobHashes),
keccak256(callData),
abi.encode(customDataHashes),
boundSender
))
for each PS k in proofSystems:
acc_k = bytes32(0)
for each rollup r (rollupId-ascending) where k ∈ rollupIdsWithProofSystems[r].proofSystemIndexes:
acc_k = keccak256(abi.encode(acc_k, rollupId_r, verificationKeysPerRollup[r][j]))
publicInputsHash[k] = keccak256(abi.encodePacked(sharedPublicInput, acc_k))entryHashes[i] = keccak256(abi.encode(batch.entries[i]))— binds the FULLExecutionEntrystruct (rollupUpdates, proxyEntryHash, l2ToL1Calls, expectedL1ToL2Calls, rollingHash, destinationRollupId, success, returnData). Prevents an orchestrator from swapping inputs at execution time without invalidating the proof.staticEntryHashes[i] = keccak256(abi.encode(batch.staticEntries[i]))— same rationale.blobHashes[i] = blobhash(batch.blobIndices[i])— the selected tx-level 4844 blob hashes; an index the tx carries no blob at (blobhash == 0) revertsBlobNotFound(index).customData_ris the opaque blob fetched per-rollup viaIRollupContract.getCustomData(batch.blockNumber)(rollup-defined L1-view commitment — the referenceRollupreturns ABI-encoded(timestamp, blockHash), an empty blob forblockNumber == 0, and revertsBlockHashUnavailablewhen the hash can't be resolved). Each blob is hashed keyed byrollupId_r, and the per-rollup hash ARRAY binds intosharedPublicInput. It does NOT vary per PS, so it is shared across allacc_krather than re-folded into each one.verificationKeysPerRollup[r][j]is the vkey ofproofSystems[proofSystemIndexes[r][j]]forrollupId_r(the jagged matrix returned by_getVerificationKeysPerRollup).boundSenderbinds the submitter: withbindMsgSenderInPublicInput = truethe proof commits to the exactmsg.senderallowed to land the batch (front-run protection for the meta-hook / AA bundle); withfalseit commits toaddress(0)and anyone may submit.
Per-rollup queue model
Storage
struct RollupVerification {
uint64 lastVerifiedBlock;
uint64 entryQueueIndex; // consumption cursor
uint64 entryQueueLength; // active execution entries / next append index
uint64 staticEntryQueueIndex; // active static entries / next append index
// All four uint64 counters share one storage slot.
mapping(uint256 => ExecutionEntry) entryQueue;
mapping(uint256 => StaticExecutionEntry) staticEntryQueue;
}
mapping(uint64 rollupId => RollupVerification record) internal verificationByRollup;lastVerifiedBlockserves as: (a) per-block reset marker (every verify that touchesridresets the queue bounds and cursor without deleting entries), (b) read gate for consumers (lastVerifiedBlock == block.number), (c) lockout signal for thesetRootowner-escape path.entryQueueandstaticEntryQueueare per-rollup deferred-consumption stores. Each entry's / static entry'sdestinationRollupIdselects which queue receives it during_saveRemainderEntries.
Reset on every verify
_markVerifiedBlockAndResetQueues(rid) zeros entryQueueLength, staticEntryQueueIndex, and the consumption cursor without deleting mapping entries on
every verify — including a same-block re-verify, which therefore REPLACES (does not append
to) the prior batch's entries. Stale entries from prior blocks are unreachable anyway because
consumers gate on lastVerifiedBlock == block.number. Appends overwrite retained slots from index zero, and scans stop at the active bounds. Static reads do not advance staticEntryQueueIndex; it is the active count, not a consumption cursor.
Routing
executeCrossChainCall(...): consumer's destination rollupId =proxyInfo.originalRollupId. Forward-scansverificationByRollup[rid].entryQueuefromentryQueueIndexfor the first entry matching identity (proxyEntryHash), routing (destinationRollupId), and state preconditions (everyRollupUpdate.currentRoot== the live root); non-matches are skipped, no match ⇒ExecutionNotFound.executeL2Txs(rid): explicitridarg; the matched entry must haveproxyEntryHash == bytes32(0). Same routing.staticCrossChainCall(...): consumer's destination rollupId =proxyInfo.originalRollupId. Outside an execution it scans ONE table: the batch's transient static pool while a batch is mid-flight, otherwiseverificationByRollup[rid].staticEntryQueue(match byproxyEntryHash+destinationRollupId+ allexpectedRootspins live, full scan)._consumeNestedCall/ reentrant static reads: resolved from the executing entry's own unifiedexpectedL1ToL2Calls[]table (entry-scoped — no queue routing at all), each entry content-addressed byexpectedL1toL2Hash == keccak256(crossChainCallHash, _rollingHash). A mutable reentrant no-match foldsCALL_NOT_FOUNDinto the rolling hash and returns success with empty bytes. Against an honest expected hash, a marker that survives to validation causesRollingHashMismatch; an ordinary enclosing revert can erase it.ContextResultpreserves the hash across an explicit rollback span. See the reverted-probe caveat. Static lookup retries candidates whose callback-result hashes mismatch, without advancing a cursor; exhaustion instead revertsExecutionNotFound. Same-key alternatives support local writes between reads (STATIC_ENTRY §4.4). This also applies to top-level static pools; the active table scope never changes during retry.
Transient phase (intra-tx)
During postAndVerifyBatch, the leading batch.immediateEntryCount entries form the
IMMEDIATE prefix. Its leading run of L2Tx entries (proxyEntryHash == 0) executes straight
from calldata — never SSTOREd. Only the prefix REMAINDER (the entries past the L2Tx run) is
copied into _transientEntries, together with the leading immediateStaticEntryCount static
entries into _transientStaticEntries; msg.sender must have code to drive them
(MetaEntriesWithoutReceiver otherwise).
The transient stream is consumed via the global _transientEntryIndex cursor (with the same
forward-scan matching as the persistent path).
After the transient stream drains (or doesn't), the persistent remainder is published to
per-rollup queues unconditionally. Each entry's RollupUpdate.currentRoot is checked at
consumption; entries whose required rollup roots do not match are skipped
(ExecutionNotFound if nothing else matches). A later entry is blocked if a skipped
transition leaves a required rollup root unmatched. Dependencies on other L1 state need
their own checks. L1 determines settlement order across deferred rollup queues; the
proven batch's array order does not impose it.
postAndVerifyBatch flow (current)
- Reentry check —
if (_insideExecution() || _transientEntriesLength != 0) revert PostBatchReentry();. There is no separate_inPostBatchflag; the conditions cover executing-entry and meta-hook windows. Ordinary registration callbacks outside them are not blocked. - Composer pins — every
expectedRootPerRolluppin must equal the live root, elseExpectedRootMismatch(rid). - Structural validation (no external calls) via
_validateBatchStructure(batch): sortedproofSystems[], strictly-ascendingrollupIdsWithProofSystems[].rollupId(androllupId > MAINNET_ROLLUP_ID), each rollup registered (rollupContract != 0), each row'sproofSystemIndexes[]strictly ascending and in range; per entry: ≥1rollupUpdates(strictly increasing, all ∈ batch),destinationRollupId∈ its own deltas, every call SOURCE ∈ its deltas (top-level + reentrant sub-calls); per static entry:expectedRootspins strictly increasing and ∈ batch,destinationRollupIdpinned, sub-call sources pinned; L2Tx canonicality (success == true, emptyreturnData), immediate prefix bounds, andImmediateCountStrandsLeadingL2Tx: the first deferred entry, if any, cannot have zeroproxyEntryHash, even when the boundary follows proxy entries. - Fetch vkeys + verify:
_getVerificationKeysPerRollup(batch)calls each rollup's manager viaIRollupContract.checkProofSystemsAndGetVkeys(subset)— manager enforces threshold and returns one vkey per PS in the subset (length-checked by the registry). Then_verifyProofSystemBatch(batch, verificationKeysPerRollup)computessharedPublicInput(folding each rollup'scustomDataviagetCustomData(batch.blockNumber)), builds per-PSpublicInputsHash[k], and callsIProofSystem.verify(proofs[k], publicInputsHash[k])for each PS. A false return reverts withInvalidProof; a verifier revert propagates its original error (including reference ECDSA malformed-signature errors). Either path unwinds the whole batch atomically. - Mark verified-this-block (
_markVerifiedBlockAndResetQueues(rid)for each rollup): resets the rollup's queue bounds and cursor without deleting entries on every verify — a same-block re-verify REPLACES (does not append to) the prior batch's entries for that rollup. Sets the read gate forexecuteCrossChainCall/executeL2Txs. - Drain the leading immediate L2Tx run straight from calldata: while
batch.entries[i].proxyEntryHash == 0(within the immediate prefix), self-call_attemptExecuteImmediateL2Txs(batch.entries[i]). A nonempty caught revert emitsL2TxSkipped(i, revertData)and advances; empty data aborts withImmediateL2TxOutOfGas(i)before emission (it does not prove OOG). If the nonempty run has no successes,AllImmediateL2TxsFailedunwinds the post. Every outer revert discards earlier skip logs. - Meta hook: if immediate-prefix entries remain past the L2Tx run,
msg.sendermust have code to receive the hook (MetaEntriesWithoutReceiverotherwise); push them into_transientEntries(and the leadingimmediateStaticEntryCountstatic entries into_transientStaticEntries), then fireIMetaCrossChainReceiver(msg.sender).executeMetaCrossChainTransactions()so the caller can drive them via cross-chain proxy calls. - Publish the remainder via
_saveRemainderEntries(batch)(unconditionally — even if the meta hook left transient entries unconsumed): entries pastimmediateEntryCountintoentryQueue[destinationRollupId], static entries pastimmediateStaticEntryCountintostaticEntryQueue[destinationRollupId]. - Cleanup transient tables (which also closes the re-entry window), then
emit BatchPosted(sharedPublicInput, rollupIds).
Reentrancy reasoning
The three external calls during step 4 (IRollupContract.checkProofSystemsAndGetVkeys,
IRollupContract.getCustomData, IProofSystem.verify) are all view. The Solidity compiler
emits STATICCALL for view-marked interface calls. Inside a STATICCALL frame, ALL state
mutations revert at the EVM level — SSTORE, TSTORE, LOG, CREATE, CALL with value,
AND any nested CALL that tries to do those things. The static context propagates down the
call stack with no assembly bypass. So a malicious manager or verifier cannot reenter
postAndVerifyBatch (state-mutating) from inside step 4 — its first SSTORE would revert.
The other reentrancy windows are non-view callbacks:
IRollupContract.rollupContractRegistered (called once from registerRollup), the immediate
L2Tx run's proxy targets (step 6), and the IMetaCrossChainReceiver hook (step 7). Those are
normal CALL → can reenter. Lockouts:
- Re-entry into
postAndVerifyBatchduring execution or the meta hook is blocked by the_insideExecution() || _transientEntriesLength != 0check in step 1 (PostBatchReentry)._insideExecution()covers the immediate L2Tx run and any executing entry;_transientEntriesLength != 0covers the meta-hook window. This covers both the same-rollup and disjoint-rollup cases without needing a separate flag. A registration callback outside those windows may post an otherwise valid batch; it is not itself an active posting window. Static verification callbacks cannot complete state writes, but need not fail specifically withPostBatchReentry. EEZ.setRoot(called from the manager) → gated byRollupBatchActiveThisBlock(lastVerifiedBlock == block.number) ANDSetRootNotAllowedDuringExecution(_insideExecution() == true). The latter prevents a malicious manager from rewriting state mid-execution via a reentrant proxy path.
Manager registration (no handoff)
Initial registration
function registerRollup(address rollupContract, bytes32 initialRoot) external returns (uint64 rollupId);- Deploy and initialize an
IRollupContract-conforming manager before registration. The reference Rollup proxy initializes its owner, proof systems, vkeys and threshold atomically during deployment; see the deployment guide. - Registry assigns next
rollupId(auint64; sequential ids stay well below 2^64), stores(rollupContract, initialRoot, etherBalance=0). - Fires
IRollupContract(rollupContract).rollupContractRegistered(rollupId, registrant)— one-shot callback so the manager learns its id;registrantis the registry's ownmsg.sender. The reference impl requiresregistrant == owner()(elseUnauthorizedRegistrantAccount), stores the id and rejects a second call (rollupId != 0⇒AlreadyRegistered). - Emits
RollupCreated(rollupId, rollupContract, rollups[rollupId].root)using the stored root after the registration callback.
No manager handoff
The registry binds each rollup ID to its manager address at registration. A registered proxy manager can be upgraded through its own administration without changing that address.
Owner escape (root)
function setRoot(uint64 rollupId, bytes32 newRoot) external;- Callable only by the registered manager (
msg.sender == rollups[rid].rollupContract). - Reverts
RollupBatchActiveThisBlockif any batch hitridearlier this block. - Reverts
SetRootNotAllowedDuringExecutionif_insideExecution()is true. - The single state-mutating call from manager into registry. Emits
RootUpdated.
Trust boundaries
- Rollup policy: the owner chooses trusted proof systems and their threshold. EEZ checks the manager's returned keys and each verifier's result; it does not establish circuit soundness.
- Cross-rollup execution: each entry must cover its destination and call sources in its proven rollup set. Nested targets must also belong to that set.
- Posting and delivery: all listed proofs must pass atomically. The poster controls the unproven immediate/deferred split, and unconsumed immediate work can be discarded. Proof acceptance does not guarantee delivery of every entry.
- Registration callbacks: EEZ forwards the registrant to the manager. The reference Rollup requires its owner and rejects repeated registration. Custom managers define their own registration policy and can permit multiple rollup IDs for one address.
- Callback guards: key lookup, custom-data lookup and proof verification are static
calls.
postAndVerifyBatchrejects reentry during execution and the meta hook. Registration callbacks outside those windows can perform otherwise permitted operations. - Root updates: nested rows share the outer entry's pre-state. Only the outer entry
applies root and ETH deltas, subject to validation and EVM rollback. Direct
setRootcalls are restricted to the registered manager and the registry's execution/block guards. - Reserved rollup ID: ID zero identifies L1. Registration starts at one, and batch validation rejects zero as a participating rollup ID.
See Caveats for proof domains, freshness, funding and supported call patterns.