Crypto OS
Technical Crypto OSStage 5 · Infrastructure & Security

T25 · Blockchain Data Infrastructure

How do you turn full-chain data into a queryable warehouse?

Capabilities practiced
BuilderOnchain Literacy
Hands on
Export all historical protocol events to columnar files and run an aggregation query across months.
AI Lab
Have AI write an analysis query. Check whether its metric definition matches the raw on-chain events.

A Real-World Problem

Your indexer has run reliably since T17: no missing blocks, rollback support, and repeatable runs. The application database holds tens of millions of transfer events.

Operations asks its first question: “How many active addresses and how much fee revenue did our protocol have in each of the past twelve months?”

You write a SQL query and press Enter. Forty minutes later it finishes, just as production alerts fire: the query saturated database I/O and frontend requests began timing out.

You add indexes; the second run is faster. Then comes “Can you separate new and returning users?” Another half hour.

The third request is “Can we see twenty-four months?”

The database has only the most recent six months. Disk grew too fast, so three months ago you scheduled deletion of old data.

All three questions seem so routine that operations expects a couple of clicks. Your system answers none well.

The indexer is not poorly written; it does its own job well. A system optimized to retrieve one address's current balance and one optimized to scan a billion rows for an aggregate differ in storage format and rebuild strategy.

This chapter builds the second.

Thought Experiment

Start with a paper ledger.

Each page holds one transaction with thirty fields: date, counterparty, amount, purpose, handler, invoice number, department, project code, and so on.

Question one: “How much was Zhang San's payment yesterday?” Find the page, read it, and answer. One-page access is cheap.

Question two: “Total monthly spending last year.” You must turn every page for a year. Only date and amount matter, but pages bind the fields together. You cannot turn just those two columns; all thirty fields pass through your hands.

Now rebind the ledger: copy every date onto one roll, every amount onto another, one roll per field. For question two, read only two rolls rather than thirty. Values of the same type compress well, so even moving the data gets cheaper.

Question one becomes harder: to reconstruct Zhang San's complete record, pull one entry from each of thirty rolls.

That is row versus column storage. Neither is universally better; each fits a different question.

But a second thought experiment is harder.

The ledger is not written all at once. It grows every day, and occasionally you must replace all entries for a day last month because of a chain reorg, a misparsed field, or an upgrade changing an event's meaning.

The problem is no longer merely fast lookup:

How can you rebuild one day so that its result exactly matches rebuilding the entire history from day one?

Only then can a warehouse support reconciliation, reporting, and settlement. Otherwise it is just a faster cache.

Your Decision

You must answer operations' three questions. Choose an approach.

What Happens

Compare the choices:

Delivery speedAggregate performanceHistorical depthDefinition controlLong-term cost
Application indexes and viewsFastestLowDisk-limitedFully controlledMedium, also burdens production
Read-only replicaFastLowDisk-limitedFully controlledMedium, full extra copy
Columnar files and analyticsSlowHighAlmost unlimitedFully controlledLow, but pipeline maintenance
Third-party platformFastestHighDeepNot controlledUsage-based, external dependency

The fourth column dominates during an incident, outweighing the others together.

People ask only two fundamental questions of analytics:

How was this number calculated?

If I calculate it again, do I get the same number?

The first is the metric definition; the second is replayability. Neither is primarily a query problem. Both are ETL problems.

That is the surprise: SQL itself can be learned quickly. The difficult work is explaining each row's origin, rebuilding partitions independently, and obtaining the same result each time.

You have already solved half of it. T17's checkpoints, idempotency, range table, and rollback reappear here in another form.

Build a Model

Three layers: Raw, Decoded, Curated

Do not combine “fetch data from the chain” and “compute business metrics” in one step. Use at least three layers:

  1. RPC / node
  2. Raw layer
  3. Decoded layer
  4. Curated business layer
  5. Reports and APIs
Each layer has one job and can be rebuilt independently.
LayerWhat it storesCost to rebuild
RawOriginal block headers, transactions, receipts, and logs as bytes, not decodedRPC again: most expensive, perhaps requiring an archive node
DecodedABI-decoded structured events: who, to whom, how muchDecode local Raw again: cheap
CuratedBusiness meaning: daily active users, fees, positions, LP returnsAggregate again: cheapest

The last column explains layering. Pay the costliest step only once.

Common corrections include “we parsed a field wrong” and “an upgrade changed an event's meaning.” If RPC feeds metrics directly, either correction requires refetching chain history, perhaps for days and subject to provider limits. With original Raw bytes retained, you rerun decoding locally in minutes.

