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

T16 · Databases and Onchain Data

How should onchain data be stored in a database?

Capabilities practiced
BuilderOnchain Literacy
Hands on
Design a table for a protocol's transfer events and write insert logic that stays correct when events are ingested twice.
AI Lab
Have AI design a schema. Test whether a chain reorganization would cause it to produce dirty data.

A Real-World Problem

You create a table with three columns:

create table balances (
  address text primary key,
  token   text,
  amount  numeric
);

An indexer reads each transfer event and runs an update. The numbers look right on day one.

On day three, operations reports that an address is missing a deposit.

You open the row: address, token, amount. Then you realize:

You have no way to explain where that amount came from.

It is the result of hundreds of additions and subtractions. You cannot identify which transaction was missed, which block it belonged to, whether you missed an incoming transfer or counted an outgoing one twice, or whether the error began today or three days ago.

All you can do is clear the database and replay from the beginning. It matches afterward, but you still do not know why it failed, so you cannot know whether it will happen again.

Three days later, it does.

This is a data-model problem, not just a bug. The table discarded the chain’s most valuable information from day one.

Thought Experiment

Consider two styles of accounting.

First: record only the balance. A sheet says “Cash: 8,500.” Spend 300; cross it out and write 8,200. Receive 1,000; replace it with 9,200.

Current values are quick to read, but discrepancies are impossible to investigate. A month later, 200 is missing. The only clue is the current number, which explains nothing.

Second: record transactions and calculate the balance. Each entry has a date, counterparty, amount, and receipt number. The balance is a conclusion derived from the entries, not a number continually overwritten.

Queries may be slower, but there is a decisive advantage: every number can be traced back to a receipt. If accounts disagree, inspect transactions individually, recompute a range, or reverse one invalid receipt.

Recall T1: a chain is the opposite of the first ledger. It records transactions, from which state is derived. Anyone can verify the same history independently precisely because the entries are retained.

That makes the problem stark:

The chain paid a high price to preserve a transaction history, and your database compressed it into one number.

You lost both traceability and the chain’s central verifiability.

There is another case a balance-only ledger cannot handle: onchain “receipts” can be withdrawn by a reorg. Transactions recorded yesterday may disappear from canonical history. With only a number, you cannot tell which changes to reverse.

Your Decision

Design storage for a protocol’s transfer events. Consider four approaches:

What Happens

Four questions distinguish the designs:

Traceable to transaction?Replay from any height?Roll back reorg?Query cost
Final state onlyNoNoNoLowest
Event history onlyYesYesYesHigh
History plus materialized balanceYesYesYesLow
Raw JSON onlyYesWith difficultyWith difficultyVery high

The first three ask the same underlying question: did you preserve the provenance of each row?

Put this rule on your schema-review checklist:

Every database row must answer: which transaction produced it, at which block height, and is that block still on the canonical chain?

The third is easiest to miss. tx_hash and block_number answer the first two, but the third also needs the block hash. Without it, you cannot know which branch a row belongs to after a reorg.

Build a Model

Separate three kinds of table by responsibility:

  1. Fact tables: append only
  2. Derived tables: rebuildable
  3. Checkpoint tables: progress cursors
Facts preserve truth, projections accelerate reads, and checkpoints enable recovery. All three are needed for replay.

Fact Table: The Primary Key Is an Onchain Coordinate

-- One row = one onchain log. Append only; never update.
create table transfer_events (
  chain_id      integer       not null,
  block_number  bigint        not null,
  log_index     integer       not null,
  block_hash    bytea         not null,      -- Used to detect reorgs
  block_time    timestamptz   not null,      -- From block header, not now()
  tx_hash       bytea         not null,
  tx_index      integer       not null,
  contract      bytea         not null,
  from_addr     bytea         not null,
  to_addr       bytea         not null,
  amount        numeric(78,0) not null,      -- Holds a 256-bit integer
  primary key (chain_id, block_number, log_index)
);

create index on transfer_events (chain_id, contract, from_addr, block_number);
create index on transfer_events (chain_id, contract, to_addr,   block_number);

Six decisions in this DDL address six incident classes:

The primary key is an onchain coordinate, not an auto-increment ID. Chain, block, and log position uniquely identify an event. Repeated ingestion is rejected automatically: this is the basis of idempotent ingestion. Auto-increment duplicates rows when you replay.

chain_id belongs in the key. Block heights overlap between chains. Without this column, adding a chain causes quiet collisions and strange symptoms.

For amount, use numeric(78,0). Onchain quantities can be 256-bit unsigned integers. bigint is too small and float loses precision. Storing token quantities in floating point is a severe correctness error. Seventy-eight decimal digits cover the range.

