Recovering Starknet state diffs from Ethereum history after blobs expire — open-source PoC

Hi all,

Starknet publishes its state diffs to Ethereum as blobs, but blobs are only kept on the Ethereum p2p network for about 18 days. After that, anyone who wants to reconstruct or audit Starknet state “from L1 only” depends on someone having archived the blobs.

We (BlockX Labs) run public Ethereum history archives (Ere/era1 mirrors and a blob archive), so we tried this end to end:

  • find Starknet’s L1 state-update transactions in Ethereum execution history (Ere files),
  • load the matching blobs from our blob archive and verify each one with KZG against the transaction’s versioned hash,
  • decode them (IFFT, stateless decompression, OS state-diff parsing, alias resolution) — ports of StarkWare’s reference code,
  • cross-check the result against public Starknet RPC.

Result on mainnet data: 8/8 updates matched by KZG; 3/3 decoded updates cross-checked (the 0x2 alias contract and class updates match exactly; the few storage differences are net no-op writes, which the OS omits by design).

Code (MIT OR Apache-2.0): GitHub - blockxlabs-ai/starknet-blob-history: starknet-blob-history · GitHub
Report and limitations are in the repo.

Questions for the community:

  1. Would indexers, explorers, auditors or researchers use an independent, L1-only source of historical Starknet state diffs (dataset or API)?
  2. Which format would be most useful (per-block-range JSON, Parquet, an RPC-like endpoint)?
  3. Is anyone already doing this, so we can align rather than duplicate?

Feedback very welcome.
— Vegard Kristiansen, BlockX Labs

Hi Vegard, updates() in cli.py picks type-3 txs by to and the updateStateKzgDA selector, and the Ere reader skips the receipts. A blob tx that reverts still lands in a block with its blobs, and since updateStateKzgDA is onlyOperator, anyone can send one with copied calldata and their own blob. It fails on L1 but passes your KZG step, because a versioned hash only says that blob belongs to that tx. With --rpc the cross-check would flag the diff. A dataset offered as L1 only has nothing to flag it, so I’d read the status from the receipts in the same Ere file and drop anything that didn’t succeed.

That status also does more than your next step 3. A successful tx already passed verifyKzgProofs and the isValid(sharpFact) check on that program output, which is what ties the blob to the proven output. Checking the proofs yourself can’t show that, since a failed tx can carry valid proofs too.

The alias bootstrap may not need to trust the RPC. Contract 0x2 is ordinary state and insert_alias only writes a key that has no alias yet, so a mapping proven once holds for every earlier update. starknet_getStorageProof at the end block of a recent update, checked against the final_root you read from L1, gives you that. Error 42 on blocks too far back doesn’t matter here, any recent root works.

On format, I’d key rows by L1 update with the block range and both roots, because the data is a net diff over the range (1,500 blocks in your runs), with no record of which block or tx wrote a value. For audit work that lets you check a contract’s state at a range boundary without trusting a provider, though it won’t trace an incident inside a range.

Happy to look again once the receipt check is in.

Thanks Carlos, good catch, and agreed on all four points.

Receipts. Confirmed: the Ere reader skipped the slim-receipt entries, so a reverted copy of an updateStateKzgDA call would have passed our versioned-hash match. We’ve added the check: an update is used only if its receipt in the same Ere file has status 1, and missing or inconsistent receipts are an error rather than a default. Re-run on the Ere file from the post (mainnet-03200): all 40 core blob txs have status 1, so the published results don’t change. One limit: receipts, like bodies, are taken from the Ere file as-is; we don’t yet check them against the header’s receipts root.

Next step 3. Agreed. Status 1 already means verifyKzgProofs and isValid(sharpFact) passed, and checking the proofs ourselves can’t rule out a reverted copy, so the receipt check replaces that step. Our versioned-hash match still ties the archived blob bytes to the tx.

Aliases. Thanks, that removes the trust in getStorageAt: prove the pre-window 0x2 entries once at the end block of a recent update, against the final_root read from L1. The open questions for us are the number of entries and an endpoint that serves starknet_getStorageProof.

Format. Agreed, and that’s the layout we’re testing: one record per L1 update with the Starknet block range, initial_root/final_root, the L1 tx and its versioned hashes, in JSON and Parquet. It’s a net diff per range, so it can check state at range boundaries but can’t attribute a write to a block or tx inside one.

The change is tested and goes into the repo after review; I’ll post here when it’s in.

Update: both changes are now in the repo.

Receipt check (fe13bc7): an update is used only if its slim receipt in the same Ere file has status 1; missing or inconsistent receipts are an error. On mainnet-03200 all 40 core blob txs have status 1, so the published results are unchanged.

Sample dataset (2d317e8): samples/sample-03200 covers Starknet blocks 16523852–16528351 (3 L1 updates, 60,790 storage rows), as one gzip JSON file per update range plus Parquet tables (updates, storage_diffs, contract_updates, declared_classes), with a manifest of SHA-256s for the input file, the code and every output file. Format and how to reproduce it: docs/SAMPLES.md.

Main limitation: 89 % of the contract addresses in the sample are still aliases, because they were allocated before our blob archive starts. They are left null and marked unresolved rather than guessed. The storage-proof bootstrap you suggested is the intended fix; it isn’t done yet. An offline comparison with cached public state updates found no discrepancies in the fields it checks (resolved storage values, nonces, class hashes), which is a consistency check, not a proof.

Comments on the format are welcome.

Before you build the storage-proof bootstrap, I need to correct my alias point. I said any recent root works, and on public endpoints it doesn’t. ZAN’s endpoint (api.zan.top/public/starknet-mainnet) served starknet_getStorageProof for 0x2 at 80 blocks behind the head and gave error 42 at 150, while the last block accepted on L1 was 5,453 behind when I checked. So an update’s end block is already out of range there by the time L1 has it, and dRPC’s public endpoint doesn’t offer the method at all. You’d need a node that keeps older tries.

The count is big too. Key 0 of 0x2 read 0x463fea at block 1,362,042 (1 May 2025), about 4.6 million pairs.

Blobs look like a better way to close the 89%. The counter first goes non-zero at block 1,256,350 on 24 March 2025, so the gap is roughly five weeks of updates before your archive starts. Blobscan still serves Starknet blobs from that stretch, I pulled one from 26 March and one from 20 April, and your KZG check against the versioned hash means you don’t have to trust the source. Keys in 0x2 are literal in the diffs, so replaying those updates gives you every pair, and the key 0 value minus 128 tells you none are missing.

Thanks Carlos, that’s an important correction. We hadn’t measured proof depth on public endpoints. At 80 blocks against an L1 lag of thousands they can’t serve it, so the proof route would need our own node with older tries.

The blob route fits what we already have. Our blob archive starts at beacon slot 11,661,189 (9 May 2025), so the gap runs from 24 March to 9 May, about six and a half weeks, and the pre-window count will be somewhat above 0x463fea − 128. Our Ere mirror covers that stretch, so the L1 transactions and their receipt status come from our own files; only the blob bytes would come from Blobscan. Spot check: the first blob of tx 0xa4cdf084…91b3 (L1 block 22,114,429, 24 March 2025), fetched from Blobscan’s IPFS copy, matches its versioned hash 0x0134264f…d7c7 under KZG.

So the bootstrap becomes: replay every successful update from the first non-zero counter, collect the 0x2 pairs (our decoder already treats keys of 0x2 as literal), and require key 0 − 128 to equal the number of pairs collected. A shortfall means a missing blob and is reported as a gap, not filled from RPC.