Base and other OP-Stack chains expose three block stages: unsafe (sequencer-published, replaceable), safe (derived from L1 batch data, still reorgable if L1 reorgs), and finalized (L1-finalized, treated as irreversible). Unlike Ethereum, the unsafe head can be replaced wholesale by a different L2 block that a new L1 batch derives, so parent-hash depth counting alone under-reports exposure. The correct rollback boundary is the highest L2 block whose L1 origin is still canonical on L1, which may be far behind the L2 height. This article gives a derivation-aware indexer design with an unsafe window, an L1-origin watermark, an undo log, idempotent upserts, and a post-rollback assertion pass. It also provides a results table for measuring your own endpoint and a troubleshooting section for provider tag gaps and in-flight backfills.
The three-stage OP-Stack model and what each stage guarantees
The OP Stack specification describes how L2 blocks are derived from L1 batch data, and the Optimism documentation describes the progression from unsafe to safe to finalized. The unsafe head is produced by the sequencer and is not yet derived from L1, so it is replaceable. Safe blocks are derived from L1 batch data but remain reorgable if L1 itself reorgs. Finalized blocks are L1-finalized and are treated as irreversible.
Only the mechanism is sourced here. The exact behavior of the safe and finalized block tags varies by provider, so you must measure it against your own endpoint. The Ethereum JSON-RPC specification defines eth_getBlockByNumber with the safe and finalized tags and eth_blockNumber as the read path an indexer uses to detect derivation progress.
For an indexer, the practical contract is: write unsafe data only inside a bounded window, treat safe as a stronger but not absolute guarantee, and treat finalized as the only stage suitable for irreversible side effects. If you need a refresher on how the tags read, see Base OP-Stack finality: safe and finalized block tags.
Both stage definitions come from primary sources rather than from this article: the OP Stack Derivation specification defines how L2 blocks are derived from L1 batch data, and the Optimism transaction finality documentation defines the unsafe, safe and finalized progression. Read them together, because the specification explains the mechanism while the finality page explains the labels you will actually query.
- Unsafe: sequencer-published, not yet L1-derived, replaceable.
- Safe: derived from L1 batch data, still reorgable if L1 reorgs.
- Finalized: L1-finalized, treated as irreversible.
- Tag behavior varies by provider; measure before relying on it.
Why OP-Stack reorgs differ from Ethereum reorgs
On Ethereum, a reorg is a fork-choice event: a competing chain of blocks replaces the canonical chain at some depth. On an OP-Stack chain, the unsafe head is produced by the sequencer, so a sequencer restart, an invalid block, or a stalled sequencer can cause the derivation pipeline to replace a run of unsafe blocks rather than extend them. Public coverage of the Base sequencer stall illustrates how a sequencer failure can produce a reorg rather than a gap.
This means normal parent-hash depth counting under-reports exposure. A detector that only compares parent hashes at height N will see a mismatch, but it will not know whether the replacement was a shallow fork or a wholesale derivation-driven replacement of a longer run. The OP Stack specification is authoritative for why the unsafe head is not yet L1-derived and how a sequencer failure produces a reorg rather than a gap.
The practical consequence is that your rollback boundary should not be derived from L2 depth alone. It should be derived from the L1 origin of each L2 block, which is the subject of the next section.
- Ethereum reorgs are fork-choice events; OP-Stack reorgs can be derivation-driven replacements.
- Sequencer restart, invalid block, or stall can replace a run of unsafe blocks.
- Parent-hash depth counting alone under-reports exposure.
- The OP Stack specification is authoritative for derivation semantics.
The L1 origin window and the canonical-L1 watermark
Each L2 block is associated with an L1 origin block. The correct rollback boundary is the highest L2 block whose L1 origin is still canonical on L1. This boundary can be far behind the L2 height, especially when the unsafe head has advanced faster than L1 derivation.
To maintain a canonical-L1 watermark, read the L1 origin per L2 block and compare it against the canonical L1 chain. If the L1 origin is no longer canonical, every L2 block derived from it must be considered suspect. This is the derivation-aware equivalent of a fork point.
The read path uses eth_getBlockByNumber on the L2 endpoint to fetch the block and its L1 origin metadata, and on the L1 endpoint to confirm canonicality. The Ethereum JSON-RPC specification is authoritative for these methods. For a generic detector you can adapt, see Ethereum block reorg detection and confirmation depth.
- Rollback boundary = highest L2 block whose L1 origin is still canonical on L1.
- The boundary can be far behind the L2 height.
- Maintain a canonical-L1 watermark by comparing L1 origins against canonical L1.
- Use
eth_getBlockByNumberon both L2 and L1 to confirm.
Choosing a write policy: unsafe-with-undo, safe-only, or shadow table
There are three practical write policies. Apply-unsafe-with-undo-log writes unsafe blocks immediately but records an undo log so a rollback can delete or supersede them. Apply-safe-only waits for the safe tag, trading latency for lower risk. Apply-unsafe-with-a-shadow-table writes unsafe data to a shadow table and promotes it to the main table only after it becomes safe.
The latency/risk trade is straightforward: unsafe-with-undo gives the lowest latency and the highest rollback complexity; safe-only gives the highest latency and the simplest correctness story; shadow-table sits in between. A depth-bounded unsafe window is the recommended compromise: write unsafe data only within a fixed number of blocks behind the unsafe head, and treat anything older as safe-only.
The window size should be measured, not assumed. Use the results table later in this article to record observed unsafe depth and deepest rollback against your own endpoint.
- Unsafe-with-undo: lowest latency, highest rollback complexity.
- Safe-only: highest latency, simplest correctness.
- Shadow table: middle ground with promotion step.
- Recommended: depth-bounded unsafe window, measured against your endpoint.
The rollback procedure: detect, confirm, fork point, delete, re-apply
Detection starts with a parent-hash or hash mismatch at a height you already wrote. Confirm the new canonical head by reading the current block at that height. Find the fork point by walking back until the stored hash matches the canonical hash. Delete or supersede all rows written above the fork point, then re-apply forward.
Implement re-application as idempotent upserts keyed on a canonical block identity (for example, block hash plus log index). This ensures re-application cannot double-count. The JSON-RPC 2.0 specification is authoritative for the error-object envelope returned when a requested block is no longer canonical, which is a useful signal during detection.
For a broader reconciliation pattern, see Block-by-block EVM indexer reconciliation.
- Detect via parent-hash or hash mismatch at a written height.
- Confirm the new canonical head.
- Find the fork point by walking back to a matching hash.
- Delete or supersede rows above the fork point.
- Re-apply forward with idempotent upserts keyed on canonical identity.
Proving the rollback worked: reconciliation and rollback counters
A rollback is only observable if you can prove it. Add a reconciliation query that asserts every indexed row's block hash is still canonical. Add a counter of rolled-back rows per fork event so a rollback is observable rather than inferred.
The assertion pass should run after every rollback and on a schedule. If the assertion fails, you have silent corruption, not a clean rollback. This is the difference between a rollback and silent corruption, and it is the operational contract the spec pages do not give you.
Keep the counter per fork event so you can correlate rollback size with the L1 origin window and with provider tag behavior.
- Assert every indexed row's block hash is still canonical.
- Count rolled-back rows per fork event.
- Run the assertion after every rollback and on a schedule.
- A failed assertion means silent corruption, not a clean rollback.
Runnable Node.js indexer with unsafe window, L1 watermark, and undo log
The following example uses a minimal in-memory store to show the shape of the logic. Replace the store with your database and the RPC calls with your provider. It reads the unsafe head, maintains an L1-origin watermark, detects a fork by hash mismatch, and re-applies forward with idempotent upserts.
The code is intentionally small so you can adapt it. It does not include retry logic or rate limiting; add those for production. For endpoint setup, see Base RPC URL, chain ID and endpoint setup (RPC Assistant).
const { JsonRpcProvider } = require('ethers');
const L2_URL = process.env.L2_RPC_URL;
const L1_URL = process.env.L1_RPC_URL;
const UNSAFE_WINDOW = 64;
const l2 = new JsonRpcProvider(L2_URL);
const l1 = new JsonRpcProvider(L1_URL);
// In-memory store: height -> { hash, l1Origin, rows: [] }
const store = new Map();
const undoLog = [];
let rolledBackRows = 0;
async function getL2Block(n) {
const b = await l2.send('eth_getBlockByNumber', ['0x' + n.toString(16), false]);
if (!b) return null;
// L1 origin is provider-specific; read from block metadata if available.
const l1Origin = b.l1BlockNumber ? parseInt(b.l1BlockNumber, 16) : null;
return { number: n, hash: b.hash, parentHash: b.parentHash, l1Origin };
}
async function isL1Canonical(l1Number, l1Hash) {
if (l1Number == null) return true;
const b = await l1.send('eth_getBlockByNumber', ['0x' + l1Number.toString(16), false]);
return b && b.hash === l1Hash;
}
async function upsertRows(block) {
// Idempotent upsert keyed on block hash + row index.
const rows = store.get(block.number)?.rows || [];
store.set(block.number, { hash: block.hash, l1Origin: block.l1Origin, rows });
}
async function detectAndRollback(headNumber) {
for (let n = headNumber; n >= 0; n--) {
const stored = store.get(n);
if (!stored) continue;
const canonical = await getL2Block(n);
if (!canonical || canonical.hash !== stored.hash) {
// Fork point found at n+1; roll back everything above n.
for (let m = headNumber; m > n; m--) {
const s = store.get(m);
if (s) {
rolledBackRows += s.rows.length;
undoLog.push({ height: m, hash: s.hash, rows: s.rows });
store.delete(m);
}
}
return n + 1;
}
}
return 0;
}
async function tick() {
const head = await l2.getBlockNumber();
const start = Math.max(0, head - UNSAFE_WINDOW);
for (let n = start; n <= head; n++) {
const b = await getL2Block(n);
if (!b) continue;
if (b.l1Origin != null) {
const ok = await isL1Canonical(b.l1Origin, null);
if (!ok) continue; // skip until L1 origin is canonical
}
await upsertRows(b);
}
const forkPoint = await detectAndRollback(head);
if (forkPoint > 0) {
console.log('Rolled back to', forkPoint, 'rows removed:', rolledBackRows);
}
}
setInterval(tick, 5000);Post-rollback assertion pass and canonicality check
After a rollback, run an assertion pass that reads every stored height and confirms the stored hash still matches the canonical hash. This is the proof that the rollback worked. The following example is a compact assertion pass you can run on a schedule.
If the assertion fails, do not re-apply blindly. Investigate whether the provider returned a stale block or whether your L1 origin watermark is wrong. For a related read path, see Base node sync status over the Engine API.
async function assertCanonical() {
let checked = 0;
let mismatches = 0;
for (const [height, stored] of store.entries()) {
const canonical = await getL2Block(height);
checked++;
if (!canonical || canonical.hash !== stored.hash) {
mismatches++;
console.error('Mismatch at', height, 'stored', stored.hash, 'canonical', canonical && canonical.hash);
}
}
console.log('Assertion pass: checked', checked, 'mismatches', mismatches);
return mismatches === 0;
}
// Run after every rollback and on a schedule.
setInterval(assertCanonical, 60000);Results table for measuring your own endpoint
Use the following table to record measurements against your own endpoint. Do not rely on published numbers; measure. Run the indexer for a fixed window and fill in the columns.
The columns capture observed unsafe depth, fork events per window, deepest rollback, and L1 origin lag. These are the four numbers that determine your unsafe window size and your rollback strategy.
- Observed unsafe depth: unsafe head minus safe head, sampled over the window.
- Fork events per window: count of detected hash mismatches.
- Deepest rollback: maximum number of blocks rolled back in a single event.
- L1 origin lag: L2 head minus the highest L2 block whose L1 origin is canonical.
Failure modes and troubleshooting
A provider whose safe/finalized tags are not populated is a common failure mode; the op-reth issue 'op-reth is not informing safe and finalized blocks' is a public example. If the tags are missing, fall back to L1 origin tracking and measure the gap yourself.
A rollback that arrives while a backfill is in flight can cause double-counting if the backfill writes rows above the fork point. Pause the backfill during a rollback, or make the backfill idempotent. A generic parent-hash detector that misses a derivation-driven replacement will under-report; add the L1 origin check. Reorgs whose depth exceeds the retained window require a full re-sync from the last finalized block.
For provider-specific behavior, treat it as documented / varies by provider and verify against your endpoint. For Base network context, see Base network.
- Missing safe/finalized tags: fall back to L1 origin tracking.
- In-flight backfill during rollback: pause or make idempotent.
- Generic parent-hash detector: add L1 origin check.
- Depth exceeds retained window: re-sync from last finalized block.
Limitations, tradeoffs, and separating finality from ingestion
The L1 origin check adds extra L1 read cost per block. Safe-only writes add a latency penalty. Both are real costs, and neither is free. The tradeoff is correctness against latency and cost.
Finality should be a separate write path from ingestion. Ingestion writes unsafe and safe data with an undo log; finality promotes rows to an irreversible state. Mixing the two makes rollback logic harder to reason about.
For cost context on L1 reads, see OP-Stack L1 fee calculation and transaction cost.
- L1 origin check adds L1 read cost.
- Safe-only writes add latency.
- Keep finality as a separate write path from ingestion.
- Mixing ingestion and finality complicates rollback reasoning.