Store block_hash. It identifies which branch your row came from. Without it, a reorg can leave permanently dirty data that you cannot recognize.

Take block_time from the block header, not now(). Ingestion time is not event time. Backfilling a year of history would otherwise give all rows today’s timestamp.

Normalize addresses and hashes. bytea is compact and avoids casing errors. If you use text, normalize all addresses to lowercase or the same address may become two rows and joins may silently omit data.

Checkpoints and Blocks

-- One row per chain: indexing progress.
create table indexer_checkpoints (
  chain_id          integer     primary key,
  last_block_number bigint      not null,
  last_block_hash   bytea       not null,
  updated_at        timestamptz not null default now()
);

-- Keep a recent chain of block hashes for tracing reorgs.
create table blocks (
  chain_id     integer     not null,
  block_number bigint      not null,
  block_hash   bytea       not null,
  parent_hash  bytea       not null,       -- Validates continuity
  block_time   timestamptz not null,
  primary key (chain_id, block_number)
);

The blocks table is essential. Without it, you know only “I indexed through block 1000,” not what block 1000 looked like when you saw it. You cannot detect a reorg. T17 uses this table extensively.

Idempotent Ingestion in One Statement

insert into transfer_events
  (chain_id, block_number, log_index, block_hash, block_time,
   tx_hash, tx_index, contract, from_addr, to_addr, amount)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (chain_id, block_number, log_index) do nothing;

That is enough. Replay the same block range a hundred times and the row count stays fixed. Idempotency comes from the primary key, not an application check asking whether you processed the log.

Derived Table: The Easy Place to Get It Wrong

create table token_balances (
  chain_id         integer       not null,
  contract         bytea         not null,
  holder           bytea         not null,
  balance          numeric(78,0) not null,
  updated_at_block bigint        not null,
  primary key (chain_id, contract, holder)
);

How should you update it?

A natural implementation inserts the event and adds its amount to the recipient. Replay doubles balances: the event insert is blocked by do nothing, but the balance update runs again.

Tie the actions together in the same statement so balances change only when the event was actually inserted for the first time:

with inserted as (
  insert into transfer_events
    (chain_id, block_number, log_index, block_hash, block_time,
     tx_hash, tx_index, contract, from_addr, to_addr, amount)
  values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  on conflict (chain_id, block_number, log_index) do nothing
  returning chain_id, block_number, contract, from_addr, to_addr, amount
),
deltas as (
  select chain_id, contract, to_addr   as holder,  amount as delta, block_number from inserted
  union all
  select chain_id, contract, from_addr as holder, -amount as delta, block_number from inserted
)
insert into token_balances as b (chain_id, contract, holder, balance, updated_at_block)
select chain_id, contract, holder, delta, block_number from deltas
on conflict (chain_id, contract, holder) do update
set balance          = b.balance + excluded.balance,
    updated_at_block = greatest(b.updated_at_block, excluded.updated_at_block);

Read this SQL closely. returning is the key: on conflict, do nothing returns no rows; then deltas is empty and balances do not move.

Remember:

Put “is this the first time we have seen the log?” and “should the projection change?” in the same transaction and statement.

Separate them and replay, retry, and concurrency can each break consistency.

Deleting After a Reorg

Delete facts above the surviving height:

begin;
delete from transfer_events where chain_id = $1 and block_number > $2;
delete from blocks           where chain_id = $1 and block_number > $2;
update indexer_checkpoints
  set last_block_number = $2,
      last_block_hash   = (select block_hash from blocks where chain_id = $1 and block_number = $2)
  where chain_id = $1;
commit;

But the derived balance table does not fix itself. Deleting events does not reverse increments already applied.

Two approaches:

  • Recalculate: reset balances of affected holders and aggregate again from facts. Slower but simple and reliable.
  • Record provenance of each projection change: maintain a row connecting every change to its event, then apply reverse changes by height. Faster but doubles storage and complexity.

Choose recalculation first. Correctness matters more, and a reorg usually touches only a few head blocks and a bounded set of addresses.

What Belongs in Redis?

One sentence: you must be able to rebuild anything in Redis from Postgres.

Appropriate: query cache, deduplication window, rate-limit counters, leaderboards, latest height. Inappropriate: the only copy of a fact about what happened onchain.

The issue is not that Redis is unreliable. Facts should have one authoritative copy. Two copies eventually diverge, likely when you can least afford it.

What It Is Called

Event ModelEvent model

Organizes data with onchain logs as facts and derived tables as projections. Facts are append-only; projections can always be rebuilt.

