THE PROBLEM — ONE VALUE FOR A WHOLE EXECUTION TREE
An ExecutionEntry is written before your transaction runs: it states what the calls are and what each returns. Execution then has to be checked against it. Not call by call, with a list of comparisons — with one equality.
require(_rollingHash == entry.rollingHash); // RollingHashMismatch
WHAT THAT ONE COMPARISON CATCHES
Wrong return data for any call · wrong success/failure flag · missing or extra calls · a skipped or extra reentrant frame · reordered operations · a reentrant call the table did not expect.
Six distinct classes of divergence, and none of them needs its own check. That is the property worth understanding — the rest of this guide is how it is bought.
SEED — STARTING STATE AND IDENTITY, BEFORE ANY RESULT
The accumulator does not start at zero. It starts from the state context the entry was written against, closed with the entry's own identity — so the hash commits to what the world looked like as well as what happened.
// L1 — ordered (rollupId, currentState) deltas, then identity
seed = keccak256(…keccak256(0 ‖ rollupId₁ ‖ currentState₁)…)
_rollingHash = keccak256(seed ‖ proxyEntryHash)
// L2 — the same formula with an empty delta prefix
_rollingHash = keccak256(bytes32(0) ‖ proxyEntryHash)
IT MUST BE CLEAR FIRST
_rollingHashEntryBegin reverts RollingHashNotCleared if _rollingHash is non-zero on entry. A prior entry that left residue cannot bleed into this one, and the check is cheap because the accumulator is transient — it is zero again by the next transaction whether or not anything cleaned up.
Deltas are strictly increasing by rollupId, so the fold over them is deterministic and needs no sort at verification time.
FIVE TAGGED EVENTS
Each fold carries a domain byte. The tag is not decoration: it is what stops one set of inputs from meaning two different things.
CALL_BEGIN
1
keccak(prev ‖ 1 ‖ crossChainCallHash)
CALL_END
2
keccak(prev ‖ 2 ‖ success ‖ retData)
NESTED_BEGIN
3
keccak(prev ‖ 3 ‖ crossChainCallHash)
NESTED_END
4
keccak(prev ‖ 4)
CALL_NOT_FOUND
5
keccak(prev ‖ 5 ‖ crossChainCallHash)
CALL_BEGIN folds the call's full identity hash, not just its position — so the chain commits to which call ran, and a substituted call with an identical return value still diverges.
THESE ARE PROTOCOL CONSTANTS, NOT PER-CHAIN CHOICES
The same call must hash identically on either chain for the proof to mean anything, so the tags live on EEZBase and both managers inherit them. "Nested" here is the neutral frame concept — it is not a direction. Directional naming lives in the per-side children.
NO CALL INDEX IS FOLDED IN — AND THAT IS THE INTERESTING PART
The obvious design folds a counter: call 0, call 1, call 2. This one does not, and the omission is deliberate.
_rollingHash = keccak256(_rollingHash, TAG, …)
// ^^^^^^^^^^^^ every fold depends on the previous value
Because each fold consumes the prior hash, order, count and nesting are already bound by the chain itself. An explicit index would be a deterministic 1, 2, 3, … — it adds no information a verifier did not already have.
THE PAYOFF, AND THE IDEA WORTH STEALING
Omitting the index is what lets a revertNextNCalls span be processed as a 0-based sub-slice and still produce a hash identical to a continuous run. Had an absolute index been folded in, the same calls executed inside a span would hash differently from the same calls executed normally, and the span could not be modelled at all.
Generalised: a hash chain plus domain tags already encodes sequence, so folding a position counter is redundant — and the redundancy is not free, because it forbids re-running a subsequence out of its original offset. That trade shows up in any commit-to-a-transcript design, not just this one.
A NO-MATCH CANNOT BE FORGED AS A NORMAL RETURN
A reentrant call that finds no matching row in the table is a real outcome, and it has to be committed to. The tempting shortcut is to treat it as "returned nothing".
// what a normal empty return folds
keccak(prev ‖ CALL_END(2) ‖ true ‖ "")
// what a no-match folds — different domain byte
keccak(prev ‖ CALL_NOT_FOUND(5) ‖ crossChainCallHash)
WHY THE SEPARATE TAG IS LOAD-BEARING
With one tag for both, a prover could present a call that silently found nothing as a call that succeeded and returned empty bytes. The distinct domain byte makes those two histories hash differently, so the substitution is caught at the entry's rolling-hash check.
It survives the awkward paths too: the divergence rides the accumulator across an intermediate try/catch and across a revert-span boundary in the ContextResult payload, so no side flag is needed to carry "a no-match happened" back out.
And it is not a trap for honest provers: one that deliberately pre-hashes CALL_NOT_FOUND is committing to a not-found at that exact position. That is a faithful outcome, not an attack.
THE POSITION KEY — A TRIPLE COLLAPSED INTO ONE COMPARISON
Rows of the unified reentrant table are content-addressed. The key has to pin both which call this is and where in the execution it fires.
(hash, rollingHash, isStatic)
→
keccak256(crossChainCallHash ‖ rollingHash)
function _computeExpectedL1toL2Hash(bytes32 crossChainCallHash, bytes32 rollingHash)
internal pure returns (bytes32)
{
return keccak256(abi.encodePacked(crossChainCallHash, rollingHash));
}
WHY isStatic DROPPED OUT
It did not stop mattering — it moved. isStatic is the first of the eight fields inside crossChainCallHash itself, so a read already keys distinctly from an otherwise-identical write. Carrying it again in the position key would have committed to the same bit twice.
That is what routes static rows and call rows through one table with no mode field: the bit that decides which resolution path can reach a row is already inside the row's address.
THE UNTAGGED EXCEPTION — STATIC SUB-HASHES
Static resolution carries its own expected hash, and it is a separate accumulator — not _rollingHash. Its formula drops the tags entirely.
hash = bytes32(0)
for cc in calls:
(success, retData) = sourceProxy.staticcall(…)
hash = keccak256(abi.encodePacked(hash, success, retData))
require(hash == expected); // RollingHashMismatch
AN ASYMMETRY WITH A STATED REASON
No domain bytes, no call-identity folds, and no nesting at all. Read as a diff against the tagged scheme that looks like a weaker version of it.
It is safe because the surrounding key already pins everything the tags disambiguate. A nested static row is addressed by keccak256(crossChainCallHash, rollingHash-at-fire-point) inside its entry; a top-level static entry by proxyEntryHash plus queue routing plus, on L1, the expected-state-root pins. All that is left for the static hash to commit to is the outcome of the read-only sub-calls in order — which is exactly what the untagged chain captures.
Nesting is absent for a structural reason rather than a chosen one: STATICCALL forbids state writes, so a proxy's executeOnBehalf cannot reenter the mutating entrypoints. A reentrant read re-enters staticCrossChainCall and resolves independently.
There is also no cross-contamination: _processNStaticCalls returns a local hash and never reads or writes _rollingHash.