Raw is insurance against the certainty that you will someday discover a decoding mistake. Its storage cost is small beside a full refetch.

Partitioning: the choice that shapes everything

Partitions physically divide data into files so queries read only relevant pieces, a feature called partition pruning.

Blockchain data offers two natural keys: block height and time. Which should you use?

Both, with different roles:

Physical partition key  →  date derived from block time (UTC)
Sort within partition   →  block height, then log_index

Business questions are by time (“last month,” “Q3”), so dates make pruning useful. Writes, deduplication, and rebuilds use block heights, so ordering by height lets the engine skip ranges and gives each partition an exact recorded height span.

A directory layout:

warehouse/
  raw_logs/
    dt=2024-03-01/part-0000.parquet
    dt=2024-03-02/part-0000.parquet
  decoded_transfers/
    dt=2024-03-01/part-0000.parquet
  curated_daily_metrics/
    dt=2024-03-01/part-0000.parquet

Two easy mistakes:

First, derive the partition key from block time, never now(). An ingestion-time key puts the same chain data into different partitions today and tomorrow, breaking replay. T16 stated the rule for fact tables: time comes from the block header, not your server.

Second, do not partition too finely. A daily file of tens or hundreds of megabytes is comfortable. Hourly partitioning can create tens of thousands of tiny files whose metadata cost erases the columnar benefit: the small-file problem.

Idempotency: a partition is the atomic write unit

T17 used database transactions and keys. Filesystems lack database transactions but offer a workable substitute: atomic rename.

1. Write the entire partition into a temporary directory
   warehouse/decoded_transfers/_tmp_2024-03-01_run42/
2. After writing and validation, atomically rename it to the published path
   → warehouse/decoded_transfers/dt=2024-03-01/
3. Update the manifest: height span, row count, checksum, decoder version

The rule from T17 appears in a new form:

A partition either exists completely or does not exist at all. Never publish half a partition.

A crash during step 1 leaves a temporary directory for the next run to clear; the published path stays intact. A crash after step 2 but before manifest update causes a rebuild. That is harmless because a repeat produces identical bytes.

Rename first, update the manifest second. Reverse them and you get “manifest says it exists, but files do not,” the same error as advancing T17's checkpoint before storing data.

Manifest: the pipeline's source of truth

Without a manifest, a warehouse is merely a pile of files and nobody can say whether a month is complete.

create table warehouse_manifest (
  dataset        text    not null,      -- raw_logs / decoded_transfers / ...
  chain_id       integer not null,
  dt             date    not null,      -- Partition key
  from_block     bigint  not null,      -- Height span actually covered
  to_block       bigint  not null,
  row_count      bigint  not null,
  checksum       text    not null,      -- Content fingerprint to compare reruns
  decoder_version text   not null,      -- Decoder code version; crucial for lineage
  status         text    not null,      -- pending / running / done / failed
  attempts       integer not null default 0,
  built_at       timestamptz not null default now(),
  primary key (dataset, chain_id, dt)
);

It answers four questions nobody asks in calm periods and everyone asks during an incident:

-- 1. Are any dates missing?
select dt from generate_series($from::date, $to::date, '1 day') g(dt)
 where not exists (
   select 1 from warehouse_manifest m
    where m.dataset = 'decoded_transfers' and m.chain_id = $chain
      and m.dt = g.dt and m.status = 'done'
 );

-- 2. Are height spans continuous between adjacent dates?
select a.dt, a.to_block, b.dt, b.from_block
  from warehouse_manifest a
  join warehouse_manifest b
    on b.dataset = a.dataset and b.chain_id = a.chain_id
   and b.dt = a.dt + 1
 where b.from_block <> a.to_block + 1;

-- 3. Which partitions use an old decoder version and need rebuilding?
select dt from warehouse_manifest
 where dataset = 'decoded_transfers' and decoder_version <> $current;

-- 4. Did content change on replay? Compare checksums by partition

Query 1 is T17's range table in another form: “complete” must be answerable with SQL, not inferred from a progress bar. Query 3 realizes the value of layers.

Reorgs: the warehouse should lag

Columnar files are immutable. How do you handle the chain-head reorgs from T17?

Keep the chain head out of the warehouse.

Application database (OLTP) ← follows the head, serves real time, handles rollback
        │
        │ Move only rows with block_number <= safeHeight
        ↓
Data warehouse (OLAP)        ← trails by blocks; published files are immutable