This resembles event sourcing, except in Crypto the chain has already defined and preserved the events.

Natural KeyNatural key

A primary key formed from attributes of the data rather than a database-generated ID.

Here it is “chain ID + block number + log index.” Use it and the database handles idempotent ingestion without custom deduplication logic.

UpsertUpsert

Insert, or update (or do nothing) when a key conflicts.

Distinguish two uses: facts do nothing on conflict, while derived tables update on conflict. Reversing their semantics creates duplicates or inflated balances.

CheckpointCheckpoint

A cursor recording how far indexing has progressed. It must contain both height and block hash. Height alone cannot detect a reorg.

Commit it in the same transaction as data. T17 returns repeatedly to this rule because each way of breaking it has different consequences.

Derived TableDerived table

A table computed from facts to speed queries: balances, holdings, rankings, and statistics.

A decisive test: if you delete the entire table, can you rebuild it from facts? If not, it contains facts of its own, where reconciliation will eventually break.

Finality DepthFinality depth

How many subsequent blocks your business requires before treating a block as irreversible.

It is a business parameter, not a technical constant. Display data may use zero confirmations; settlement of funds may need dozens. Keep it in configuration rather than scattered code.

Hands-on

Hands onDesign storage for transfer events and write ingestion that stays correct when replayedPostgreSQL + any language + testnet RPCFree; use testnet throughout

Use testnet and public read-only endpoints; no private key is needed. This chapter sends no transactions.

There is one demanding acceptance criterion: reprocess the same blocks any number of times and the database contents remain identical.

Create three tables.

Create transfer_events, blocks, and indexer_checkpoints from the definitions above.

For any row, ask whether you can trace its transaction, know its height, and determine whether its block remains canonical.

Ingest logs from 500 blocks.

Choose an active token contract on a testnet. Use eth_getLogs to fetch Transfer events by block range, as in the lab of T1.

Fetch each block header into blocks too, including parent_hash.

Replay three times and verify the row count stays fixed.

Run the same block range twice more, executing after each run:

select count(*) from transfer_events;

All three counts must match. If they rise, your primary key or on conflict is wrong. Fix this before moving on.

Add materialized balances with the CTE statement.

Create token_balances and ingest using the with inserted as (...) statement above.

Replay the 500 blocks. Not one balance should change.

If values double, you separated event insertion from balance updates. Bring them back together. This is the lab’s central check.

Simulate a reorg.

Suppose your latest height is N. Manually execute:

delete from transfer_events where chain_id = $1 and block_number > $2;  -- $2 = N - 5
delete from blocks           where chain_id = $1 and block_number > $2;

Reset affected holders in token_balances, recompute from facts, and move the checkpoint back to N-5.

Replay N-5 through N. Balances must exactly match their pre-deletion values.

This proves the derived table can be rebuilt. If not, it contains independent facts.

Reconcile against the chain.

Sample 20 holders. Query balanceOf over RPC at the current height and compare each balance with your database.

Disagreement can occur; the point is that you can now investigate it using the event history. A token may charge transfer fees, mint or burn without the events you assumed, or you may have missed a block.

Compare cases in T1.

Save a checksum for T17.

select md5(string_agg(t.tx_hash::text || t.amount::text, '|'
              order by t.chain_id, t.block_number, t.log_index))
from transfer_events t
where t.chain_id = $1 and t.block_number <= $2;

Save the result. T17’s acceptance criterion is that replaying from any height reproduces the same checksum.

AI Lab

AI LabAsk AI to design a schema, then test it against a chain reorgLevel 2 · AI Copilot

Use two steps. The second is the real examination:

Step one:
Design PostgreSQL tables for one ERC-20 token's Transfer events. Requirements:
query history and current balance by address, support multiple chains, and scale to
hundreds of millions of events. Provide complete DDL and ingestion SQL.

Step two:
A reorg replaces the last five indexed blocks with five different blocks. Three
previous transfer events disappear and two new ones appear. Using your schema and
write logic, explain step by step:
1. How do I detect the reorg?
2. Which rows do I delete, and with what SQL?
3. What happens to the balance table? Is it still correct?
4. After reindexing, how do I prove balances match canonical history?

The model can usually produce the first schema, but often defaults to auto-increment primary keys because general database tutorials do. In an indexer, that creates duplicate rows on replay.

The second step exposes deeper flaws. Two common failures: detecting a reorg by comparing heights instead of hashes, which misses it entirely, and deleting events without repairing the balance table, leaving balances dirty.

If it handles that, ask how an incrementally maintained balance can be guaranteed correct after rollback and replay. This distinguishes a plausible explanation from a complete one.

