Cross-Repository Protocol Design & Implementation Audit
A field-by-field trace of every piece of data in a Taiko Proposal and every derived L2 block, showing how each is proved — directly or transitively — against state managed by the protocol contracts on Ethereum L1, and a formal argument that the verification graph is acyclic (no circular dependency).
Headline result. Every piece of proposal-level and block-level data reduces — through a finite, strictly backward-looking chain of cryptographic checks — to one of seven L1 trust roots: the proposal-hash ring buffer, CoreState.lastFinalizedBlockHash, the rest of CoreState, EIP-4844 blob versioned hashes, origin block hashes, the immutable verifier registry (chain id + verifier address + trusted image ids / SGX instances), and the soundness of the proving system itself. The verification graph is a DAG that terminates at these roots; no datum is verified against a value that (directly or indirectly) depends on it.
But the adversarial pass found real bugs. A second, bug-hunting pass (§15) targeted the implementations, not the design. It surfaced a Critical witness-soundness break in the gaiko2 (TEE) prover (D1 — execution over unauthenticated pre-state), a High consensus-split in the Go driver (D2 — a missing fork-time floor), and several Medium/Low issues. These are prover/driver implementation defects, not design cycles: the L1 contracts and the raiko2 ZK guest held up under the same tracing. "Sound design" does not imply "bug-free stack" — see §15.
Three repositories jointly define and verify Taiko's Shasta rollup. This audit reads all three at the commits above and treats them as one system:
| Repo | Role | Language | What it owns in the trust chain |
|---|---|---|---|
| taiko-mono /packages/protocol | L1 & L2 smart contracts + the derivation spec | Solidity | Inbox (propose/prove/finalize, ring buffer, CoreState, bonds), verifiers (SGX/RISC0/SP1/compose), Anchor, SignalService, and docs/Derivation.md. |
| taiko-mono /packages/taiko-client(-rs) | Node / driver — the reference deriver | Go + Rust | Converts a Proposed event into L2 blocks (blob→manifest→blocks), builds the anchor tx, inserts via the engine API. |
| raiko2 | ZK prover (RISC0 & SP1 guests) | Rust | Re-derives & re-executes each proposal statelessly inside a zkVM; aggregates into one validity proof matching the L1 Commitment. |
| gaiko2 | TEE prover (Intel SGX via EGo) | Go | Independently re-derives, re-executes, and signs the same commitment inside an SGX enclave; attested key registered on L1. |
raiko2's guest bindings and the L1 contracts were read first-hand; the gaiko2 internals
were mapped both by reading and against the repo's own in-progress audit (gaiko2/docs/audits/) — items sourced
from that document are labelled and carry an explicit confidence note.Two structural facts that govern everything below.
(a) The on-chain Proposal struct is stored only as a hash in a ring buffer; its full contents live
off-chain (in the Proposed event + L1 block context + blobs). Whoever reconstructs a proposal must reproduce a
struct whose keccak matches the ring-buffer slot — that single equality authenticates all proposal fields at once.
(b) The Proposed event deliberately omits timestamp, originBlockNumber, and
originBlockHash. The deriver reconstructs them from the L1 block that emitted the log (timestamp = that
block's time; originBlockNumber = emitting block − 1; originBlockHash = that parent's hash). They are
re-checked because they are part of the hashed Proposal.
These are the only values the whole construction is allowed to assume. Everything else must chain to one of them. All are managed by the protocol contracts on Ethereum (or are a stated cryptographic assumption).
| # | Root | Where it lives on L1 | Written by |
|---|---|---|---|
| R1 | Proposal-hash ring buffer_proposalHashes[id % ringBufferSize] = keccak256(abi.encode(Proposal)) |
Inbox.sol:136,286,559 | propose() at proposal time |
| R2 | Last finalized L2 block hashCoreState.lastFinalizedBlockHash |
Inbox.sol:96,378 | prove() / activate() |
| R3 | Rest of CoreState — nextProposalId, lastFinalizedProposalId, lastFinalized/CheckpointTimestamp, lastProposalBlockId |
Inbox.sol:83-97 | propose() / prove() |
| R4 | EIP-4844 blob versioned hashes — captured via the blobhash() opcode and stored inside the proposal's BlobSlice.blobHashes (→ folded into R1) |
LibBlobs.sol:44-48 | propose() / saveForcedInclusion() |
| R5 | Origin block hash — originBlockHash = blockhash(block.number-1), stored in the proposal (→ folded into R1) |
Inbox.sol:610-618 | propose() |
| R6 | Verifier trust root — two distinct parts: immutable wiring (verifier contract address, taikoChainId, and Inbox._proofVerifier itself) and governance-mutable allowlists (trusted RISC0 imageId via setImageIdTrusted, SP1 vkey via setProgramTrusted, and the SGX instance registry + MRENCLAVE/MRSIGNER policies). Changing an allowlist changes which proofs L1 accepts — see F9. |
Risc0Verifier.sol, SP1Verifier.sol, SgxVerifier.sol | Immutable: constructor. Allowlists: owner / DAO controller; SGX via DCAP attestation |
| R7 | Soundness of the proving system — the ZK proof system and the SGX TEE/attestation are assumed sound (a cryptographic/hardware assumption, not a checkable value) | — (assumption) | — |
R4 and R5 are not separate storage slots — they are values captured by L1 opcodes at propose time and embedded into the Proposal, so they are ultimately anchored by R1. They are called out separately because they are the bridge from "raw off-chain bytes" (blobs, L1 headers) to on-chain commitments.
On R6's mutability (a governance trust assumption). Only the verifier wiring is immutable. The trusted sets R6 depends on — RISC0 image ids, SP1 program keys, and the SGX instance / MRENCLAVE / MRSIGNER allowlists — are on-chain state the verifier owners (the DAO controller, plus DCAP-gated registration for SGX) can change, and a change alters which proofs L1 will finalize. The §12 DAG treats these as L1-managed roots; their integrity is a governance trust assumption, tracked as Observation F9.
┌─ L1 (Ethereum) ──────────────────────────────────────────────────────────────┐ propose(lookahead, data) prove(data, proof) • dequeue ≤10 forced inclusions (FIFO), append • decode Commitment proposer source last • require lastProposalHash == _proposalHashes[lastId] → R1 • blobHashes = blobhash(i) → R4 • require lastFinalizedBlockHash == firstProposalParentBlockHash → R2 • originBlockHash = blockhash(n-1) → R5 • saveCheckpoint(endBlockNumber,endStateRoot,lastBlockHash) • Proposal = {id,ts,proposer,parent,origin,…,src} • CoreState.lastFinalized* ← commitment → R2,R3 • _proposalHashes[id] = keccak(Proposal) → R1 • verifyProof(age, hashCommitment(commitment), proof) → R6,R7 • emit Proposed(id,proposer,parent,eos,pctg,sources) └────────────────────────────────────────────────────────────────────────────────┘ │ Proposed event + blobs ▲ Commitment + validity proof ▼ │ ┌─ Node / driver (taiko-client) ─────────┐ ┌─ Provers (raiko2 ZK / gaiko2 TEE) ──────┐ reconstruct full Proposal, hash-check re-derive blocks from (Proposal,blobs) the blob → manifest (v0x1|size|zlib|RLP) same way; re-execute statelessly against manifest → L2 blocks (timestamp/gas/ parent state (MPT witness); bind the result anchor/coinbase/basefee rules) into a per-proposal sub-proof, then aggregate build anchorV4 tx (golden touch, fixed-k) into one Commitment whose hash the L1 verifier insert via engine API; set head reproduces. └─────────────────────────────────────────┘ └─────────────────────────────────────────┘
The driver and the two provers implement the same derivation function D(Proposal, blobs, parentState) → blocks.
The driver produces the canonical chain; the provers re-derive and re-execute it and attest that the result is
exactly what the rules dictate. L1 then checks the attested commitment against R1/R2 and the proof against R6/R7.
PROPOSAL-LEVEL Struct IInbox.Proposal (IInbox.sol:60-79). The whole struct is
authenticated by one equality — hash_proposal(Proposal) == R1 — verified in every prover
(raiko2 guest-common/src/lib.rs:852; gaiko2 guestinput_carry.go:112).
| Field | Type | Origin | How verified → root |
|---|---|---|---|
id | uint48 | Proposed.id | ∈ hash_proposal; aggregation also checks ids are sequential & ∈ uint48 → R1 |
timestamp | uint48 | L1 block time of the emitting log (not in event) | ∈ hash_proposal → R1; also bound into the sub-proof transition |
endOfSubmissionWindowTimestamp | uint48 | Proposed.eos | ∈ hash_proposal → R1 (used only by preconf handover; not a block field) |
proposer | address | Proposed.proposer (= msg.sender at propose) | ∈ hash_proposal → R1; also the Transition.proposer checked equal |
parentProposalHash | bytes32 | Proposed.parentProposalHash (= getProposalHash(id-1) on L1) | ∈ hash_proposal; aggregation checks prev.hash == next.parentProposalHash → chains to R1 |
originBlockNumber | uint48 | emitting L1 block − 1 (not in event) | ∈ hash_proposal; bounds anchorBlockNumber; matched to the L1 ancestor-header chain → R1,R5 |
originBlockHash | bytes32 | blockhash(n-1) at propose (not in event) | ∈ hash_proposal; the L1 ancestor-header chain's tip must equal this → R1,R5 |
basefeeSharingPctg | uint8 | Proposed.basefeeSharingPctg (immutable config) | ∈ hash_proposal; also re-encoded into block extraData and checked → R1 |
sources[] | DerivationSource[] | Proposed.sources | ∈ hash_proposal → R1 (see below) |
SOURCE-LEVEL Struct IInbox.DerivationSource (IInbox.sol:52-57) and
LibBlobs.BlobSlice (LibBlobs.sol:21-28). Each source is either a forced inclusion (queued via
saveForcedInclusion) or the proposer's own source (appended last).
| Field | Type | How verified → root |
|---|---|---|
isForcedInclusion | bool | ∈ hash_proposal → R1; selects the derivation branch (forced sources inherit parent anchor, must have exactly 1 block) |
blobSlice.blobHashes[] | bytes32[] | ∈ hash_proposal → R1. The raw blob bytes are additionally bound: KZG(blob) → versionedHash == blobHashes[i] (R4 semantics) in raiko2 blob.rs:117 / gaiko2 blob_validate.go:75 |
blobSlice.offset | uint24 | ∈ hash_proposal → R1; the byte offset the manifest decoder reads from |
blobSlice.timestamp | uint48 | ∈ hash_proposal → R1; used for the forced-inclusion delay gate |
Blob data-availability is fully proved. The versioned hash is a keccak/SHA of the KZG commitment; the guest
recomputes it from the actual blob bytes it derives from and requires equality with the on-chain blobHashes — which
are themselves inside the ring-buffer proposal hash. A proposer cannot feed the prover blob bytes different from those committed
on L1.
DERIVED FROM DA The blob bytes decode into a ProposalManifest of
DerivationSourceManifests, each holding BlockManifests. Encoding:
[32B version=0x1][32B size][zlib(RLP(manifest))] at blobSlice.offset. This is a pure, deterministic
function of the blob bytes, which are bound to R4/R1 — so the manifest needs no separate anchor; the prover simply re-runs
the decode and rejects/degrades identically to the driver.
| Field (BlockManifest) | Type | Verification |
|---|---|---|
timestamp | uint48/u64 | Decoded from DA, then clamped (§9); out-of-range ⇒ whole source degrades to default. Final value re-checked into the L2 header. |
coinbase | address | Decoded from DA; for forced/default sources overwritten with proposal.proposer. Re-checked as block beneficiary. |
anchorBlockNumber | uint48/u64 | Decoded from DA (0 ⇒ inherit parent); validated monotonic & within [origin−MAX_OFFSET, origin] (§9); binds the anchor tx checkpoint number. |
gasLimit | uint48/u64 | Decoded from DA; validated ±200 ppm of parent, clamped [10M,45M]; +1,000,000 anchor budget added; re-checked as block gasLimit. |
transactions[] | SignedTx[] (RLP TxEnvelope) | Decoded from DA; the guest re-executes them and requires the produced block (tx set, roots) to match the canonical block byte-for-byte. |
Invalid-data policy (all three impls identical): version mismatch, bad size/offset, zlib/RLP failure, trailing bytes, forced source with ≠1 block, or block count over the per-source cap (192 pre-Unzen / 768 Unzen) → the source is replaced by the default manifest (one anchor-only block). This is a deterministic function of authenticated bytes, so provers and driver agree. See Observation F7.
BLOCK-LEVEL Each derived block's header. The prover reconstructs the block from
(anchor tx + manifest txs + derived env), executes it statelessly against the parent state, and requires
generated.header == canonical.header (raiko2 lib.rs:688-719), which forces every field below to be exactly
the derivation-rule value.
| Header field | Determined by | How verified → root |
|---|---|---|
parentHash | previous block hash | Block i>0: equals block i-1's validated hash. Block 0: equals carry.parentBlockHash = Commitment.firstProposalParentBlockHash = R2 |
number | parent.number + 1 | re-derived; consensus check ties to parent header (chained to R2) |
timestamp | manifest, clamped (§9) | re-derived from parent-ts & proposal-ts (∈ R1); header must match |
gasLimit | manifest gas + 1,000,000 | re-derived within ±200 ppm of parent; header must match |
beneficiary (coinbase) | manifest / proposer | re-derived; header must match |
extraData | basefeeSharingPctg ‖ proposalId(6B BE) (7 bytes) | encode_extra_data(pctg,id); both inputs ∈ R1 |
mixHash / prevRandao | keccak(abi.encode(parentMixHash, number)) | re-derived; recursion bottoms out at parent header (chained to R2) |
baseFeePerGas | EIP-4396 from parent & grandparent times; genesis ⇒ INITIAL_BASE_FEE; clamped [min,1 gwei] | re-derived; enforced by consensus validation + header match |
difficulty | 0 pre-Unzen; engine value at Unzen | re-derived; header match |
stateRoot | post-execution output | Guest executes txs against parent state (MPT witness proven vs parent state_root ∈ parent header ∈ R2) and recomputes the root; must equal header. Also bound into Commitment.endStateRoot for the last block. |
transactionsRoot, receiptsRoot, logsBloom, gasUsed | post-execution outputs | produced by stateless execution; consensus/post-state validation enforces them (chained to R2 via parent state) |
ommersHash, withdrawalsRoot | empty (fixed) | re-derived constants; header match |
parentBeaconBlockRoot, requestsHash, blobGasUsed, excessBlobGas, nonce | fixed constants (fork-gated) | Post-Unzen (the report's scope) each block must have parentBeaconBlockRoot = 0x0…0, requestsHash = EmptyRequestsHash (EIP-7685), blobGasUsed = excessBlobGas = 0, and nonce = 0; pre-Unzen the beacon root / requests hash must be absent. Enforced by the driver's Unzen-aware canonical-block check (common.go:386-407) and by header/consensus validation in both provers — chained to R2 via the header / block-hash equality. |
| block hash | keccak of the header | recomputed (hash_slow), used as next block's parent & (last block) Commitment / Transition.blockHash → R1,R2 at prove |
The execution-integrity linchpin. The parent header supplies the pre-state root used to materialize the MPT
witness and to read parent anchor/checkpoint state. A forged state_root would break soundness — so raiko2
recomputes each full ancestor header's hash on deserialize and discards the host-supplied hash
(primitives/src/stateless.rs:132-136: let _host_hash = value.hash; Self::from_header(value.header)),
then requires that recomputed hash to equal the child's parent_hash. Compact (hash-only) headers are rejected on the
proposal path. Result: fixing the parent hash to R2 fixes the parent state_root too — it cannot be forged
independently. gaiko2 enforces the same via l2_state.go:32 (binds the parent header to carry.ParentBlockHash)
and replay.go:511.
anchorV4)SYSTEM TX Every derived block's first transaction is a golden-touch call to
Anchor.anchorV4(Checkpoint{blockNumber,blockHash,stateRoot}) (Anchor.sol:124). It is how L1 state is synced
into L2. The provers validate its shape and — crucially — its L1 payload.
| Element | Value | How verified → root |
|---|---|---|
| selector | anchorV4 (0x523e6854) | must prefix calldata; ABI-decoded with canonical uint48 padding |
| sender | golden touch 0x0000…B4Ec | recovered signer must equal it; execution rejects any other sender (Anchor.onlyValidSender) |
| signature | deterministic fixed-k (k∈{1,2}) | gaiko2 re-signs with the same fixed k and requires an identical tx hash (blocks block-hash malleability) |
| nonce | golden-touch nonce at parent | must equal the pre-state account nonce read from the parent state (∈ R2) |
| gas limit | exactly 1,000,000 | consensus check; matches ANCHOR_GAS_LIMIT |
| fees | maxFee = baseFee, maxPriority = 0 | checked against the derived block base fee |
checkpoint.blockNumber | the block's anchorBlockNumber | must equal the derived manifest anchor number (§6, §9) |
checkpoint.blockHash | L1 block hash at that number | matched against the l1_ancestor_headers chain whose tip = originBlockHash ∈ R1,R5 |
checkpoint.stateRoot | L1 state root at that number | same L1 ancestor-header match → R1,R5 |
Anchor L1-data is proof-enforced, not merely "node-enforced." Derivation.md says the node ensures
anchorBlockHash/anchorStateRoot reflect real L1 state. Both provers actually enforce it: they require a
contiguous chain of L1 headers (each parent-linked, hashes recomputed) whose final block equals the proposal's
originBlockHash/Number, and match every anchored checkpoint against a header in that chain. Forced-inclusion
and "stalled anchor" cases are matched instead against the parent L2 CheckpointStore value read by MPT proof. See
Observation F6.
PROVE INPUT Struct IInbox.Commitment (IInbox.sol:122-138). The prover does
not accept the commitment as trusted input — the guest reconstructs it from the per-proposal carry data and its
continuity checks (raiko2 instance.rs:130-158; gaiko2 hash.go:120).
| Field | Source (rebuilt by guest) | How verified → root |
|---|---|---|
firstProposalId | first carry's id | L1: firstProposalId ≤ lastFinalizedProposalId+1 (R3); bound in hashCommitment |
firstProposalParentBlockHash | first carry's parentBlockHash = first block's parent hash | L1: == lastFinalizedBlockHash → R2 |
lastProposalHash | last carry's proposal hash | L1: == getProposalHash(lastId) → R1 |
actualProver | constant across carries | bound in hashCommitment; drives liveness-bond credit; the proof itself fixes it (see §10) |
endBlockNumber | last carry checkpoint number | = last derived block number; ∈ hashCommitment; saved as checkpoint |
endStateRoot | last carry checkpoint state root | = last block's post-exec state root (chained to R2); ∈ hashCommitment |
transitions[] | one {proposer,timestamp,blockHash} per proposal | each block hash chains to the next carry's parent (continuity); ∈ hashCommitment |
Aggregation continuity (instance.rs:69-127, aggregate_validate.go:104): sequential
proposal ids; prev.proposalHash == next.parentProposalHash; prev.checkpoint.blockHash == next.parentBlockHash;
constant chainId, verifier, actualProver; all uint48 fields range-checked. This is what lets a
single ring-buffer entry (R1, on the last proposal) plus a single R2 value (on the first block's parent) authenticate an
entire multi-proposal batch.
L1 OUTPUTS CoreState (R2/R3) and the Checkpoint saved to
SignalService are outputs of prove(), computed from the verified commitment. They are consumed by
future derivation (the next batch's R2, and the L1→L2 checkpoint the anchor syncs) — never by the proof that produced
them. This forward-only flow is the backbone of the acyclicity argument (§12).
The exact rules that turn a manifest + parent context into block metadata. Identical in Derivation.md, the Go/Rust
driver, and both provers. Any block whose fields violate these is either rejected (consensus) or the whole source degrades to the
default manifest.
| Field | Rule | On violation |
|---|---|---|
timestamp | lower ≤ ts ≤ proposal.timestamp, where lower = max(parent.ts+1, proposal.ts − TIMESTAMP_MAX_OFFSET, SHASTA_FORK_TIME) | source → default manifest |
anchorBlockNumber | parent.anchor ≤ n ≤ origin and n ≥ origin − MAX_ANCHOR_OFFSET; a normal source must strictly advance the anchor | source → default manifest (forced sources may stall) |
gasLimit | lower ≤ g ≤ upper where upper = min(parent·(1e6+200)/1e6, 45M), lower = min(max(parent·(1e6−200)/1e6, 10M), upper); then +1,000,000 | source → default manifest |
coinbase | forced/default → proposal.proposer; normal → manifest value | — |
timestamp/difficulty/number | number=parent+1; difficulty=keccak(abi.encode(parentMixHash,number)) | — |
baseFee | EIP-4396 (parent & grandparent block times), clamped to [chainMin, 1 gwei]; genesis → 0.025 gwei | consensus reject |
| Constant | Value (non-mainnet / mainnet) |
|---|---|
| DERIVATION_SOURCE_MAX_BLOCKS (pre-Unzen / Unzen) | 192 / 768 |
| MAX_ANCHOR_OFFSET | 128 / 512 |
| TIMESTAMP_MAX_OFFSET | 1536 s (12×128) / 6144 s (12×512) |
| BLOCK_GAS_LIMIT_MAX_CHANGE / denominator | 200 / 1,000,000 (±0.02%) |
| MIN / MAX block gas limit | 10,000,000 / 45,000,000 |
| ANCHOR_GAS_LIMIT (reserved + tx gas) | 1,000,000 |
| SHASTA_PAYLOAD_VERSION / blob bytes | 0x1 / 131,072 |
| MAX_FORCED_INCLUSIONS_PER_PROPOSAL | 10 |
| MAINNET_ANCHOR_CHECK_SKIP_PROPOSAL_OFFSET | 7 (bootstrap; see F5) |
For one proposal, the guest (prove_shasta_proposal, raiko2 lib.rs:1000) verifies, in order:
blobSlice.blobHashes (R4);hash_proposal(proposal) == carry.proposalHash and carry.parentProposalHash == proposal.parentProposalHash;generated block == canonical block; chain block hashes;carry.parentBlockHash == firstBlock.parentHash and carry.checkpoint.{number,hash,stateRoot} == lastBlock.*;originBlockHash.Public output = hash_shasta_subproof_input(carry) = keccak(VERIFY_PROOF, chainId, verifier, hash_transition_input(carry)),
where the transition-input hash binds all continuity-critical fields (proposal id/hash/parent, parent block hash, prover,
transition, end checkpoint).
The aggregation guest (aggregate_shasta_zk_with_verifier, lib.rs:1058) verifies each sub-proof
(RISC0 receipt / SP1 verify_sp1_proof), requires each sub-proof's journal to equal
hash_shasta_subproof_input(carry_i), rebuilds the Commitment from the carries with the continuity checks,
and outputs:
shasta_zk_aggregation_output(image_id, hash_public_input( hash_commitment(commitment), chainId, verifier, prover ))
The Rust hash_commitment reproduces Solidity LibHashOptimized.hashCommitment word-for-word
(libhash/shasta.rs:69-106 vs LibHashOptimized.sol:32-84), and hash_public_input reproduces
LibPublicInput.hashPublicInputs. gaiko2's Go encoders (hash.go) do the same.
prove() — the closing checks// Inbox.prove (Inbox.sol:321-398) require(state.lastFinalizedBlockHash == expectedParentHash); // → R2 require(commitment.lastProposalHash == getProposalHash(lastId)); // → R1 _signalService.saveCheckpoint(endBlockNumber,endStateRoot,lastHash); _coreState.lastFinalized* = commitment.*; // → R2,R3 (for next batch) _proofVerifier.verifyProof(age, hashCommitment(commitment), proof); // → R6,R7
The verifier reproduces the same public input from hashCommitment(commitment), its own address, and
taikoChainId (all R6), and checks the ZK proof / SGX signature against it. Because the
verifier address and chain id are baked into the public input, a proof made for a different verifier or chain simply fails to
reproduce — the binding is self-enforcing.
Defence-in-depth verifier policy. Mainnet finalization runs through a composite verifier requiring two
sub-proofs. The post-Unzen ZkRequiredVerifier structurally mandates that the second is always a ZK proof
(SGX+ZK or ZK+ZK) — no TEE-only pair can finalize. See Finding F1 for the deprecated
MainnetVerifier it replaces.
Every arrow reads "is verified against". Data flows downward until it rests on the L1 bedrock (green). There is no upward arrow — the graph is a DAG grounded at L1.
Let the nodes be the data items and the directed edges be "A is verified against B". A circular dependency exists iff this graph has a cycle. We claim it is a DAG grounded at R1–R7. The argument has two parts.
A proof for batch N consumes only values that were on L1 before the proof was submitted and that do not depend on the proof's own output:
lastFinalizedBlockHash), finalized by batch N−1's proof (strictly earlier);The proof's outputs — the new lastFinalizedBlockHash, checkpoint, and CoreState — become roots for batch
N+1. Ordering nodes by (batch index, propose-before-prove) makes every edge point to a strictly earlier item.
A strict order cannot contain a cycle. Base case: activate() seeds R2 with the last Pacaya block hash
(LibInboxSetup.activate), independent of any Shasta proof.
| Candidate cycle | Why it does not close | Evidence |
|---|---|---|
| block hash ↔ state root ("hash needs root, root needs execution, execution needs hash") | A block's own state root is an output; execution consumes the parent's state root — a distinct, earlier block. No self-reference. | validation.rs:360 post-state check; parent via determine_pre_state_root |
parent header ↔ parent state root (forge a header with a fake state_root) |
Full ancestor header hashes are recomputed and the host hash discarded; the recomputed hash must equal the child parent_hash which chains to R2. Fixing the hash fixes the header bytes, incl. state_root. |
stateless.rs:132-136 (_host_hash dropped); compute_ancestor_hashes_for_child:510 |
| L1 data in L2 ↔ L1 state (anchor checkpoint verified against L2) | Anchored L1 hash/root is matched against an L1 header chain whose tip is the proposal's originBlockHash ∈ R1/R5 — an L1 value, never an L2-derived one. |
validate_l1_anchor_linkage:133-294 |
| proposal ↔ parent proposal (the hash chain) | Finite, strictly-decreasing id chain anchored at the top by the last proposal's ring-buffer entry (R1). Each link is a field already inside an authenticated struct. | instance.rs:107-110; L1 Inbox.sol:349 |
| Commitment ↔ proof ("the proof verifies the commitment; the commitment is the proof's input") | Not a data cycle: the circuit computes the commitment from R1/R2/R4-anchored inputs and commits its hash as output (R7). L1 independently re-checks lastProposalHash∈R1 and parent∈R2. The proof does not verify itself. |
Inbox.sol:346-398 |
| parent anchor number ↔ current anchor | Parent anchor number is read from L2 Anchor._blockState by MPT proof against the parent state root (∈ R2), plus an optional host hint that must equal the proven value. Current anchor is validated relative to that fixed parent value. |
verified_parent_anchor_block_number:372-400 |
Conclusion. No cycle survives. Every proposal-level and block-level datum is verified — directly or through a finite backward chain — against R1–R7, all of which are L1-managed state or a stated cryptographic assumption. The requirement "each piece of data must be verified against the data managed by the protocol contracts on L1" is satisfied, with no circular dependency.
The three implementations must agree bit-for-bit or a valid proof could fail to finalize (liveness) — or worse, a wrong block could be accepted by one but not caught by another. Verified equal:
| Element | L1 (Solidity) | raiko2 (Rust ZK) | gaiko2 (Go SGX) | driver (Go/Rust) |
|---|---|---|---|---|
hash_proposal | keccak(abi.encode) | ✓ shasta.rs:108 | ✓ guestinput_carry.go:557 | ✓ (implicit) |
hashCommitment layout | LibHashOptimized:32 | ✓ shasta.rs:69 | ✓ hash.go:146 | n/a |
hashPublicInputs | LibPublicInput:18 | ✓ shasta.rs:178 | ✓ hash.go:84 | n/a |
| derivation constants (§9) | Derivation.md | ✓ constants.rs | ✓ manifest_validate.go | ✓ manifest.go / constants.rs |
extraData = pctg‖id(6B) | spec | ✓ payload_helpers.rs:41 | ✓ :1188 | ✓ input.go:166 |
| anchor tx shape (golden touch, fixed-k, 1M gas) | Anchor.sol | ✓ lib.rs:546 | ✓ manifest_validate.go:974 | ✓ anchor.rs:102 |
| invalid-manifest → default | spec | ✓ derivation.rs | ✓ manifest_validate.go:357 | ✓ source_fetcher.go |
The core design is sound and complete (§12). The items below are security context, prover-implementation notes, and edges to watch. Severity reflects impact if the caveat conditions hold; confidence states how much was verified first-hand here.
Confidence: High — linchpin bindings (parent-header hash recompute, ancestor-chain anchoring, ring-buffer & R2 checks) read first-hand in raiko2 + L1 contracts.
Every field enumerated in §4–§8 reduces to R1–R7. The verification graph is an acyclic, L1-grounded DAG. This is the headline result and the answer to the audit's central question.
MainnetVerifier still live — accepts a zero-ZK (SGX+SGX) pairContext · Being remediatedConfidence: High (both contracts read). Repo: taiko-mono.
The currently-live mainnet verifier (MainnetVerifier, 0x7180…) accepts SGX_GETH + SGX_RETH with
no ZK proof — the exact combination behind the June-2026 forged-proof incident. Its contract header marks it DEPRECATED. The
replacement ZkRequiredVerifier (0x7284…, Proposal-0019 / Unzen) structurally requires ≥1 ZK proof per batch.
Because Inbox._proofVerifier is immutable, cutting over requires an Inbox upgrade (the mechanism Proposal-0019
uses).
Note: This concerns the strength of root R7, not the data-verification graph. Until the Unzen inbox is the active one, finalization safety rests on the SGX registry (R6) alone for TEE-only pairs. Recommend confirming the cutover has executed on the target network before relying on the ZK mandate.
Confidence: Medium-High on the structural facts (subagent code read + gaiko2's own in-repo audit docs/audits/); not line-verified first-hand in this pass. Repo: gaiko2.
gaiko2's "native" proving mode uses a hard-coded, publicly-known private key (the same golden-touch key used to sign anchors),
with instance id 0xDEADC0DE, and the /prove/shasta* HTTP endpoints are unauthenticated; the aggregate
endpoint executes no blocks. If a native/mock instance address is ever registered on-chain in the SgxVerifier, it
becomes a proof-forgery oracle for the SGX leg.
Mitigations already in place: mainnet SGX registration is gated by DCAP attestation and an owner allowlist (a mock instance cannot self-register), and — post-Unzen — the ZK mandate (F1) means an SGX forgery alone cannot finalize. Recommend: assert in deploy tooling that no native/mock instance id is ever registered on a production verifier, and require auth on any internet-exposed prove endpoint.
db.Error() for missing witness nodesConfirmed & elevated → see D1 (Critical)Confidence: High — this lead was verified first-hand in the deep-dive. Repo: gaiko2.
The medium-confidence lead recorded here has been confirmed and elevated to a Critical finding: GethRunner.Execute
computes the state root via IntermediateRoot without ever checking db.Error(), so a withheld witness node
reads as empty/0 and can finalize a wrong state root, while the sibling paths (l2_state.go, manifest_tx_filter.go)
and raiko2's sparse trie all fail closed. Full exploit chain, scope, and fix are in finding D1 (§15).
Confidence: High (constant + driver logic confirmed; raiko2 has the should_bypass_stalled_anchor_linkage analog).
For the first MAINNET_ANCHOR_CHECK_SKIP_PROPOSAL_OFFSET = 7 mainnet proposals, the parent anchor block number is
recovered from the parent block's anchorV4/anchorV3 calldata instead of from Anchor._blockState. The calldata
is part of an already-authenticated L2 block, so the value stays bound; the exception is bounded to genesis bootstrap. No action
required beyond awareness that this special path exists and should not be generalized.
Confidence: High (both provers read).
Derivation.md states the node "enforces" that anchorBlockHash/anchorStateRoot reflect L1 state,
which could read as an unproved trust assumption. In fact both provers cryptographically bind these to a contiguous L1
header chain terminating at originBlockHash (∈ R1/R5). Recommend: update the spec wording to say
"proved," to avoid a future reader treating it as out-of-protocol trust.
Confidence: High (all three impls read/consistent).
Malformed blob/manifest content does not fail the proof — the source degrades to a single anchor-only block. This is a deliberate censorship-resistance property (a bad source can't invalidate a good forced inclusion) and is identical across driver and both provers. Soundness rests on the default always being the conservative outcome (empty block, inherited metadata). Recommend: keep the differential tests that assert driver/prover agreement on every degrade trigger; a divergence here is the most likely place a real bug would hide.
Confidence: High for proof-age (L1 read); Medium for gaiko2 digest (subagent read).
(a) _proposalAge is computed in Inbox.prove and forwarded to every verifier, but no shipped verifier
consumes it — it is a forward hook for "prover-killer" handling. (b) The per-proposal signed digest is domain-separated by
VERIFY_PROOF + chainId + verifier but not by the Inbox address; cross-deployment replay
separation therefore rests on the verifier address being unique per deployment (it is, and it is bound into the public input).
Neither is a vulnerability today; both are worth a comment so a future refactor doesn't weaken them.
Confidence: High (owner setters read first-hand). Repo: taiko-mono. Raised by: automated PR review, confirmed.
The verifier trust root R6 has two layers. Immutable: the verifier contract addresses, taikoChainId, and
Inbox._proofVerifier. Governance-mutable: the trusted RISC0 image ids (Risc0Verifier.setImageIdTrusted,
onlyOwner), SP1 program keys (SP1Verifier.setProgramTrusted, onlyOwner), and the SGX instance registry + MRENCLAVE/MRSIGNER
allowlists (addInstances / deleteInstances / setMrEnclave / setMrSigner /
registerInstance). A change to these sets changes which proofs L1 accepts.
This does not create a cycle — the sets are L1 state, written by governance independently of any single proof — but it is a governance trust assumption: the soundness of R6 rests on the owner / DAO controller (and, for SGX, DCAP attestation) admitting only correct programs and enclaves. Recommend: the report, and any downstream threat model, treat R6's allowlists as governance-controlled state rather than fixed constants.
Sections 1–13 prove the design is acyclic and L1-grounded. This section reports a second, adversarial pass whose goal was the opposite: to break it. Eight bug surfaces were each swept by an independent hunter tasked to produce a concrete failing scenario, and — in a parallel workflow — every candidate was put through three independent verifiers (refute / reproduce / cross-impl), keeping only those a majority confirmed against real code. The pass found real implementation bugs. The L1 contracts and the raiko2 ZK guest held up; the defects live in the gaiko2 (TEE) prover and the Go driver, headlined by a gaiko2 witness-soundness break. Per the repository owner's direction, findings are documented in full, including exploit chains.
Verification status. The two most severe findings (D1, D2) were re-verified first-hand against the source while writing this. The remaining findings are reported by the single-shot hunters; the parallel 3-vote workflow's independent reconciliation is in progress and may adjust some Medium/Low severities. Each finding below carries an explicit confidence note.
GethRunner.Execute never checks statedb.Error()Critical (gaiko2)Repo: gaiko2 · internal/prover/replay.go:51-88 (and the Unzen twin processUnzenReplayBlock, :105-212). Class: soundness. Confidence: High — both sides read first-hand.
The bug. The authoritative state-root path builds a state.StateDB over the witness hash-DB, executes, then
returns db.IntermediateRoot(...) (line 82) — with no db.Error() or db.Commit() anywhere.
go-ethereum swallows a missing trie node into a deferred error and returns a default value (account treated as
non-existent; storage slot reads 0); IntermediateRoot never consults that deferred error — only
Commit does, and it is never called. ValidateState(block, db, res, true) returns early in stateless
mode before its own root/error check.
Exploit. A malicious prover supplies a witness that omits node B (holding victim account X
or a gating storage slot) while keeping its parent branch A, which still references hash(B). During
execution the descent to X hits the missing B → the read returns empty/0 with the error set-and-ignored.
Because X's subtree is only read, A's hash(B) reference is unchanged, so
IntermediateRoot recomputes a root that is self-consistent with a crafted header — while execution used fabricated
pre-state. Any path where "reads as 0/empty" benefits the attacker (a bypassed replay-protection / nonce / allowance /
processed[id] slot, a zeroed oracle/config, a balance-gated payout) mints or steals; the attacker's own mutations
are materialized in the root. Prove only checks result.StateRoot == block.Root() against the
attacker's own header — nothing catches it.
Why it's a forgotten check, not by design. The two sibling witness-read paths defend against exactly this:
l2_state.go:65-69 calls db.Error() right after db.GetState with a comment — "Surface that
error explicitly so an incomplete or corrupt witness cannot masquerade as a legitimately empty storage slot" — and
manifest_tx_filter.go re-checks it after every transaction. raiko2's sparse trie fails closed
(TrieWitnessError) on any unresolved node.
Scope & severity. A rollup soundness break in gaiko2's state-root producer — a release-blocker for gaiko2 as a
standalone / SGX-only / SGX+SGX prover. It is mitigated on a composite verifier that mandates a fail-closed ZK co-prover
(post-Unzen ZkRequiredVerifier): the ComposeVerifier requires both sub-proofs over the same
commitment hash, and raiko2 (fail-closed) will not produce a ZK proof of the forged commitment — so the forgery cannot finalize
there. It can finalize wherever a gaiko2 proof stands without such a co-prover.
Fix. Call db.Error() after execution (or compute the root via db.Commit, which checks it) in
both GethRunner.Execute and processUnzenReplayBlock, matching manifestWitnessStateError.
Remediation status: already fixed — gaiko2 PR #45
(merged) landed the statedb.Error() replay guard; the dedicated #46
was closed as a duplicate, and #48 refines the zk-gas / witness-error
precedence on top. This audit did not open a duplicate.
SHASTA_FORK_TIME from the block-timestamp lower boundHigh (consensus split at fork activation)Repo: taiko-client (Go driver) · driver/chain_syncer/event/derivation/source_fetcher.go:317-332. Class: liveness / consensus. Confidence: High — verified first-hand.
The bug. ComputeTimestampLowerBound computes max(parent.ts + 1, proposal.ts − TIMESTAMP_MAX_OFFSET)
— it has no SHASTA_FORK_TIME floor (its own doc comment claims "the maximum of three constraints" but implements two).
The spec (Derivation.md:223) and the other three implementations include it: Rust driver
validation.rs:141, raiko2 guest derivation.rs:435, gaiko2 guest manifest_validate.go:456.
Scenario. Shasta activates at a non-genesis timestamp T (the normal rollout). Parent (last pre-fork block)
ts = T−2; the first Shasta proposal lands at ts = T+20 (Hoodi offset 1536). Go lower bound =
max(T−1, T+20−1536) = T−1; the others = max(…, T) = T. A proposer sets the first block's timestamp to
T−1: the Go driver accepts and builds it, while the Rust driver and both provers reject it
(ts < lowerBound) and collapse the source to the default manifest. Result — Go-driver nodes build a block that
Rust-driver nodes reject and that neither prover can prove: a driver/driver partition and an unprovable chain, precisely at fork
activation.
Latent, not live. Genesis-activated chains are unaffected (parent.ts + 1 > fork_ts makes the clamp a
no-op), which is why it hasn't fired — it triggers the first time Shasta is scheduled at a future timestamp.
Fix. Add the fork-time floor to ComputeTimestampLowerBound and pass it at both call sites.
Remediation status: a fix adding the fork-time floor to the Go driver is being opened as a separate pull request citing this report (the Rust driver and both provers already have it).
Repo: gaiko2 · internal/protocol/manifest_validate.go:521-528. Class: liveness / prover split. Confidence: Medium (hunter-reported; workflow reconciliation pending).
The forced-inclusion anchor branch only checks anchor == parentAnchor and returns true; it never applies the
origin-window check (anchor < origin − MAX_ANCHOR_OFFSET) that the drivers and raiko2 apply to every block.
Scenario: a catch-up proposal [forced (1 block, user txs), normal (advances anchor)] with a stale parent anchor
A where origin − A > MAX_ANCHOR_OFFSET. Drivers + raiko2 default the forced source (drop the user txs →
empty block); gaiko2 keeps them (non-empty block), then rejects the canonical empty block on the tx-root check → the proposal is
unprovable by gaiko2 while raiko2 proves it (prover/prover and prover/driver split). The spec (Derivation.md:236)
only exempts forced inclusions from the strict-advance penalty and is silent on the window — that ambiguity produced the
split. Fix: apply the excessive-lag window to forced anchors too (and pin the spec so all four impls agree).
Repos: taiko-client + raiko2 · driver syncer.go:302-308; guest guest-common/src/lib.rs:372-400. Class: liveness (one-time). Confidence: Medium (hunter-reported).
Both drivers recover the parent anchor from the parent block's anchorV4/anchorV3 calldata for mainnet proposals
id ≤ MAINNET_ANCHOR_CHECK_SKIP_PROPOSAL_OFFSET (7), because Anchor._blockState.anchorBlockNumber is not yet
reliable during bootstrap. raiko2's guest reads the parent anchor only from that storage slot (plus a host cross-check);
gaiko2 trusts the host value. So for mainnet id ≤ 7 raiko2's storage-derived anchor can differ from the driver's
tx-derived one → the host == verified cross-check fails or yields a wrong anchor → those proposals unprovable by raiko2. A
one-time mainnet-launch determinism gap worth confirming against the migration state. Fix: give the guest the same
id ≤ 7 tx-based recovery, or guarantee the bootstrap storage matches.
Repo: taiko-mono · Inbox.sol:602-607; unused permissionlessInclusionMultiplier/permissionlessProvingDelay (:121,99); LibForcedInclusion.isOldestForcedInclusionDue (never called). Class: censorship-resistance. Confidence: Medium (hunter-reported).
Forced inclusions are the system's censorship-resistance primitive, but their only consumer — propose — is fully
gated by the permissioned _proposerChecker.checkProposer ("Permissionless proposing is temporarily disabled"). The
config carries all the scaffolding for a "proposing/proving becomes permissionless once an inclusion is too old" escape hatch
(permissionlessInclusionMultiplier, mainnet 160 ≈ 25.6 h), and isOldestForcedInclusionDue exists — but
none of it is wired into propose/prove, and checkProposer is never given the inclusion age.
If the permissioned set colludes or is unavailable, a due forced inclusion is never processed and its paid fee is locked (no
cancel/refund path in saveForcedInclusion) — the guarantee the mechanism exists to provide silently does not hold;
it rests on the social expectation of the current whitelist phase. Fix: wire the permissionless escape hatch and add a
refund path.
Repos: all impls · raiko2 manifest.rs:179, gaiko2 manifest_validate.go:387, Go utils/compress.go:69. Class: DoS / liveness. Confidence: Medium (hunter-reported).
Every implementation decompresses the manifest to completion (read_to_end / io.ReadAll) before
the block-count cap (192/768) is applied; only the compressed slice is bounded (≤ one blob). A source whose payload is
DEFLATE of a long zero-run (~1032:1 ceiling) expands ~130 KB → ~130 MB (single blob) or ~800 MB (multi-blob) in a single
allocation, OOM-ing the memory-constrained guest. It is consistent across impls (a DoS, not a state divergence) and gated by
L1 blob cost — hence Medium. Fix: a bounded reader capped at max_blocks × max_block_rlp_size.
| ID | Finding | Sev | Where | Note |
|---|---|---|---|---|
| D7 | gaiko2 BLOCKHASH fails-open for withheld ancestor headers | Low | gaiko2 replay.go:669-722 | Same fail-open class as D1, bounded to the ≤256 BLOCKHASH window; raiko2 fails closed. |
| D8 | Reverted anchor not rejected on gaiko2's raw replay path | Low | gaiko2 replay.go | Parity gap; caught in the guest-validated flow and on both raiko2 paths. |
| D9 | Non-saturating origin − first in stalled-anchor bypass | Low | gaiko2 manifest_validate.go:1445 | Underflow needs an L1 reorg regressing the origin; raiko2 saturates. Near-unreachable. |
| D10 | raiko2 hashes sub-proof proposal_id as full u64 (no uint48 guard) | Low | raiko2 libhash/shasta.rs:43 | Benign — aggregation guards uint48 in both impls, so an out-of-range id can never finalize. |
| D11 | Dead-code derivation.rs with real ABI bugs | Low | raiko2 libhash/derivation.rs | No caller / no Solidity counterpart; wrong empty-array and bool encodings if ever wired. Recommend delete. |
| D12 | Driver beacon path doesn't KZG-verify blob bytes vs commitment | Low | taiko-client blob_datasource.go:163 | Only commitment→versioned-hash is checked; a bad beacon yields an unprovable chain (local liveness). Add VerifyBlobProof. |
| D13 | Empty-blob-hash source: strictness asymmetry (raiko2 rejects inline, gaiko2 continues) | Low | raiko2 blob.rs:58 / gaiko2 blob_validate.go:49 | raiko2's inline-decode path is currently unreachable; latent divergence if ordering changes. |
| D14 | CEI order in _dequeueAndProcessForcedInclusions; saveForcedInclusion not nonReentrant | Low | taiko-mono Inbox.sol:710,431 | Not profitable — a reentrant caller only overpays its own fee; no protocol fund loss. Defense-in-depth. |
| D15 | init2 finalizes owner-supplied (id, blockHash) with no consistency check | Low | taiko-mono Inbox.sol:217-244 | Grants nothing beyond existing UUPS upgrade authority (onlyOwner + reinitializer(2)). Centralization note. |
| D16 | init3 permanently locks voided forced-inclusion fees | Low | taiko-mono Inbox.sol:253-258 | Owner-gated incident recovery; real user-fund lock with no recovery route. |
| D17 | proposalAge overstated vs the liveness "provable-since" basis | Low | taiko-mono Inbox.sol:336 | Nil impact today (all verifiers ignore age); latent if age-based tiering is ever enabled. |
| D18 | activate() can reset core state within the 2 h window | Low | taiko-mono Inbox.sol:188-201 | Owner-only, bounded to 2 h from genesis; orphaned slots cleanly overwritten. Note only. |
| D19 | SignalService.saveCheckpoint has no monotonicity / write-once guard | Low | taiko-mono SignalService.sol:174-184 | Safe today (both callers guard order); a future syncer / re-prove path could swap a fresh root for a stale one. Enforce in-function. |
| I1 | Manifest version word not fully validated (high 32 bits ignored) | Info | all impls | Identical across all three — spec-looseness, not a divergence. |
| I2 | SignalService pause freezes all inbound cross-chain verification | Info | taiko-mono SignalService.sol:121,201 | Owner or immutable pauser can halt bridge consumption. Standard centralization trade-off. |
| I3 | Anchor.l1ChainId stored/validated but never read; gaiko2 witness.accounts pinned but unused | Info | Anchor.sol:50 / gaiko2 guestinput.go:127 | Vestigial fields — confirm no off-chain consumer, else remove to avoid confusion. |
abi.encode, and public-input ordering matches Solidity — checked with executable oracles.rlp.DecodeBytes and Rust decode_exact both reject) — the divergence this pass specifically hunted is absent.| Topic | File : line |
|---|---|
| Proposal / Commitment / Transition structs | taiko-mono · packages/protocol/contracts/layer1/core/iface/IInbox.sol:52-156 |
| propose / prove / ring buffer / CoreState | …/layer1/core/impl/Inbox.sol:270-400,559-628 |
| Proposal & Commitment hashing (L1) | …/layer1/core/libs/LibHashOptimized.sol:24-84 |
| Blob slice / versioned hash capture | …/layer1/core/libs/LibBlobs.sol:21-55 |
| Verifier public input | …/layer1/verifiers/LibPublicInput.sol:18-52 |
| SGX / RISC0 / SP1 / compose verifiers | …/layer1/verifiers/{SgxVerifier,Risc0Verifier,SP1Verifier,compose/*}.sol |
| Anchor (anchorV4) & checkpoint store | …/layer2/core/Anchor.sol:124-186; …/shared/signal/SignalService.sol:174 |
| Derivation specification | …/packages/protocol/docs/Derivation.md |
| ZK guest: derive + execute + bind | raiko2 · crates/guest-common/src/lib.rs:427-1034 |
| Commitment reconstruction + continuity | raiko2 · crates/primitives-shasta/src/instance.rs:69-187 |
| Parent-header hash recompute (linchpin) | raiko2 · crates/primitives/src/stateless.rs:132-136 |
| Blob KZG verification | raiko2 · crates/primitives-shasta/src/blob.rs:30-128 |
| Hashing parity (Rust ↔ Solidity) | raiko2 · crates/protocol-shasta/src/libhash/shasta.rs:69-192 |
| TEE prove / aggregate / anchor validate | gaiko2 · internal/prover/{replay,aggregate}.go; internal/protocol/manifest_validate.go:79-1160 |
| gaiko2 hashing parity + attestation | gaiko2 · internal/protocol/hash.go; ego/* |
| Driver derivation (reference) | taiko-mono · packages/taiko-client(-rs)/… source_fetcher / payload / anchor |
How to read a claim's strength. Items about the L1 contracts and raiko2's guest were read directly at the commits above and are High confidence. Items about gaiko2 internals (F2, F3, parts of F8) combine a code-mapping pass with gaiko2's own in-repo audit and are flagged Medium; they should be confirmed line-by-line before being actioned or dismissed. Nothing in this report changes protocol code.
Cross-repository protocol design & implementation audit of Taiko Shasta — proposal & block data verification
completeness and circular-dependency analysis. Generated 2026-07-22 against taiko-mono 9ef35dd,
raiko2 cb3a5f2, gaiko2 5a7718b. This document is an engineering analysis, not a guarantee; the
listed cryptographic and hardware assumptions (R7) are out of its scope.