safeHeight is the same safe boundary used by the T17 outbox. Lagging by minutes or hours is a design choice, not a flaw.

Reorg handling becomes simple: the warehouse contains no data that needs rollback. It cannot answer “what happened one second ago”; the application database and T18's real-time channel serve that question.

Two systems offer two freshness levels and two consistency guarantees. Do not force both into one system.

If you must rewrite a published partition, perhaps after finding a decoder error from three months ago, regenerate it, atomically replace it, update the manifest, and mark dependent downstream partitions pending. That is why manifest records decoder_version.

Curated layer: definitions belong next to code

At this layer, technical questions give way to definitions.

“Monthly active addresses” has at least six interpretations:

- Count only transaction initiators, or both sides of a transfer?
- Count failed transactions?
- Count contract addresses?
- For aggregator routes, count the aggregator or the end user?
- One person with five addresses: one or five?
- Use UTC or a local timezone?

Two answers to each generate dozens of legitimate metrics with the same label.

Keep a definition comment for every curated table in the same file as its SQL. A separate document will drift away from the code within months.

-- daily_active_addresses
-- Definition (finalized 2024-03):
--   1. Count logs from successful transactions only
--   2. Count and deduplicate both transfer from and to
--   3. Exclude the zero address used for mint and burn counterparties
--   4. Include known contract addresses, but flag them separately
--   5. Use UTC calendar days
create table curated_daily_active (
  chain_id   integer not null,
  dt         date    not null,
  contract   bytea   not null,
  addresses  bigint  not null,
  tx_count   bigint  not null,
  primary key (chain_id, dt, contract)
);

A useful observation: when two people report different numbers for one metric, the usual cause is different definitions, not bad arithmetic. Put the definition beside the SQL and a long argument becomes a short comparison.

A complete example

Aggregate monthly metrics from decoded data:

-- Monthly active addresses and fees over 12 months
-- Partition pruning uses dt, so only these months' files are scanned
with e as (
  select dt, from_addr, to_addr, amount, gas_used, gas_price
    from decoded_transfers
   where chain_id = 1
     and contract = $contract
     and dt >= date '2024-01-01'
     and dt <  date '2025-01-01'
),
addrs as (
  select date_trunc('month', dt) as m, from_addr as addr from e
  union all
  select date_trunc('month', dt) as m, to_addr   as addr from e
)
select m,
       count(distinct addr)                      as active_addresses,
       sum(gas_used * gas_price) / 1e18          as fees_native
  from addrs
  join e on date_trunc('month', e.dt) = addrs.m
 group by m
 order by m;

Watch where dt >= ... and dt < .... It determines whether the engine scans twelve months of files or the full archive, which creates the order-of-magnitude difference.

If you instead write where date_trunc('month', dt) = ... or wrap dt in any function, partition pruning may fail and the engine scans everything. Fast queries often depend on leaving the partition key bare in the filter.

What It Is Called

ETLExtract, transform, load

A pipeline that takes source data, reshapes it, and stores it elsewhere. Onchain that means fetching blocks, decoding events, and calculating business metrics.

Its opposite is not another technique but “one SQL query against production.” If that query runs for over a minute, you are already doing ETL without treating it as engineering.

Data WarehouseData warehouse

Storage optimized for analytics rather than transactions. It accepts slower ingestion and weaker real-time freshness in return for fast aggregates and deep history.

The test is simple: a system asked both for one address's balance and a sum across a billion rows will struggle at both.

Columnar StorageColumnar storage

Data organized by column instead of row. Queries read only needed columns, and same-type values compress well.

It is a default for analytics. Two implementation types appear in the concepts: embedded single-machine engines that query files directly within your process and distributed columnar databases for larger data and concurrency. Data volume and team size guide the choice; this chapter's model applies to either.

PartitioningPartitioning

Physically dividing data by a key so queries read only relevant pieces: partition pruning.

For onchain data, partition by date and sort inside by block height. Date serves queries; height serves ingestion and replay. A partition is also the atomic unit of idempotent loading.

Idempotent LoadIdempotent load

Loading the same batch repeatedly without changing the final result.

For files, write a temporary directory, validate, atomically rename, then update the manifest. This is T17's data-and-checkpoint transaction in another medium: repeat work if needed, but never publish half of it.

Safe HeightSafe height

The boundary below which blocks may enter the warehouse, preventing rollback-requiring data from entering it.

The cost is predictable lag. Document that lag as a contract with data users, rather than hiding it as a performance problem.

LineageLineage

