Crypto OS
Technical Crypto OSStage 3 · Full-stack Onchain Application

T17 · Indexers

What happens to your data after a chain reorg?

Capabilities practiced
BuilderOnchain Literacy
Hands on
Write a minimal indexer that can rerun from any block height and produce exactly the same results.
AI Lab
Have AI write reorganization rollback logic. Construct fork data yourself to check whether anything remains undeleted.

A Real-World Problem

Your indexer has run for two weeks without a red dashboard indicator: no crashes, errors, missing blocks, or latency above three seconds.

One morning a user reports receiving a “Received 500” notification last night, but the funds are not in their wallet.

You find the database row. It has a transaction hash, block number, amount, and timestamp. You paste its transaction hash into a block explorer:

“Transaction not found.”

You check the block number. A block exists at that height, but its contents differ entirely from the block in your database. It has no such transaction.

The indexer neither crashed nor skipped a block. It faithfully recorded a history that later ceased to exist and notified a user based on it.

T1 warned that this can happen. T16 had you store block_hash. Now put it to work.

The problem is sharp: the chain’s recent history can be rewritten, but your database does not rewrite itself. What connects the two safely?

Thought Experiment

A noticeboard stands across a river. You copy it through binoculars. Its rule: the newest notices can be torn down and replaced at any time, while older notices underneath stay fixed.

There are three ways to copy it.

First: remember only “which notice did I reach?” Your notebook says “copied through 1000”; next time, start at 1001.

If notices 996–1000 were replaced, you never know. Five notices that never existed remain among thousands of real ones in your book, with no marker to identify them.

Second: record each notice’s fingerprint and the fingerprint it claims for the previous notice. Your notebook contains a linked chain. Before copying notice 1001, compare its claimed predecessor with your fingerprint for 1000.

A mismatch means history changed. You can determine that from your own side of the river immediately, without asking anyone’s opinion.

Third: copy only notices buried far below the newest ones. You will rarely be wrong, but you will always lag reality.

The second approach hides another question: you know 1001 fails to connect, but not how many notices were replaced. Perhaps only 1000, perhaps everything after 996.

The solution is to compare backward: check notebook 1000 against the current noticeboard 1000, then 999, then 998, until the first match. That matching notice is the fork point; everything after it is dirty.

This thought experiment is the chapter’s full skeleton. The rest turns it into reliable code.

Your Decision

Choose a reorg strategy for your indexer.

What Happens

Compare the strategies:

LatencyRequires reorg detection?Recovery costComplexity
Deep blocks onlyHighMostly noNoneLowest
Head plus full rebuildLowYesVery highMedium
Head plus fork-point rollbackLowYesLowHigh
Two-track confirmationLowestYesLowHighest

The second column matters: all except the first depend on detecting reorgs. Without detection, even elegant rollback code never runs. That is why T16 stored block hashes.

First conclusion:

A reorg is normal chain behavior, not an exceptional outage. Make it an ordinary indexer code path.

Second, and more important for the opening complaint:

The hard part of a reorg is not deleting data. It is undoing effects that have already escaped your database.

Notifications, emails, and payments have no delete statement. Reorg handling has two parts: inside the database, precisely delete and recompute; outside it, the only defense is not acting before blocks are deep enough.

The two parts use different mechanisms. Mixing them produced the opening notification.

Build a Model

The Main Loop

Start with readable pseudocode for the whole indexer:

loop:
  head = rpc.blockNumber()
  cp   = db.readCheckpoint(chainId)          # Height + block hash

  if not isStillOnChain(cp):                 # 1. Before advancing, check that our position is still canonical
      rollbackTo(findForkPoint(cp))          #    One transaction
      continue

  from = cp.number + 1                       # 2. Range for this pass
  to   = min(cp.number + batchSize, head)
  if from > to: sleep(pollInterval); continue

  for n in from..to:                         # 3. One transaction per block
      block = rpc.getBlockHeader(n)
      if block.parentHash != lastHash: break # Head changed during fetch; recheck on next pass
      logs = rpc.getLogs(n, n, filters)
      db.transaction:
          insertBlock(block)
          insertLogs(logs)                   # Idempotent through primary key
          enqueueOutbox(logs, n)             # Store side effects; do not send them yet
          writeCheckpoint(n, block.hash)     # In the same transaction as data
      lastHash = block.hash

Five decisions are embedded here. Consider them in turn.

Detect Reorgs in Two Places

First, while advancing: the next block’s parentHash must equal the checkpoint’s last_block_hash. A mismatch signals a reorg.