Execute every claim in a real database. Schema mistakes can look reasonable in review and surface only after data arrives.

Verify these points yourself after using AI

  • Is the primary key chain ID plus block height plus log index, or an auto-increment ID? The latter cannot ensure idempotent replay
  • Is chain_id in the key? Without it, two chains at the same height collide
  • Can the amount type hold 256-bit integers? bigint overflows and floating point loses precision
  • Are block_hash and parent_hash stored? Without them, reorgs cannot be detected properly
  • On replay, does its upsert add a derived balance twice? Run it yourself
  • Does it use now() for block time? That misdates backfilled events
  • Does it normalize address case, or accept arbitrary casing?
  • Are checkpoints and data writes in the same transaction?

Real-World Case

Only a balance table; rebuilding is the sole debugging tool

The opening case: when a balances table disagrees with reality, the only available response is replay from genesis.

The deeper cost is not the hours spent replaying but never learning why the first run failed. Without a cause, it happens again. Such systems can develop an absurd operations ritual: wipe and rebuild weekly “for safety.”

Traceability is the entry point for investigating problems, not a nice extra.

A quantity column overflowed

A team stores token amounts in bigint. Most routine transfers fit, so it works for a long time.

Then a large transfer of an 18-decimal token exceeds bigint range. In the best case, insertion errors; in the worst, data is truncated. Accounts become permanently wrong with little clue as to why.

Use a decimal type large enough for 256-bit onchain amounts. This is correctness, not optimization.

Address casing was inconsistent

Some addresses arrive lowercase from RPC; others come from a checksummed interface. Both forms go into the same text column.

One address becomes two rows, its balance split between them, joins miss data, and users see only part of their history.

Worse, nothing errors; numbers just quietly shrink. Store binary addresses or normalize to lowercase before ingestion, enforcing the rule at the database boundary.

No block hash; a reorg left permanent dirty data

An indexer records only “processed through block 1000.” After a reorg changes block 1000, it resumes at 1001.

It never discovers that events from blocks 995 to 1000 came from a branch no longer canonical. They stay mixed with valid data, with no query able to identify them.

Months later, reconciling becomes especially painful: the database contains no evidence identifying which rows are wrong.

block_hash is cheap insurance. T17 turns it into a full reorg-handling mechanism.

Change One Variable

What ifThe indexed contract is an upgradeable proxy whose event definition changes

The same address emits different event structures before and after upgrade. Parsing both into one assumed shape can fail or misalign fields.

Add an event or ABI version column to facts, using block height to choose the decoder: old ABI before the upgrade, new ABI afterward.

The broader lesson: a contract address is not a stable type identifier. Do not assume events from an address always share one structure. T25 explores infrastructure for this systematically.

What ifIndex 20 chains at once

Including chain_id in the primary key moves from good practice to necessity. Block heights overlap heavily; without it, chains overwrite one another.

Other differences appear: confirmation depths, block speeds, decimal rules, and reliability of block timestamps vary.

Put these differences in a configuration table, not scattered if branches. Hard-coding one chain’s assumptions creates major multichain debt.

What ifThe fact table reaches a billion rows

Partition by block_number range. This speeds some queries. Deleting whole ranges can become as simple as dropping a partition, making reorg rollback and archival much faster.

Address-history indexes also become huge. You may need specialized query tables or columnar storage, while the fact table returns to its fundamental role: the raw record from which everything else can be replayed.

That is exactly where it belongs.

What ifThe product asks for an address's balance on one day last year

token_balances holds only the current value, so it cannot answer directly.

Either replay facts up to the historical height (slow but storage-light and correct) or save periodic snapshots (fast but larger, with a snapshot interval to choose).

Many systems combine them: daily snapshots plus replay from the nearest prior snapshot. That works only because the fact table remains complete.

Takeaway Questions

1
What problem does it solve?

What problem does it solve? This chapter preserves verifiability as onchain data enters your system. Ingestion can easily discard provenance; then your database becomes a black box no one can audit.

9
Who bears the risk?

Who bears the risk? Users may see incorrect balances and history for months. These errors are quiet: nothing crashes while the database drifts from reality. Regular reconciliation is the only timely detector.

17
Who bears the loss when AI gets it wrong?

Who loses when AI is wrong? The AI Lab asks for a schema. A model may choose an auto-increment ID, appropriate in ordinary business data but fatal for replay here. The person using the design is responsible, so verify each checklist item against a real database.

Chapter Check

Key idea

Treat events as facts and block heights as versions; every row must trace back to its source transaction.

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

On this page