The ability to trace a metric to upstream partition, block span, decoder version, and aggregation logic.

The minimum implementation is those columns in the manifest. Without lineage, “why did this number change?” becomes guesswork.

Hands-on

Hands onStore a protocol's historical events as columnar files and query aggregates across monthsAny language, a columnar engine, and public read-only RPC or archive dataFree and read-only: no transactions or keys. For deep history, use a testnet or small protocol to avoid exhausting RPC quotas

Everything is read-only. No signing, broadcasting, or spending in this chapter.

Three acceptance checks can each be automated:

Check 1: Rebuild any one daily partition; its checksum stays the same
Check 2: No holes in the manifest; adjacent height spans are continuous
Check 3: Aggregates match direct calculations from raw logs row by row

Choose a small protocol and a bounded range. Do not start with the whole chain. Pick a contract with a moderate event count over three or four continuous months. The goal is a working pipeline, not a volume record.

Before coding, answer the start and end block heights of the range. Write them down as the basis for every validation.

Build Raw: store without decoding.

Partition by day and store original log fields: block_number, log_index, block_time, tx_hash, address, topics, and data. Keep data as raw bytes here.

raw_logs/dt=YYYY-MM-DD/part-0000.parquet

Write in four steps: temporary directory, validation, atomic rename, manifest update. You will test that order in this Lab.

Create the manifest using the DDL above.

After each partition, record height range, row count, checksum, decoder version, and status.

Run the missing-date query immediately. It should report many dates not yet processed, demonstrating that it detects holes before you rely on it in production.

Build Decoded from local Raw only, with no RPC requests.

Decode topics and data with the ABI into structured fields. If this step still calls RPC, Raw did not store enough; fix Raw.

Keep amounts as integers or high-precision decimal, never floating point. A floating-point sum may look fine in a report but fail reconciliation.

Check 1: rebuild individual days.

Select three dates, rerun each, and compare its checksum in the manifest:

Three reruns, three checksums; each must exactly match its original

If not, inspect partition keys for now(), nondeterministic sorting, and run-time fields such as updated_at written into files. The first two are common replay failures.

Check 2: create a hole deliberately and verify detection.

Delete one middle day's partition directory without changing the manifest. Run an aggregate: it silently omits the day with no error.

Delete that manifest row too, and run the missing-date query. It must identify that day.

This comparison justifies the manifest: files can be silently absent; a checked manifest exposes the gap.

Build Curated monthly metrics with a written definition.

At the top of the SQL file, document at least the six definitional choices listed earlier. Then run the chapter's twelve-month aggregate.

Check 3: reconcile to raw logs.

Pick one day and calculate the metric directly from Raw, without any Curated table. The two numbers must match.

A mismatch is usually a definition issue: perhaps Curated excludes zero addresses while the reconciliation script includes them. The value of this step is forcing a precise definition, not merely finding a bug.

Measure the benefit. Run the same monthly aggregate against the application database and columnar storage. Record elapsed time, bytes scanned, and storage size in both.

Make a table from your own measurements. The course provides no universal numbers; they depend on your data. You should see the scale of the difference yourself.

AI Lab

AI LabAsk AI to write an analytics query; verify its metric definition against raw onchain eventsLevel 2 · AI Copilot

Give the model your schema, definition comments, and sample rows in three steps:

Step one:
Here is my decoded_transfers schema and partition layout (partition by dt,
sort by block_number within each). Write a query for monthly active addresses
and total fees over the last 12 months. Use partition pruning and state how
many partitions it should scan.

Step two:
List every metric-definition assumption you made. Write each as:
"I assumed X; if the actual rule is Y, the result changes in this way."
Give at least six.

Step three:
The contract actually upgraded last June. After the upgrade the same event
has an extra field and a different topic0. What happens to your query around
June, and how should it change?

Models often handle step one because this SQL pattern has been written countless times. Watch whether a function wraps the partition key. It can leave results identical while raising scan costs by orders of magnitude, an error hidden by a correct answer.

Step two is the real test. Models silently decide definitions for you: whether zero addresses count, which side of a transfer counts, and so on, then report an authoritative-looking number. Listing assumptions gives you a chance to correct them.

Step three checks a blockchain-specific hazard: a contract address is not a permanent type marker; event layout can change by height. T16 introduced this. Decode by block-height range, and use decoder_version in the manifest to identify partitions to rebuild. A model may say “add a union” without asking whether fields before and after the upgrade mean the same thing.

Finally, calculate one day from raw logs, bypassing Curated. Only then is the query suitable for reporting.