This catches most cases but has a blind spot: a deeper earlier reorg after you have already passed that point, or an RPC service switching between nodes with different head lag, can leave a superficially continuous parentHash.

Second, periodically rescan: fetch hashes of the last D indexed blocks from blocks and compare each against the current onchain block at that height. Make D a multiple of the deepest reorg you have observed.

The scan is cheap and catches the cases the first check misses, which are precisely the hardest to diagnose.

Find the Fork Point by Walking Backward

findForkPoint(cp):
    n = cp.number
    while n > cp.number - maxRollbackDepth:
        onchain = rpc.getBlockHeader(n).hash
        local   = db.blockHash(chainId, n)
        if local is null:  return n        # Missing locally, so treat this as the boundary
        if onchain == local: return n      # First matching height = fork point
        n = n - 1
    raise DeepReorgError(n)

Three key details:

The fork point is the first matching height, not the first mismatching one. That word decides whether deletion uses > or >=. An off-by-one error leaves a dirty block or removes a good one. Neither necessarily throws an error.

Set a maximum rollback depth. Without it, a faulty RPC connection to another network or a half-synced node could make the indexer walk back to genesis and delete everything. That failure mode has happened in real systems.

A deeper mismatch requires human attention. Stop and alert rather than deleting on your own. This is one of the few indexer conditions that should wake someone up.

Linear or binary search? Use linear search. Most reorgs are only a few blocks deep. A few extra RPC calls buy simple, auditable boundary logic. Binary search is premature here.

Roll Back in One Transaction

Use the three tables from T16 and add an outbox for pending effects:

begin;

-- 0. Capture affected holders before deletion; afterward their identities are lost.
create temp table touched_holders on commit drop as
select distinct holder from (
  select from_addr as holder from transfer_events
   where chain_id = $1 and block_number > $fork
  union
  select to_addr   as holder from transfer_events
   where chain_id = $1 and block_number > $fork
) x;

-- 1. Delete fact and block rows above the fork point.
delete from transfer_events where chain_id = $1 and block_number > $fork;
delete from blocks           where chain_id = $1 and block_number > $fork;

-- 2. Delete effects not yet sent.
delete from outbox
 where chain_id = $1 and block_number > $fork and delivered_at is null;

-- 3. Recalculate derived balances for affected holders.
delete from token_balances b
 where b.chain_id = $1
   and exists (select 1 from touched_holders t where t.holder = b.holder);

insert into token_balances (chain_id, contract, holder, balance, updated_at_block)
select ... from transfer_events
 where chain_id = $1 and to_addr in (select holder from touched_holders)
    or chain_id = $1 and from_addr in (select holder from touched_holders)
group by chain_id, contract, holder;

-- 4. Move the checkpoint back to the fork point.
update indexer_checkpoints
   set last_block_number = $fork, last_block_hash = $forkHash
 where chain_id = $1;

commit;

All five steps belong in one transaction. Crash after any one step outside an atomic transaction and the database may contradict itself without an obvious error.

Step 0 is easy to put in the wrong place: collect affected addresses before deleting their event rows. Do it afterward and the recomputation set is empty, leaving derived balances stale.

Two Checkpoint Ordering Mistakes

Remember this rule above all others:

Mistake one: commit checkpoint before writing data
  Crash between steps → checkpoint says “done,” but data was never written
  → permanent missing data with no automatic detector

Mistake two: write data, then commit checkpoint in another transaction
  Crash between steps → data exists, checkpoint did not move
  → restart redoes the range; idempotent insertion blocks duplicates → harmless

Correct: commit data and checkpoint in one transaction

If one transaction is impossible, update the checkpoint last. Repeating work is safer than silently skipping it.

This turns a crash from a correctness problem into a performance problem. It depends on T16 and its on conflict do nothing: idempotency is the foundation of resuming, not an optional optimization.

Backfill and Head Tracking: Same Code, Different Ranges

Historical backfill and live tracking share processing logic. They differ in who determines the range. Live tracking advances from checkpoint plus one serially and checks reorgs each pass. Backfill takes ranges from a work table, can run shards in parallel, and need not worry about stable historical blocks reorganizing.

Parallel backfill has a trap: progress cannot be one number.

Suppose five shards cover 1–100, 101–200, 201–300, 301–400, and 401–500. Shards 1, 2, and 4 finish while shard 3 fails. If progress is the highest completed height, it displays 400. The gap of 100 blocks disappears from monitoring and may never be filled.

Use a range table with a row for each work unit, status, and retry count:

create table backfill_ranges (
  chain_id   integer not null,
  from_block bigint  not null,
  to_block   bigint  not null,
  status     text    not null,          -- pending / running / done / failed
  attempts   integer not null default 0,
  primary key (chain_id, from_block)
);

