0
Circular dependencies found
7
L1 trust roots (bedrock)
70+
Distinct data fields traced
3
Independent impls in lock-step
1
Critical bug found (§15)

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.

## Contents

## 1 · Scope & methodology

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.

#### Method

- Enumerate every field of every struct on the propose and prove paths (contracts are the source of truth).
- For each field, identify its origin, how the driver computes it, and — the core question — what it is verified against inside the prover, terminating at an L1-managed value.
- Model the "verified-against" relation as a directed graph and prove it is acyclic and grounded at L1.
- Claims about `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`.

## 2 · The seven L1 trust roots

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 hash `CoreState.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.

## 3 · Propose → derive → prove → finalize

    ┌─ 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.

## 4 · Data inventory — Proposal & DerivationSource

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.

## 5 · Data inventory — Manifest (decoded from blobs)

DERIVED FROM DA The blob bytes decode into a `ProposalManifest` of `DerivationSourceManifest`s, each holding `BlockManifest`s. 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.

## 6 · Data inventory — derived L2 block & header

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`.

## 7 · Data inventory — anchor transaction (`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.

## 8 · Data inventory — Commitment / Transition / CoreState / Checkpoint

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).

## 9 · Block-derivation rules

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

#### Governing constants (identical across all impls)

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)

## 10 · The two-layer proof & its L1 binding

### Layer A — per-proposal sub-proof

For one proposal, the guest (`prove_shasta_proposal`, `raiko2 lib.rs:1000`) verifies, in order:

- blob KZG → versioned hash == `blobSlice.blobHashes` (R4);
- `hash_proposal(proposal) == carry.proposalHash` and `carry.parentProposalHash == proposal.parentProposalHash`;
- re-derive blocks (§9) from proposal + blobs + parent context (parent anchor/checkpoint read by MPT vs parent state root);
- stateless-execute each block; `generated block == canonical block`; chain block hashes;
- bind `carry.parentBlockHash == firstBlock.parentHash` and `carry.checkpoint.{number,hash,stateRoot} == lastBlock.*`;
- match every anchor checkpoint to the L1 ancestor-header chain ending at `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).

### Layer B — aggregation → L1 commitment

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.

### L1 `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.

## 11 · Verification-dependency graph

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.

Proposal / DA data Derived block / execution Prover check (guest) L1 trust root (bedrock)
Reduction of proposal & block data to the seven L1 roots. Every path terminates in the green bedrock; no arrow points back up.

## 12 · Circular-dependency analysis

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.

### 12.1 · Temporal grounding (the batch chain is backward-only)

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:

- proposal data → R1/R4/R5, written at propose time (strictly earlier than prove);
- the batch's parent state → R2 (`lastFinalizedBlockHash`), finalized by batch N−1's proof (strictly earlier);
- verifier config → R6, set by governance (independent of any single proof).

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.

### 12.2 · Each candidate cycle is explicitly broken

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.

## 13 · Cross-repo consistency matrix

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

## 14 · Findings & observations

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.

F0 Complete data coverage, no circular dependency Positive

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.

F1 Deprecated `MainnetVerifier` still live — accepts a zero-ZK (SGX+SGX) pair Context · Being remediated

Confidence: 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.

F2 gaiko2 native mode ships a published key = golden-touch key; unauthenticated prove endpoints High (config-dependent)

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.

F3 gaiko2 block-replay path does not check `db.Error()` for missing witness nodes Confirmed & 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).

F5 Mainnet bootstrap: parent anchor read from the parent's anchor calldata (proposals id ≤ 7)Low (bounded)

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.

F6 Anchor L1-data is stronger than the spec implies — it is proof-enforced Positive (clarification)

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.

F7"Degrade to default manifest" accepts adversarial bytes then neutralizes them Low (by design)

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.

F8 Proof-age plumbed to verifiers but unused; sub-proof digest has no Inbox/nonce domain Low (observation)

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.

F9 R6 is not uniformly immutable — trusted sets are governance-mutable Low (trust-assumption clarification)

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.

## 15 · Adversarial deep-dive — concrete bug hunt

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.

1
Critical (gaiko2)
1
High (driver)
4
Medium
13
Low / informational

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.

D1 gaiko2 executes over unauthenticated pre-state — `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.

D2 Go driver omits `SHASTA_FORK_TIME` from the block-timestamp lower bound High (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).

D3 gaiko2 skips the anchor excessive-lag window for forced-inclusion sources Medium

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).

D4 Mainnet bootstrap (proposal id ≤ 7): parent-anchor source diverges between driver and raiko2 Medium

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.

D5 L1 forced-inclusion censorship guarantee is unenforced on-chain; escape-hatch config is dead code Medium

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.

D6 Unbounded zlib output — manifest decompression-bomb DoS of the zkVM guest Medium

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`.

### Lower-severity & informational

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.

### Verified clean under adversarial tracing

- Hash / ABI parity — the forgery surface: alloy (raiko2) and go-ethereum (gaiko2) encoders produce bit-identical hashes on the exact structs (incl. 0/1/N blob-hash and multi-source edges), the hand-laid commitment buffer equals canonical `abi.encode`, and public-input ordering matches Solidity — checked with executable oracles.
- Blob KZG binding & decode — versioned-hash formula identical; provers derive from exactly the KZG-verified bytes; blob/commitment/proof/hash counts enforced; and the trailing-byte RLP handling agrees (Go `rlp.DecodeBytes` and Rust `decode_exact` both reject) — the divergence this pass specifically hunted is absent.
- Aggregation & proof-carry continuity — every carry field is bound into a verified sub-proof journal; ids sequence, proposal-hash and block-hash chains, and chain-id/verifier/prover equality all enforced on both prover paths.
- L1 Inbox — prove bounds/offset, ring-buffer capacity & stale-hash safety, parent-hash anchoring, liveness-bond victim/split, fee accounting, and reinitializer versioning all verified correct.
- Anchor + SignalService — the 255-entry ancestors ring buffer is correct across bootstrap and the 256-block boundary; the signal merkle proof is not forgeable (account/slot/root all bound); checkpoint gating holds.
- raiko2 stateless path — fail-closed on every missing-witness-node path (sparse trie aborts), parent-header hash recomputed, post-state-root checked on every execution path.

## 16 · Key references

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.