Verify these points yourself after using AI

  • Does its where clause leave partition key dt bare? A function around dt can disable pruning; check the plan and count scanned partitions
  • Check active-address deduplication: initiator only or both sides, zero address excluded or not, against your written definition
  • Were failed transactions included? Logs occur only in successful transactions, but a transaction table requires filtering status
  • Did it use floating point in any amount step or sum tokens with different decimals directly?
  • Does date_trunc use UTC or session time? They can differ at month boundaries
  • Did a join multiply rows? Calculate one day manually to detect double counting
  • Most important: calculate one day directly from raw logs without its query; the results must match

Real-World Case

One decoder edit leaves old and new history on different definitions

A team discovers that an event's amount should be parsed as a signed rather than unsigned integer. They change the decoder for new data only.

Three months later, annual reconciliation shows several inflated months. Investigation takes weeks because no row records which decoder version produced it.

That is why the manifest has decoder_version: one SQL query lists partitions needing rebuild after a decoder change. New checksums show which outputs actually changed. Without it, you either rebuild everything or rely on memory.

Partitioning with now() doubles data after a rerun

A backfill script uses the current system date as partition key. The first run writes data into “today”; a rerun writes the same data into “tomorrow.”

Both copies remain and aggregates double, even though each partition looks correct alone.

Rule: partition keys must be properties of data, not the execution environment. Onchain time comes from block headers. T16 emphasized it; warehouses magnify the cost of ignoring it.

Without atomic rename, half a partition remains for a month

A scheduler kills an export job mid-write, leaving a half-filled file at the published path.

Queries return normally, but that day's metric is 60% too low. Someone notices only when reconciling a quarterly report a month later.

A half-written file is more dangerous than a missing file because it is accepted as complete. Temporary directories and atomic rename are the general defense, not unnecessary neatness.

One transaction with thousands of logs breaks transaction-sized batches

A batch action emits an enormous number of logs in one transaction. A pipeline batching “one transaction at a time” runs out of memory. On restart it reads the same transaction and loops through crashes.

Two lessons: bound batches by bytes or rows, not business units such as a transaction or block, and give retrying pipelines attempt counts and backoff so one poison record does not stop the line forever. The manifest's attempts column supports that.

Change One Variable

What ifData volume increases by 100 times

A single-machine embedded engine first hits query memory and partition-file size limits.

The response has a sequence, and the first two steps barely change architecture: partition more appropriately, perhaps by chain and contract; precompute common Curated aggregates; only then consider a distributed engine.

Inspect query plans before buying more machines. Many apparent scale problems are failed partition pruning.

What ifBusiness needs second-level freshness instead of hourly metrics

Do not merely lower safe height. That lets reorg-prone data enter immutable storage.

Run two systems together: the warehouse remains final and lagged, while the application database and T18's channel serve live metrics. The query layer joins the two ranges and marks the boundary clearly.

The live range may be corrected. Show users which portion is final and which can change, an analytics version of T17's risk tiers.

What ifSupport several chains

chain_id becomes part of keys and partition paths throughout, a mostly mechanical change.

The harder problem is comparability. Chains have different block times and numbers of blocks per day, fee currencies differ, and the same token has distinct addresses. Adding raw values across chains often produces wrong reports.

Calculate each chain separately, then merge through a separately documented definition. Asset equivalence across chains raises a deeper question addressed next chapter.

What ifThe third-party platform you rely on changes its schema

If you use it only for exploration, the change costs an afternoon. If production reporting depends on it, it is an incident.

The difference is whether you hold replayable raw data of your own. With Raw, the worst case is hours of rebuilding; without it, you wait for the provider.

That is why the unglamorous Raw layer is the last to remove.

Takeaway Questions

1
What problem does it solve?

What problem does it solve? This chapter makes a metric recalculable. The hardest part is not querying but partitioning, idempotency, and lineage, which become visible when someone questions a number.

5
Who is paying?

Who pays? Warehouse costs have three parts: archive RPC or node access, object storage, and bytes scanned by queries. The first is often largest, and layering makes sure it is paid once. Any correction requiring a full refetch repeats the most expensive stage.

17
Who bears the loss when AI gets it wrong?

Who bears the loss if AI is wrong? Here a model may silently choose a metric definition: whether zero addresses or both transfer sides count. The result looks authoritative and raises no error; it becomes a distributed report. The person whose name is on that report is responsible.

Chapter Check

Key idea

On-chain data engineering centers on idempotent, replayable ETL, not clever queries.

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

On this page