Then backfill is complete when no row has a status other than done. One SQL query answers that more reliably than “how far did we get?”

Side Effects: Wait Until a Safe Height

The opening notification went wrong here.

Do not perform irreversible actions directly inside the block-processing transaction. Split them into two steps:

create table outbox (
  id            bigserial   primary key,
  chain_id      integer     not null,
  block_number  bigint      not null,   -- Used to decide whether the event is deep enough
  event_key     text        not null,   -- Onchain coordinate used by senders for deduplication
  payload       jsonb       not null,
  delivered_at  timestamptz,
  unique (chain_id, event_key)
);

First, save “what to send” in the same database transaction as the event, but do not send yet. If the transaction fails, the outbox row vanishes. If a reorg removes the block, the pending row is deleted with it.

Second, a separate dispatcher sends only eligible rows:

select * from outbox
 where chain_id = $1
   and delivered_at is null
   and block_number <= $safeHeight        -- Only events deep enough
 order by block_number, id
 limit 500;

safeHeight is where confirmation depth takes effect. An event does not leave your database before it is buried deeply enough.

That closes the issue from T1: a notification for a nonexistent transfer remains unsent in the outbox until a reorg removes it with delete.

Confirmation depth is not one global constant. Configure it by business risk:

UseExample depth policy
Display “processing”0; show immediately
Lists, statistics, chartsShallow; occasional movement is acceptable
Push notifications and emailMedium; sent messages cannot be recalled
Rewards, accounting, settlementDeep, with reconciliation

Different actions on the same chain should use different depths. Put them in configuration rather than scattering constants through code.

Acceptance: An Automatable Standard

This chapter has a strict testable standard:

Replay from any height and obtain exactly the same final result, byte for byte.

Use the checksum query at the end of T16. Record the checksum, delete data above a chosen height, move the checkpoint back, replay, and calculate again. The values must match.

That turns “is my indexer correct?” into an automated yes/no test. If it cannot pass, its replay or reorg logic is wrong even if no user has yet noticed.

What It Is Called

Block ScannerBlock scanner

The loop that advances by height and fetches blocks and logs in order.

It looks like the heart of the indexer but is its simplest part. The hard parts surround it: checkpoints, reorgs, idempotency, and side effects.

Event IndexEvent index

Parses onchain logs into structured records and stores them as the fact table from T16.

An onchain-coordinate primary key rejects repeated insertion and provides idempotency.

ReorgChain reorganization

Recent blocks are replaced by another branch. Blocks you processed may no longer belong to the canonical chain.

It is routine behavior, not necessarily a fault. Detect it by hash comparison and roll back to the fork point. Height-only systems cannot see it.

Fork PointFork point

The first height whose stored hash matches the current onchain hash while walking backward. Every later row is dirty.

It is the first match, not the first mismatch. That boundary determines > versus >= in deletion. Get it wrong and you can leave dirty rows or remove good ones without an error.

CheckpointCheckpoint

A cursor storing both the last processed height and its block hash.

One rule: commit it in the same transaction as data; if impossible, commit it after data. Moving it first loses data permanently; moving it last may merely repeat idempotent work.

BackfillBackfill

The process of filling historical blocks. It shares processing code with live tracking but gets its ranges from another source.

For parallel backfill, progress must be a range table rather than one number. A maximum completed height hides gaps.

OutboxOutbox

Records irreversible side effects in the database before a separate process sends them.

It ensures side-effect intent and event data commit together, while a reorg can still remove unsent intent. Dispatching only after sufficient block depth is how confirmation policy becomes operational.

Hands-on

Hands onBuild an indexer you can replay from any height with exactly the same final resultAny language + PostgreSQL + testnet JSON-RPCFree; use testnet and public read-only endpoints, with no transactions or private keys

Stay on testnet and read only. This chapter requires no signing, broadcasting, or spending.

Three automatable acceptance criteria:

Check 1: replay the same blocks any number of times; checksum stays fixed
Check 2: simulate a reorg, roll back and replay; checksum returns to its prior value
Check 3: kill the process 20 times while indexing; after each restart the checksum agrees

Reuse three tables from T16 and add two.

Bring over transfer_events, blocks, and indexer_checkpoints. Add this chapter’s outbox and backfill_ranges.

For every data row, check that you can answer which transaction produced it, its block height, and whether its block remains canonical.

Write the main loop without reorg handling first.

Implement the pseudocode above, temporarily skipping isStillOnChain.

Get these pieces right together:

