TAIFOONWhat a V5 proof is
Source a blob with GET /api/v5/verifiable/:chain. Check it with POST /v5/proof/verify, which now walks the same hashes the contract walks and tells you which layer failed. Verify on-chain against the verifier deployed on Base mainnet,0x2D475a53eAA8F9AC78B69bD2429275a622C644f8, whose own eth_call has accepted a real Base receipt and a real Arc receipt.
| Layer | What it binds |
|---|---|
| L1 superroot | The 32-byte root over every indexed chain in this batch. |
| L2 chainHeader | The target chain’s tip: chainId, blockNumber, blockHash, miniMmrRoot, twigCount, stateRoot. |
| L3 superrootProof | A Merkle path from keccak(chainId‖blockNumber‖blockHash‖miniMmrRoot‖twigCount) up to L1. This is the layer that binds the block — the leaf contains blockHash. |
| L4 blockProof | Served EMPTY, on purpose. See “L4 is skipped” below. |
| L5 event | The event. In the strict path it is decoded from a receipt proven on-chain, not supplied. |
| L6 finality | A finality commitment per chain type: OP dispute game for Base, INSTANT for Arc, ETH checkpoint for L1. |
Sourcing a proof
The verifiable blob is the one whose target header is the chain’s current snapshot-reference block — the block the superroot actually committed to. Blobs for other blocks share L1 and L3 but cannot be bound on-chain by the single-header verifier; the response says so in verifiableBlock.
curl https://api.taifoon.dev/api/v5/verifiable/8453
{
"superroot": { "superrootHash": "0x3eb685…6c5a93", "batchId": 1789557157, "chainCount": 61, "totalTwigs": 6110602 },
"chainHeaders": [{ "chainId": 8453, "chainType": 0, "blockNumber": 51383901, "blockHash": "0x47a02f…", "miniMmrRoot": "0xd32a46…", "twigCount": 5589383, "stateRoot": "0x346dcd…" }],
"superrootProof": { "targetChainIndex": 4, "siblings": [ …6 × bytes32 ] },
"blockProof": { "blockIndex": 1629, "siblings": [], "l4Status": "skipped: block is bound by the L3 chain leaf …" },
"event": { "chainType": 0, "encoded": "0x" },
"finality": { "finalityType": 12, "finalizedBlockNumber": 51383901, "finalizedBlockHash": "0x47a02f…", "proof": "0x…" },
"verifiableBlock": 51383901
}For a specific transaction’s event, ask for the block-level blob with tx_index and log_index (the index within the receipt, not the block-level logIndex):
curl "https://api.taifoon.dev/api/v5/proof/blob/8453/51383792?tx_index=163&log_index=1"
| Route | Returns |
|---|---|
| GET /api/v5/verifiable/:chain | The blob at the snapshot-reference block — the one that verifies on-chain end to end. |
| GET /api/v5/proof/blob/:chain/:block[?tx_index&log_index] | Blob for a block; with tx_index the L5 event carries the real receipt data. |
| GET /v5/proof/tx/:chain/:txHash | Flat inclusion proof. Not self-verifying — it omits the chain leaf. Prefer the blob. |
| POST /v5/proof/verify | Offline check: walks L3 (and L4 if served) with the contract’s hashing, checks the L6 invariant, reports per layer. |
| GET /api/v5/verify/onchain/:chain | eth_call the deployed verifier with the ref-block blob; reports verifier_has_code and executable, not just a boolean. |
| GET /api/spinner/superroot | The latest superroot: root hash, batch id, event count, chains included. |
Checking a proof offline
The verify route used to return a structural yes/no while echoing roots it had never computed. It now performs the walk:
curl -X POST https://api.taifoon.dev/v5/proof/verify \
-H 'Content-Type: application/json' \
-d '{"chain_id": 8453, "block_number": 51383901, "siblings": [], "batch_id": 0}'{
"valid": true,
"computed_root": "0x3eb685937c764b2da18a01d7a82f5b4b1dc0c01077d95baa44b6d7747f6c5a93",
"expected_root": "0x3eb685937c764b2da18a01d7a82f5b4b1dc0c01077d95baa44b6d7747f6c5a93",
"match_": true,
"checks": { "l3_superroot": true, "l4_block": null, "l6_finality_structural": true },
"block_number": 51383901,
"l4_status": "skipped: block is bound by the L3 chain leaf (commits to blockHash) …"
}The rule the walk uses is the contract’s: keccak(left‖right), left/right chosen by the index bit at each level, starting from the chain leaf keccak(chainId‖blockNumber‖blockHash‖miniMmrRoot‖twigCount). Anyone can reproduce it with no Taifoon code.
Verifying on-chain
TaifoonV5ProofVerifier at 0x2D475a53eAA8F9AC78B69bD2429275a622C644f8 (build with the superroot-anchor gate on both entry points; TUO points here). The first deployment, 0xBf4283b791d5a5541F01BC93ef5DD9B9dC8f334c, predates the gate, remains on-chain and is no longer referenced. Two entry points:| Function | Proves |
|---|---|
| verify(V5ProofBlob) | L1–L4 and L6. L5 is self-consistency only: eventHash == keccak(topics, data) over the supplied fields. |
| verifyWithReceipt(V5ProofBlob, ReceiptInclusion) | Everything above, plus L5 for real: the receipt is proven by Merkle-Patricia against the receiptsRoot of a header whose keccak equals the L3-bound blockHash, and the event is decoded from that receipt. The claimed event, emitter and receiptsRoot must match what the receipt yields. |
ReceiptInclusion is what you already have if you can call eth_getBlockReceipts:
struct ReceiptInclusion {
bytes headerRlp; // full RLP header; keccak256 must equal the target blockHash
bytes key; // rlp(txIndex)
bytes receiptRlp; // the receipt as stored, EIP-2718 type byte included
bytes[] nodes; // Merkle-Patricia nodes, root first
uint256 logIndex; // index within the receipt
}Executed through TaifoonUniversalOperatorV5 (proxy 0x785F4701C681dCBa8762FcfaF18db4048215c0ca) with executeVerifiedCallV5Strict(blob, ri, vendor, calldata), a vendor is called only after the receipt is proven on-chain, and the same event cannot be executed twice.
Verified on Base by the contract’s own eth_call on 2026-09-16 (both deployments): the live ref-block blob (verify), a real Across V3 deposit receipt from Base block 51383792 (verifyWithReceipt), and a real receipt from a finalized Arc block 21151343 (verifyWithReceipt, finality INSTANT).
What to know before you rely on it
L1 anchoring exists but is not yet switched on. The verifier carries upstream’s superroot-anchor gate on both verify and verifyWithReceipt, but superrootAnchor is unset because no SuperrootAnchor contract exists on Base yet — so blob.superroot.superrootHash is still taken from the caller. What verifyWithReceipt proves on-chain today is that the receipt is in the receipts trie of a header whose hash the caller asserted — real Merkle-Patricia inclusion, with the header’s own authenticity coming from the oracle (Surge’s TaifoonHeaderOracle) until the anchor is set. Setting it is one owner transaction, no redeploy.
L4 is skipped, deliberately. The chain’s miniMmrRoot is an MMR over events; a block-level path cannot fold to it. The contract skips L4 when siblings is empty and the block is bound through the L3 leaf instead, which contains blockHash. A served path that cannot verify was worse than none, so it is no longer served.
Only the reference block verifies end to end on-chain. The single-header verifier binds one header per blob. For a historical transaction, prove its receipt against its own header with verifyWithReceipt; the L3 binding then covers that header only if it was the reference block at proof time.
OP-stack receipts have extra fields. Type-0x7e deposit receipts (tx 0 of every Base block) carry depositNonce and depositReceiptVersion. Encode them or your receipts root will never match the header.
Finality types are honest about what they can check. OP_DISPUTE_GAME requires a resolved game; INSTANT (Arc, Monad, Sei) accepts any non-empty commitment because the chain’s own consensus is the finality. The trust statement for Arc is therefore “≥ 2/3 of Arc’s validators did not equivocate”, not “no trust”.
The devnet verifier is inert. Chain 36927 predates Shanghai; the V5 bytecode there fails with invalid opcode: PUSH0 on every call. Use Base.