- One transaction per block
- Ingest events with on conflict do nothing
- Commit checkpoint with data in the same transaction
- Save block headers in blocks, including parent_hash

Process 500 blocks and record the checksum from the query at the end of T16.

Check 1: replay from an arbitrary height.

-- Choose height N, delete everything after it, then move checkpoint back.
delete from transfer_events where chain_id = $1 and block_number > $N;
delete from blocks           where chain_id = $1 and block_number > $N;
update indexer_checkpoints set last_block_number = $N,
       last_block_hash = (select block_hash from blocks
                          where chain_id = $1 and block_number = $N)
 where chain_id = $1;

Restart the indexer, run back to the original height, and compute the checksum again.

The two checksums must be identical. Stop here if they are not. Try three different values of N.

Implement reorg detection and backward fork-point search.

Add isStillOnChain and findForkPoint, including the maximum depth and an alert and stop when exceeded.

Mind the fork-point boundary: return the first height with a matching hash and delete where block_number > fork. The next step tests that boundary.

Check 2: create a fake reorg instead of waiting for one.

-- Alter one byte of a block hash, simulating a block that is no longer canonical.
update blocks
   set block_hash = decode('00' || encode(block_hash, 'hex'), 'hex')
 where chain_id = $1 and block_number = $N - 3;

Also add a few fake transfer events above that height to represent dirty rows.

Restart. Correct behavior is to detect discontinuity, walk back to the fork point (in this setup, N - 4), delete later rows, and reindex.

The final checksum must equal the value recorded in step two. Fake events must disappear.

Then run a boundary experiment: change deletion from > to >= and see how the checksum changes. Seeing the off-by-one error once is more useful than memorizing the rule.

Check 3: kill the process at random times. Start the indexer, force-stop and restart it twenty times. After each restart verify:

- Does the checksum match the control run?
- Does checkpoint height match the maximum height in blocks?
- Does checkpoint ever say it reached N while N's data is absent?

The last case means checkpoint commit order is wrong. This reproduces ordinary operations events: restart, deployment, out-of-memory kill, or machine failure.

Add outbox and verify side effects stay inside until safe.

Write notification intent with each event in the same transaction. A dispatcher selects only rows at or below the safe height, then sets delivered_at after sending.

Repeat check 2. For rolled-back heights, count outbox rows whose delivered_at is not null. There must be zero. That prevents the opening user complaint.

Follow the chain head for a week and record real reorgs.

Set confirmation depth to zero on a fast-block testnet. For every detected reorg, record time, fork-point height, and depth.

After a week, examine the depth distribution. Your own observations can inform the confirmation depth needed by your business better than blindly copying a number from an article.

AI Lab

AI LabAsk AI to write reorg rollback, then use a constructed fork to check for missed rowsLevel 2 · AI Copilot

Ask in three steps; the third is the deeper challenge.

Step one:
I have a fact table keyed by chain_id + block_number + log_index,
a block table storing block_hash and parent_hash, and a checkpoint table with height
and hash. Write complete reorg detection and rollback logic, including fork-point search.
Provide pseudocode and SQL.

Step two:
My database contains blocks 1000 through 1010. A reorg replaces 1006–1010 with five
new blocks; 1005 and earlier remain. Under your logic, explain in order:
1. Where will processing block 1011 detect the problem?
2. Which heights does findForkPoint compare, and what does it return?
3. Write the deletion condition and say exactly which heights it removes.
4. What changes if I use >= instead of >, and what are the consequences?

Step three:
Before the reorg, my system sent notifications, emails, and rewards for events in
1006–1010. They cannot simply be deleted. Design a way to prevent this, and explain
when your design could still fail.

Models often handle step one well, because reorg rollback has been explained countless times. Watch three details: hash versus height, whether a maximum rollback depth exists, and where the transaction boundary is drawn.

Step two is arithmetic, and models make more mistakes on step-by-step concrete numbers than you may expect. The most common mistake is an off-by-one fork point: it claims findForkPoint returns 1006, then deletes with > 1006, leaving dirty block 1006 behind. The mistake is difficult to spot in review yet permanent in the data.

Step three is the real value of this Lab. It asks where the boundary lies: what can you roll back, and what cannot be undone? A good answer proposes an outbox and confirmation depth, and explicitly names the cases where that design can still fail. If it does, ask a follow-up: “Your confirmation depth is 12, but a reorg goes 30 blocks deep. What happens to notifications already sent?” There is no purely technical answer; the response is a business decision. Recognizing that distinction separates writing code from taking responsibility for a system.

You must do the last part yourself: construct the fork data from lab step five, run the generated code, and compare checksums. T16 made the same point in its AI Lab: these bugs look reasonable in review and reveal themselves only when real data goes through the code.

Verify these points yourself after using AI

  • Does it detect reorgs by block hashes, not merely heights? Height-only detection can never find replacement at the same height
  • Does it compare backward to find the fork point rather than assume a fixed reorg depth?
  • Is there a maximum rollback depth, and does exceeding it stop and alert rather than continue deleting?
  • Does deletion use greater than the fork point, not greater than or equal? Compare checksums from both on constructed data
  • Does it repair derived tables? If recalculating, does it collect affected holders before deleting facts?
  • Are checkpoint updates and deletion in one transaction?
  • What happens to notifications and emails already sent and impossible to retract?
  • Does it handle a lagging RPC node or disagreement between RPC nodes?
  • Run its logic on a constructed fork and compare checksums rather than trusting its prose

Real-World Case

A notification reported a transfer that never existed

The opening case was foreshadowed in T1.

An indexer sends a notification immediately when a block appears. After a reorg, the transfer never makes it into canonical history. The user still has a notification about it.

The damage is not one message; the user may no longer trust any notification. An outbox and safe-height check prevent it.

Height-only tracking left dirty data for three months

An indexer records “processed through block 1000.” After a reorg, it continues at 1001. No parentHash check exists.

Events from five replaced blocks stay among canonical events, with no query marking them as dirty. Three months later, reconciliation fails. The worst part is that the database cannot identify the wrong rows.

This continues the final case in T16: a stored block hash is inexpensive insurance.

Checkpoint committed first, silently skipping a block

To “reduce transaction size,” someone updates the checkpoint in a separate transaction before writing events. It works until a deployment restart lands between them. The checkpoint moved, but event rows did not; all events in that block are permanently missed.

No alert, log, or ordinary query necessarily finds them, because the system thinks the block was processed. Repeat work rather than skip work.

Parallel backfill left a gap

Five shards run backfill in parallel, tracking progress as the maximum completed height. One shard stops after RPC rate limiting. Monitoring sees only the maximum height and reports success.

A hundred missing blocks remain undiscovered until users notice absent records weeks later.

Parallel progress is not one number. Track ranges and define completion as “no row has a status other than done.” One SQL query answers it.

Change One Variable

What ifThis chain has deterministic finality with a known reorg bound

The task gets easier: blocks beyond that bound cannot change, so maximum rollback and safe height can have a principled value.

The head can still reorganize, so detection and rollback remain necessary. You know how deep “deep enough” is instead of guessing. Chains define finality differently; configure it per chain rather than hard-coding one value.

What ifYou switch RPC providers and the new node trails the old by ten blocks

The new node has not reached your checkpoint height. If “cannot find this block” is treated as “hash mismatch,” the indexer may roll back ten perfectly good blocks.

Treat “remote head is below my checkpoint” as a separate state: wait rather than roll back. Being ahead and being wrong are different.

What ifA reorg is deeper than your maximum rollback depth

The correct code behavior is stop, alert, and do not continue deleting. Beyond that bound, you cannot tell a deep reorg from connecting to the wrong network.

The business must answer something code cannot: what happens to notifications and rewards already delivered? Set that policy before launch rather than improvising during the event.

What ifFetch logs for a thousand blocks in one large request

It is faster, but the resulting batch may span two branches if the head changes during the request. Logs include their block hash; compare it with each corresponding block in your table and discard and refetch a batch that disagrees.

More generally, do not assume a batch request is internally consistent. Larger near-head ranges are more likely to cross a reorg. Shrink batches near the head and use larger ones for old stable history.

Takeaway Questions

1
What problem does it solve?

What problem does it solve? This chapter safely stores a history that can be rewritten in a database that cannot rewrite itself automatically. Fetching data is not the hard part; a scanner is an afternoon’s loop. Reorgs, checkpoints, and idempotency reveal their difficulty only when something goes wrong.

9
Who bears the risk?

Who bears the risk? If an index is wrong, users see false balances or receive false notifications and rewards, perhaps for months. The characteristic is silence: nothing crashes while it drifts from the chain. Acceptance must be an automated checksum assertion, not “the dashboard looks fine.”

17
Who bears the loss when AI gets it wrong?

Who loses when AI is wrong? An AI answer can explain fork points correctly yet place one wrong symbol in code, leaving a dirty block forever. Review alone rarely exposes that; constructed data and execution do. The person operating the indexer owns the result.

Chapter Check

Key idea

The hard part of an indexer is never fetching; it is handling reorgs, resuming after interruption, and ensuring idempotency.

Finished this chapter’s lab? Mark it completeView all progress

On this page