ClickHouse (OLAP) - same query, faster for large scans

July 11, 2026 · View on GitHub

tidx

QuickstartInstallationConfigurationCLIAPIQueries


tidx indexes Tempo chain data into a hybrid PostgreSQL + ClickHouse architecture for fast point lookups (OLTP) and lightning-fast analytics (OLAP).

Features

  • Tiered Storage — hot window in PostgreSQL (OLTP), full archive in ClickHouse (OLAP), queried as one
  • Event/Function Decoding — Query decoded events or function calldata by ABI signature (no pre-registration)
  • HTTP API + CLI — Query data via REST, SQL, or command line

Table of Contents

Quickstart

curl -L https://tidx.vercel.app/docker | bash

Overview

The sync engine writes to both PostgreSQL and ClickHouse in parallel. With [chains.retention] enabled, PostgreSQL keeps only a hot window of recent blocks (e.g. pg_keep = "30d") and prunes the rest once it is durable in ClickHouse, which holds the full archive. Without retention, both stores hold full history. Queries route across the tiers via the engine and source parameters:

                        ┌───────────────────────────────────────┐
                        │                /query                 │
                        │                                       │
                        │  ?signature=...                       │
                        │  ?engine=...  &source=...             │
                        └───────────────────┬───────────────────┘


                        ┌───────────────────────────────────────┐
                        │             Query Router              │
                        └───────┬───────────────────────┬───────┘
                          hot   │                       │   cold / analytics
                                ▼                       ▼
          ┌──────────────────────────┐                ┌──────────────────────────┐
          │        PostgreSQL        │  pg_clickhouse │        ClickHouse        │
          │    hot window (OLTP)     │     (FDW)      │    full archive (OLAP)   │
          │   point reads ~1 ms †    │───────────────►│  90d rollups ~160 ms †   │
          │    30d hot ≈ 8 GB †      │                │   full chain ≈ 26 GB †   │
          └────────────┬─────────────┘                └────────────┬─────────────┘
                       ▲                                           ▲
                       │                                           │
                       └─────────────────────┬─────────────────────┘

                                     ┌───────┴───────┐
                                     │   Dual Sink   │
                                     └───────┬───────┘

                                     ┌───────┴───────┐
                                     │  Sync Engine  │
                                     └───────────────┘

— sampled from Tempo mainnet (chain 4217): full archive ≈ 29.3M blocks, 30d hot window ≈ 5.8M blocks.

enginesourceRoute
postgres*postgres-clickhouse*Tiered — hot reads on PostgreSQL, cold/analytical reads on native ClickHouse; provably-safe shapes split at the prune boundary, everything else falls back to pg_clickhouse (FDW)
postgres*postgresPlain PostgreSQL (hot window only when retention is enabled)
postgres*clickhousePostgreSQL planner over the full ClickHouse archive via pg_clickhouse foreign tables
clickhouseclickhouse*Native ClickHouse (full archive + materialized views)

* — default when omitted. engine=tiered is accepted as a legacy alias for engine=postgres&source=postgres-clickhouse.

Note

FDW routes require a pg_clickhouse build with ClickHouse/pg_clickhouse#296 (unreleased 0.4): 0.3.2 crashes PostgreSQL on LATERAL / correlated subqueries over foreign tables. ghcr.io/tempoxyz/pg_clickhouse:16-02505c4 ships a pinned build (see .github/workflows/pg-clickhouse.yml). Aggregates pushed to ClickHouse (e.g. avg) compute in Float64 and can differ from PostgreSQL numeric in the last decimal.

# Tiered (default) - last 10 transfers from an address; hot rows come from
# PostgreSQL, older rows from the ClickHouse archive
curl "https://indexer.tempo.xyz/query \
  ?chainId=4217 \
  &signature=Transfer(address,address,uint256) \
  &sql=SELECT * FROM Transfer WHERE from = '0x...' ORDER BY block_num DESC LIMIT 10"

# ClickHouse (OLAP) - same query, faster for large scans
curl "https://indexer.tempo.xyz/query \
  ?chainId=4217 \
  &engine=clickhouse \
  &signature=Transfer(address,address,uint256) \
  &sql=SELECT * FROM Transfer WHERE from = '0x...' ORDER BY block_num DESC LIMIT 10"

# ClickHouse (OLAP) - query pre-computed views
curl "https://indexer.tempo.xyz/views?chainId=4217"
> {"ok":true,"views":[{"name":"top_holders","columns":[{"name":"token","type":"String"},{"name":"holder","type":"String"},{"name":"balance","type":"UInt256"}]}]}

curl "https://indexer.tempo.xyz/query \
  ?chainId=4217 \
  &engine=clickhouse \
  &sql=SELECT * FROM top_holders WHERE token = '0x...' LIMIT 10"

Installation

Docker

docker pull ghcr.io/tempoxyz/tidx:latest
docker run -v $(pwd)/config.toml:/config.toml ghcr.io/tempoxyz/tidx up

From Source

git clone https://github.com/tempoxyz/tidx
cd tidx
cargo build --release

Configuration

tidx uses a config.toml file to configure the indexer.

Example

# config.toml

[http]
enabled = true
port = 8080
bind = "0.0.0.0"
trusted_cidrs = ["100.64.0.0/10"]   # Optional: trusted IPs for admin operations (e.g., Tailscale)

[prometheus]
enabled = true
port = 9090

[[chains]]
name = "mainnet"
chain_id = 4217
rpc_url = "https://rpc.tempo.xyz"
batch_size = 100

[chains.postgres]
url = "postgres://user@tidx.example.com:5432/tidx_mainnet"
password_env = "TIDX_PG_PASSWORD"  # Password from environment variable

# Optional: ClickHouse for OLAP queries
[chains.clickhouse]
enabled = true
url = "http://clickhouse:8123"
# Historical materialized-table repair runs after base ClickHouse backfill by default.
repair_derived_on_startup = true

# Optional: tiered storage. Keeps a 30d hot window in PostgreSQL and prunes
# older rows once they are durable in the ClickHouse archive.
[chains.retention]
pg_keep = "30d"

[[chains]]
name = "moderato"
chain_id = 42431
rpc_url = "https://rpc.testnet.tempo.xyz"

[chains.postgres]
url = "postgres://user@tidx.example.com:5432/tidx_moderato"
password_env = "TIDX_PG_PASSWORD"

Reference

[http]                                             HTTP server configuration
├── enabled                 bool      = true         Enable HTTP API server
├── port                    u16       = 8080         HTTP server port
├── bind                    string    = "0.0.0.0"    Bind address
└── trusted_cidrs           string[]  = []           Trusted CIDRs for admin ops (e.g., Tailscale)

[prometheus]                                       Prometheus metrics server
├── enabled                 bool      = true         Enable metrics endpoint
└── port                    u16       = 9090         Metrics server port

[[chains]]                                         Chain configuration 
├── name                    string    (required)     Display name for logging
├── chain_id                u64       (required)     Chain ID
├── rpc_url                 string    (required)     JSON-RPC endpoint URL
├── batch_size              u64       = 100          Blocks per RPC batch request
├── [postgres]                                       PostgreSQL settings
│   ├── url                 string    (required)     PostgreSQL connection string
│   ├── password_env        string    (optional)     Env var name for PostgreSQL password
│   ├── api_url             string    (optional)     Separate PostgreSQL URL for API (e.g., read replica)
│   └── api_password_env    string    (optional)     Env var name for API PostgreSQL password
├── [clickhouse]                                     ClickHouse OLAP settings
│   ├── enabled             bool      = false        Enable ClickHouse OLAP queries
│   ├── url                 string    = "http://clickhouse:8123"  ClickHouse HTTP URL
│   ├── fdw_url             string    = url          ClickHouse URL as reachable from PostgreSQL (pg_clickhouse FDW)
│   ├── repair_derived_on_startup bool = true        Repair historical derived-table gaps on startup
│   └── replicated_database bool      = false        Create the database as ENGINE = Replicated with Replicated* table engines
└── [retention]                                      Tiered storage (PostgreSQL hot window)
    ├── pg_keep             string    (required)     Recent data kept in PostgreSQL, e.g. "30d" or "36h"
    ├── prune_interval      string    = "1h"         How often the pruner runs
    └── require_clickhouse  bool      = true         Only prune rows durably archived in ClickHouse

CLI

Usage: tidx <COMMAND>

Commands:
  init         Initialize a new config.toml
  up           Start syncing blocks from the chain (continuous) and serve HTTP API
  status       Show sync status
  query        Run a SQL query (use --signature to decode event logs)
  views        Manage ClickHouse materialized views
  upgrade      Update tidx to the latest version
  help         Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

tidx init

Initialize a new config.toml

Usage: tidx init [OPTIONS]

Options:
  -o, --output <OUTPUT>  Output path for config file [default: config.toml]
      --force            Overwrite existing config file
  -h, --help             Print help

tidx up

Start syncing blocks from the chain (continuous) and serve HTTP API

Usage: tidx up [OPTIONS]

Options:
  -c, --config <CONFIG>  Path to config file [default: config.toml]
  -h, --help             Print help

tidx status

Show sync status

Usage: tidx status [OPTIONS]

Options:
  -c, --config <CONFIG>  Path to config file [default: config.toml]
  -w, --watch            Watch mode - continuously update status
      --json             Output as JSON
  -h, --help             Print help

tidx query

Run a SQL query (use --signature to decode event logs)

Usage: tidx query [OPTIONS] <SQL>

Arguments:
  <SQL>  SQL query (SELECT only). Use event name from --signature as table

Options:
  -u, --url <URL>              TIDX HTTP API URL (e.g., http://localhost:8080)
  -n, --chain-id <CHAIN_ID>   Chain ID to query (uses first chain if not specified)
  -e, --engine <ENGINE>        Query engine (postgres, clickhouse)
      --source <SOURCE>        Data source (postgres, clickhouse, postgres-clickhouse) [default: postgres-clickhouse]
  -f, --format <FORMAT>        Output format (table, json, csv, toon) [default: table]
  -l, --limit <LIMIT>          Maximum rows to return [default: 10000]
  -s, --signature <SIGNATURE>  Event signature to create a CTE
  -t, --timeout <TIMEOUT>      Query timeout in milliseconds [default: 30000]
  -c, --config <CONFIG>        Path to config file [default: config.toml]
  -h, --help                   Print help

tidx views

Manage ClickHouse materialized views

Usage: tidx views --url <URL> <COMMAND>

Commands:
  list    List all views for a chain
  get     Get view details
  create  Create a new materialized view
  delete  Delete a view

Options:
      --url <URL>  TIDX HTTP API URL [env: TIDX_URL]
  -h, --help       Print help

tidx upgrade

Update tidx to the latest version

Usage: tidx upgrade

Downloads and replaces the current binary from GitHub releases.

Examples

# Start with config
tidx up --config config.toml

# Watch sync status (updates every second)
tidx status --watch

# Run SQL query
tidx query "SELECT COUNT(*) FROM txs"

# Query with event decoding
tidx query \
  --signature "Transfer(address indexed from, address indexed to, uint256 value)" \
  "SELECT * FROM Transfer LIMIT 10"

# List views on the hosted mainnet indexer
tidx views --url https://indexer.tempo.xyz list --chain-id 4217

# Create a view (must be run from trusted IP)
tidx views --url http://localhost:8080 create \
  --chain-id 4217 \
  --name top_holders \
  --sql "SELECT holder, SUM(balance) as total FROM balances GROUP BY holder" \
  --order-by holder

# Self-update
tidx upgrade

HTTP API

tidx exposes a HTTP API for querying the indexer.

Hosted endpoints:

  • Mainnet: https://indexer.tempo.xyz (chainId=4217)
  • Testnet: https://indexer.testnet.tempo.xyz (chainId=42431)

Examples

# Point lookup (tiered by default: hot rows from PostgreSQL, archived rows from ClickHouse)
curl "https://indexer.tempo.xyz/query?chainId=4217&sql=SELECT * FROM blocks WHERE num = 12345"
> {"columns":["num","hash","timestamp"],"rows":[[12345,"0xabc...","2024-01-01T00:00:00Z"]],"row_count":1,"engine":"tiered","ok":true}

# Aggregation (native ClickHouse)
curl "https://indexer.tempo.xyz/query?chainId=4217&engine=clickhouse&sql=SELECT type, COUNT(*) FROM txs GROUP BY type"
> {"columns":["type","count"],"rows":[[0,50000],[2,120000]],"row_count":2,"engine":"clickhouse","ok":true}

# Status
curl http://localhost:8080/status
> {"ok":true,"chains":[{"chain_id":4217,"synced_num":567890,"head_num":567890,"lag":0}]}

Reference

GET  /health                                             Health check
GET  /status                                             Sync status for all chains
GET  /query                                              Execute SQL query
     ?sql                   string    (required)         SQL query (SELECT only)
     ?chainId               number    (required)         Chain ID to query
     ?signature             string                       Event signature for CTE generation
     ?engine                string    = postgres         Query engine: postgres or clickhouse
     ?source                string    = postgres-clickhouse  Data source: postgres, clickhouse, or postgres-clickhouse
     ?live                  bool      = false            Enable SSE streaming (postgres only)
GET  /views?chainId=                                     List materialized views
GET  /views/{name}?chainId=                              Get view details
POST /views                                              Create view (trusted IP only)
DELETE /views/{name}?chainId=                            Delete view (trusted IP only)
GET  /metrics                                            Prometheus metrics

Views API

Manage ClickHouse materialized views for pre-computed analytics. Views are stored in analytics_{chainId} database and auto-update on new data.

Note: POST and DELETE require connection from a trusted IP (configured via trusted_cidrs).

List Views

curl "https://indexer.testnet.tempo.xyz/views?chainId=42431"
{
  "ok": true,
  "views": [
    {
      "name": "whale_holders",
      "engine": "MaterializedView",
      "database": "analytics_42431",
      "columns": [
        {"name": "token", "type": "String"},
        {"name": "holder", "type": "String"},
        {"name": "balance", "type": "UInt256"}
      ]
    }
  ]
}

Create View (trusted IP only)

curl -X POST "http://localhost:8080/views" \
  -H "Content-Type: application/json" \
  -d '{
    "chainId": 42431,
    "name": "whale_holders",
    "sql": "SELECT token, holder, sum(balance) AS balance FROM token_balances GROUP BY token, holder HAVING balance > 0",
    "orderBy": ["token", "holder"]
  }'
FieldRequiredDescription
chainIdyesTarget chain ID
nameyesView name (alphanumeric + underscore)
sqlyesSELECT statement for the view
orderByyesPrimary key columns for table sorting
enginenoClickHouse engine (default: SummingMergeTree())

This creates:

  1. Target table analytics_{chainId}.{name} with inferred schema
  2. Materialized view analytics_{chainId}.{name}_mv that auto-populates on inserts
  3. Backfills existing data from the source query

Get View Details

curl "https://indexer.testnet.tempo.xyz/views/whale_holders?chainId=42431"
{
  "ok": true,
  "view": {"name": "whale_holders", "engine": "View", "database": "analytics_42431"},
  "definition": "CREATE VIEW analytics_42431.whale_holders AS SELECT ...",
  "row_count": 1234567
}

Delete View (trusted IP only)

curl -X DELETE "http://localhost:8080/views/whale_holders?chainId=42431"
{
  "ok": true,
  "deleted": ["token_holders_mv", "whale_holders"]
}

Query Views

Views are auto-prefixed with analytics_{chainId} when using engine=clickhouse:

# Query the view (auto-prefixed)
curl "https://indexer.testnet.tempo.xyz/query?chainId=42431&engine=clickhouse&sql=SELECT * FROM whale_holders WHERE token = '0x...' ORDER BY balance DESC LIMIT 10"

Tables

Three families of tables are queryable through /query:

  • Base Tables — raw chain data written by the sync engine, available in both PostgreSQL and ClickHouse.
  • Event Tables — virtual, decoded-at-query-time tables generated from ?signature=Event(...). Available in both engines.
  • Materialized Tables — precomputed token, holder, and DEX analytics maintained by ClickHouse. Available only with engine=clickhouse, alongside user-defined views registered through the /views API.

Base Tables

Written by the sync engine to both PostgreSQL and ClickHouse. Schemas are identical across engines; the per-table column names below are the source of truth.

blocks

ColumnTypeDescription
numINT8Block number
hashBYTEABlock hash
parent_hashBYTEAParent block hash
timestampTIMESTAMPTZBlock timestamp
timestamp_msINT8Block timestamp (milliseconds)
gas_limitINT8Gas limit
gas_usedINT8Gas used
minerBYTEABlock producer
extra_dataBYTEAExtra data field
consensus_proposerBYTEAEd25519 consensus proposer pubkey (TIP-1031, NULL pre-fork)

txs

ColumnTypeDescription
block_numINT8Block number
block_timestampTIMESTAMPTZBlock timestamp
idxINT4Transaction index
hashBYTEATransaction hash
typeINT2Transaction type
fromBYTEASender address
toBYTEARecipient address
valueTEXTTransfer value (wei)
inputBYTEACalldata
gas_limitINT8Gas limit
max_fee_per_gasTEXTMax fee per gas
max_priority_fee_per_gasTEXTMax priority fee
gas_usedINT8Gas consumed
nonce_keyBYTEANonce key (2D nonces)
nonceINT8Nonce value
fee_tokenBYTEAFee token address
callsJSONBBatch call data
call_countINT2Number of calls
valid_beforeINT8Validity window start
valid_afterINT8Validity window end
signature_typeINT2Signature type

logs

ColumnTypeDescription
block_numINT8Block number
block_timestampTIMESTAMPTZBlock timestamp
log_idxINT4Log index
tx_idxINT4Transaction index
tx_hashBYTEATransaction hash
addressBYTEAEmitting contract
selectorBYTEAEvent selector (topic0)
topicsBYTEA[]All topics
dataBYTEAEvent data

receipts

ColumnTypeDescription
block_numINT8Block number
block_timestampTIMESTAMPTZBlock timestamp
tx_idxINT4Transaction index
tx_hashBYTEATransaction hash
fromBYTEASender address
toBYTEARecipient address
contract_addressBYTEACreated contract (if deploy)
gas_usedINT8Gas consumed
cumulative_gas_usedINT8Cumulative gas in block
effective_gas_priceTEXTActual gas price paid
statusINT2Success (1) or failure (0)
fee_payerBYTEATempo fee payer (if sponsored)

The ClickHouse mirror of receipts additionally carries type (Nullable(Int16)) and fee_token (Nullable(String)), denormalized from the matching tx at ingest so receipt queries don't have to join txs. Rows written before the receipts_20260604_type_fee_token migration stay NULL until re-synced.

sync_state

ColumnTypeDescription
chain_idINT8Chain identifier
head_numINT8Remote chain head from RPC
synced_numINT8Highest contiguous block (no gaps from backfill_num to here)
tip_numINT8Highest block near chain head (realtime follows this)
backfill_numINT8Lowest synced block going backwards (NULL=not started, 0=complete)
started_atTIMESTAMPTZSync start time
updated_atTIMESTAMPTZLast update time

Event Tables

Pass ?signature=Event(type1,type2,...) to /query and tidx exposes a virtual table named after the event with one column per parameter. The table is generated as a CTE at query time, so no schema registration is needed and any event signature works on demand. Works against both engine=postgres and engine=clickhouse:

curl -G "https://indexer.tempo.xyz/query" \
  --data-urlencode "chainId=4217" \
  --data-urlencode "signature=Transfer(address,address,uint256)" \
  --data-urlencode "sql=SELECT \"from\", \"to\", value
    FROM Transfer
    WHERE \"from\" = '0xabc…'
    ORDER BY block_num DESC
    LIMIT 10"

For Transfer logs specifically, token_transfers is pre-decoded and cheaper.

Materialized Tables

Note

All tables in this section are ClickHouse-only. Query them with engine=clickhouse.

ClickHouse maintains insert-time derived tables and scheduled snapshots, pruning block-scoped rows on reorg. Token-first and holder-first balance data use separate sort keys for their inverse access patterns.

On startup, tidx verifies built-in materialized tables after base ClickHouse backfill is caught up. It compares each table against its source SELECT in bounded block ranges, logs detected gaps, and replays only the missing ranges in chunks while realtime sync continues. Failed derived repairs are retried with backoff. Set repair_derived_on_startup = false under [chains.clickhouse] only if an operator needs to suppress historical repair work temporarily.

For self-hosted multi-replica ClickHouse (coordinated by Keeper/ZooKeeper), set replicated_database = true under [chains.clickhouse]. The sink then creates the database with ENGINE = Replicated and rewrites MergeTree-family table engines to their Replicated* counterparts, so both DDL and data reach every replica. Leave it off for ClickHouse Cloud (replication is implicit) and single-node instances. The flag only affects newly created objects — point it at a fresh database when enabling replication for an existing chain.

NamePurpose
address_balancesCurrent positive balance per (holder, token).
address_balances_snapshotPre-aggregated address_balances, refreshed on a schedule.
address_holder_deltasHolder-first mirror of token_holder_deltas.
dex_fillsDecoded stablecoin-DEX OrderFilled events.
dex_ohlc_1m1-minute OHLC candles per pair, refreshed on a schedule.
dex_ordersDecoded stablecoin-DEX OrderPlaced events.
dex_pair_liquidityPairs joined to their on-DEX base liquidity.
dex_pairsDecoded stablecoin-DEX PairCreated events.
token_balancesCurrent positive balance per (token, holder).
token_balances_snapshotPre-aggregated token_balances, refreshed on a schedule.
token_holder_countsHolder count per token, refreshed on a schedule.
token_holder_deltasPer-event ± balance change, two rows per transfer.
token_metadataPer-token first/last seen + lifetime transfer count.
token_supplyPer-token mints − burns (zero-address legs).
token_transfer_statsPer-(day, token) count, volume, unique senders/recipients.
token_transfersDecoded Transfer events.

User-defined views registered through the /views API live alongside these in analytics_{chainId} and are queryable the same way.

address_balances

Note

View over address_holder_deltas FINAL, grouped by (holder, token).

Current positive balances grouped by holder first — answers "what does this address hold?" in one sort-key range. token_balances answers the inverse "who holds this token?" — same underlying transfer events, two different sort orders.

ColumnTypeDescription
holderStringHolder address
tokenStringToken contract
balanceInt256Current balance (positive only)
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT token, toString(balance) AS balance
    FROM address_balances
    WHERE holder = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8'
    ORDER BY balance DESC"

address_balances_snapshot

Note

Refreshable materialized view over address_holder_deltas FINAL, recomputed on a schedule (every 15 minutes) rather than on insert.

Same (holder, token, balance) rollup as address_balances, but stored in its own MergeTree ordered by (holder, balance, token) so account balance pages and counts resolve via a holder-keyed range instead of re-aggregating the full delta history on every read. Use this for hot addresses where the live address_balances view would time out; results are up to one refresh interval stale.

ColumnTypeDescription
holderStringHolder address
tokenStringToken contract
balanceInt256Current balance (positive only)
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT token, toString(balance) AS balance
    FROM address_balances_snapshot
    WHERE holder = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8'
    ORDER BY balance DESC
    LIMIT 5"

address_holder_deltas

Note

Materialized view over token_transfers, kept in sync on reorg.

Same deltas as token_holder_deltas but ordered (holder, token, block_num, …) so per-holder balance reconstructions are a sort-key seek. Two rows per transfer (recipient leg=+1, sender leg=-1); skips zero-address legs.

ColumnTypeDescription
block_numInt64Block number
block_timestampDateTime64(3, 'UTC')Block timestamp
tx_hashStringTransaction hash
log_idxInt32Log index
holderStringHolder address
tokenStringToken contract
legInt8+1 for credit, -1 for debit
balance_deltaInt256Signed delta applied to (holder, token)

dex_pairs

Note

Materialized view over logs, kept in sync on reorg.

Decoded PairCreated(bytes32 indexed key, address indexed base, address indexed quote) events from the stablecoin DEX precompile. All three params are indexed, so the payload lives in topics. ReplacingMergeTree ordered by (block_num, log_idx) for the pair-creation feed, with bloom indexes on base/quote. Replaces the runtime signature=PairCreated(...) CTE.

ColumnTypeDescription
block_numInt64Block number
block_timestampDateTime64(3, 'UTC')Block timestamp
tx_idxInt32Transaction index
log_idxInt32Log index
tx_hashStringTransaction hash
addressStringEmitting contract (the DEX precompile)
keyStringPair key (bytes32)
baseStringBase token address
quoteStringQuote token address

dex_orders

Note

Materialized view over logs, kept in sync on reorg.

Decoded OrderPlaced(uint128 indexed orderId, address indexed maker, address indexed token, uint128 amount, bool isBid, int16 tick, bool isFlipOrder, int16 flipTick) events. int16 ticks decode from their sign-extended ABI word (trailing 2 bytes, little-endian) so negatives are correct. ReplacingMergeTree ordered by (token, orderId) so pair-scoped filters (WHERE token = base) seek by sort key and joins by orderId use the bloom index.

ColumnTypeDescription
block_numInt64Block number
block_timestampDateTime64(3, 'UTC')Block timestamp
tx_idxInt32Transaction index
log_idxInt32Log index
tx_hashStringTransaction hash
addressStringEmitting contract (the DEX precompile)
orderIdUInt256Order id
makerStringOrder maker
tokenStringPair base token
amountUInt256Order amount
isBidUInt81 = bid, 0 = ask
tickInt16Order price tick
isFlipOrderUInt81 = flip order
flipTickInt16Flip order price tick

dex_fills

Note

Materialized view over logs, kept in sync on reorg.

Decoded OrderFilled(uint128 indexed orderId, address indexed maker, address indexed taker, uint128 amountFilled, bool partialFill) events. The pair (token/isBid/tick) is not on this event — join orderId to dex_orders. ReplacingMergeTree ordered by (orderId, block_num, log_idx) so the join probes by sort key, with bloom indexes on maker/taker/tx_hash.

ColumnTypeDescription
block_numInt64Block number
block_timestampDateTime64(3, 'UTC')Block timestamp
tx_idxInt32Transaction index
log_idxInt32Log index
tx_hashStringTransaction hash
addressStringEmitting contract (the DEX precompile)
orderIdUInt256Order id (join key to dex_orders)
makerStringFill maker
takerStringFill taker
amountFilledUInt256Amount filled
partialFillUInt81 = partial fill
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT f.block_num, f.amountFilled, o.token, o.isBid, o.tick
    FROM dex_fills f
    JOIN dex_orders o ON o.orderId = f.orderId
    WHERE o.token = '0x20c000000000000000000000b9537d11c60e8b50'
    ORDER BY f.block_num DESC, f.log_idx DESC
    LIMIT 10"

dex_ohlc_1m

Note

Refreshable materialized view over dex_fills ⋈ dex_orders, recomputed on a schedule (every minute) over a rolling 30-day retention window.

1-minute OHLC (candlestick) buckets per trading pair, stored in a MergeTree ordered by (token, bucket). Replaces scanning up to ~1000 raw fills and bucketing in memory on every chart request: a read becomes a primary-key range scan over candles, and long windows are no longer truncated. Coarser intervals (5m, 1h, …) re-bucket these 1m candles client-side (high = max(high), low = min(low), open = first bucket's open, close = last bucket's close, volumes summed); historical ranges beyond the retention window fall back to scanning dex_fills.

Refreshable (full recompute + atomic swap) because candles are keyed by (token, bucket) with no block_num, so an incremental aggregate couldn't be reorg-pruned — same reasoning as token_balances_snapshot. Price math mirrors the API: priceScale = 100000, rate = (priceScale + tick) / priceScale for bids and its inverse for asks. Tune REFRESH EVERY and the retention window to fill volume.

ColumnTypeDescription
bucketDateTime('UTC')Start of the 1-minute bucket
tokenStringPair base token
openFloat64Rate of the first fill in the bucket
highFloat64Highest fill rate in the bucket
lowFloat64Lowest fill rate in the bucket
closeFloat64Rate of the last fill in the bucket
base_volumeUInt256Sum of base-token amounts filled
quote_volumeUInt256Sum of quote-token amounts
fill_countUInt64Number of fills in the bucket
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT bucket, open, high, low, close, base_volume
    FROM dex_ohlc_1m
    WHERE token = '0x20c000000000000000000000b9537d11c60e8b50'
      AND bucket >= now() - INTERVAL 6 HOUR
    ORDER BY bucket"

dex_pair_liquidity

Note

Plain view over dex_pairs FINAL ⋈ token_balances_snapshot.

Trading pairs joined to their on-DEX base-token liquidity. The DEX precompile escrows both base and quote tokens, but only base addresses map to a pair; this view pushes that intersection into ClickHouse so the "pairs by liquidity" endpoint reads ranked pairs directly (ORDER BY liquidity DESC, base ASC LIMIT …) instead of over-fetching DEX balances and intersecting with the pair set in memory. The DEX precompile address (0xdec0…0000) is fixed across Tempo chains and inlined.

ColumnTypeDescription
keyStringPair key (bytes32)
baseStringBase token address
quoteStringQuote token address
block_numInt64Pair creation block
log_idxInt32Pair creation log index
block_timestampDateTime64(3, 'UTC')Pair creation timestamp
tx_hashStringPair creation tx hash
liquidityInt256Base-token balance escrowed on the DEX
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT base, quote, liquidity
    FROM dex_pair_liquidity
    ORDER BY liquidity DESC, base ASC
    LIMIT 20"

token_balances

Note

View over token_holder_deltas FINAL, always reflects the post-merge state.

Current positive balance per (token, holder), rolled up from token_holder_deltas. Filtered to balance > 0.

ColumnTypeDescription
tokenStringToken contract
holderStringHolder address
balanceInt256Current balance (positive only)
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT holder, toString(balance) AS balance
    FROM token_balances
    WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'
    ORDER BY balance DESC
    LIMIT 5"
{"ok":true,"columns":["holder","balance"],"rows":[
  ["0x70997970c51812dc3a010c7d01b50e0d17dc79c8","68056473384187692692674921486353642324"],
  ["0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc","68056473384187692692674921486353642370"],
  ["0x15d34aaf54267db7d7c367839aaf71a00a2c6a65","68056473384187692692674921486353642328"],
  ["0x90f79bf6eb2c4f870365e785982e1f101e93b906","68056473384187692692674921486353642286"],
  ["0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","68056473384187692692674921486353642272"]
]}

token_balances_snapshot

Note

Refreshable materialized view over token_holder_deltas FINAL, recomputed on a schedule (every 15 minutes) rather than on insert.

Same (token, holder, balance) rollup as token_balances, but stored in its own MergeTree ordered by (token, balance) so holder counts and "top holders by balance" resolve via a sort-key seek instead of re-aggregating the full delta history on every read. Use this for high-cardinality tokens where the live token_balances view would time out; results are up to one refresh interval stale.

ColumnTypeDescription
tokenStringToken contract
holderStringHolder address
balanceInt256Current balance (positive only)
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT count() AS holders
    FROM token_balances_snapshot
    WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'"

token_holder_counts

Note

Refreshable materialized view over token_balances_snapshot, recomputed on a schedule (every 15 minutes) rather than on insert.

Holder count per token, collapsed to a single row per token and stored in its own MergeTree ordered by (token). count() … FROM token_balances_snapshot WHERE token = X is a primary-key range scan that still touches one row per holder (millions for tokens like PathUSD); this turns the token-detail "holder count" into a single-row primary-key lookup. Derived from the already-deduped snapshot rather than the raw deltas, so each refresh is cheap; counts are at most one refresh interval staler than the snapshot.

ColumnTypeDescription
tokenStringToken contract
holder_countUInt64Number of holders with a positive balance
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT holder_count
    FROM token_holder_counts
    WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'"

token_holder_deltas

Note

Materialized view over token_transfers, kept in sync on reorg.

Two rows per transfer (recipient leg=+1, sender leg=-1); skips zero-address legs. ReplacingMergeTree deduplicates by (token, holder, block_num, tx_hash, log_idx, leg) so retried inserts collapse on merge.

ColumnTypeDescription
block_numInt64Block number
block_timestampDateTime64(3, 'UTC')Block timestamp
tx_hashStringTransaction hash
log_idxInt32Log index
tokenStringToken contract
holderStringHolder address (sender or recipient)
legInt8+1 for recipient credit, -1 for sender debit
balance_deltaInt256Signed delta applied to holder for token at this block
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT block_num, toString(balance_delta) AS delta, leg
    FROM token_holder_deltas
    WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'
      AND holder = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8'
    ORDER BY block_num DESC, log_idx DESC
    LIMIT 5"
{"ok":true,"columns":["block_num","delta","leg"],"rows":[
  [1062,"-17431",-1],
  [1059,"42",1],
  [1051,"-180",-1],
  [1043,"500",1],
  [1027,"-23",-1]
]}

To reconstruct a holder's balance at block N:

SELECT sum(balance_delta)
FROM token_holder_deltas FINAL
WHERE token = '0x…' AND holder = '0x…' AND block_num <= 1050

token_metadata

Note

View over token_transfers FINAL, aggregated per token.

Discovery / activity rollup per token contract. Every token that has ever emitted a Transfer shows up here with first/last seen block and timestamp plus a lifetime transfer count. Pair with a verified-tokens allowlist at query time to power /tokens listings.

ColumnTypeDescription
tokenStringToken contract
first_seen_blockInt64Block of the first observed Transfer
last_seen_blockInt64Block of the most recent Transfer
first_seen_timestampDateTime64(3, 'UTC')Timestamp of the first Transfer
last_seen_timestampDateTime64(3, 'UTC')Timestamp of the most recent Transfer
transfer_countUInt64Total Transfer events ever emitted by this token
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT token, transfer_count, first_seen_block, last_seen_block
    FROM token_metadata
    ORDER BY transfer_count DESC
    LIMIT 5"

token_supply

Note

View over token_transfers FINAL, computes net mints minus burns from zero-address legs.

Outstanding supply per token, derived from Transfer events whose sender or recipient is 0x0. Mints (from = 0x0) add to supply, burns (to = 0x0) subtract. Filtered to supply > 0.

ColumnTypeDescription
tokenStringToken contract
supplyInt256Cumulative mints − cumulative burns
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT token, toString(supply) AS supply
    FROM token_supply
    ORDER BY supply DESC
    LIMIT 5"
{"ok":true,"columns":["token","supply"],"rows":[
  ["0x20c000000000000000000000e65cb5a40b7885ae","340282366920938463463374607431768211455"],
  ["0x20c0000000000000000000003f9a1b2c4d5e6f70","100000000000000000000000"]
]}

token_transfer_stats

Note

View over token_transfers FINAL, aggregated per (day, token).

Per-day per-token rollups of transfer activity. Aggregated at query time so reorgs and retries are inherited from the underlying token_transfers table.

ColumnTypeDescription
dayDateDay (UTC) bucket from block_timestamp
tokenStringToken contract
transfer_countUInt64Number of Transfer events on that day
volumeUInt256Sum of amount across all transfers on that day
unique_sendersUInt64Distinct from addresses on that day
unique_recipientsUInt64Distinct to addresses on that day
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT day, transfer_count, toString(volume) AS volume, unique_senders, unique_recipients
    FROM token_transfer_stats
    WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'
    ORDER BY day DESC
    LIMIT 5"
{"ok":true,"columns":["day","transfer_count","volume","unique_senders","unique_recipients"],"rows":[
  ["2026-05-27",18342,"984320012345678901234567",1024,987],
  ["2026-05-26",17211,"872100012345678901234567",1011,973],
  ["2026-05-25",16982,"851234012345678901234567",998,961]
]}

token_transfers

Note

Materialized view over logs, kept in sync on reorg.

One row per canonical Transfer(address,address,uint256) log, decoded at insert time. ReplacingMergeTree keyed on (token, block_num, log_idx, tx_hash).

ColumnTypeDescription
block_numInt64Block number
block_timestampDateTime64(3, 'UTC')Block timestamp
tx_idxInt32Transaction index
log_idxInt32Log index
tx_hashStringTransaction hash
tokenStringEmitting token contract
fromStringSender address
toStringRecipient address
amountUInt256Transfer amount
is_virtual_forwardUInt81 if this transfer was inserted via a virtual-forward path
curl -G "https://indexer.testnet.tempo.xyz/query" \
  --data-urlencode "chainId=42431" \
  --data-urlencode "engine=clickhouse" \
  --data-urlencode "sql=SELECT token, \`from\`, \`to\`, toString(amount) AS amount, tx_hash
    FROM token_transfers
    WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'
    ORDER BY block_num DESC, log_idx DESC
    LIMIT 3"
{"ok":true,"columns":["token","from","to","amount","tx_hash"],"rows":[
  ["0x20c000000000000000000000e65cb5a40b7885ae","0x70997970c51812dc3a010c7d01b50e0d17dc79c8","0xfeec000000000000000000000000000000000000","17431","0x9d…ab"],
  ["0x20c000000000000000000000e65cb5a40b7885ae","0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc","0x70997970c51812dc3a010c7d01b50e0d17dc79c8","42","0x77…3c"],
  ["0x20c000000000000000000000e65cb5a40b7885ae","0x90f79bf6eb2c4f870365e785982e1f101e93b906","0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc","99","0x12…ef"]
]}

Sync Architecture

tidx uses two concurrent sync operations: Realtime follows the chain head, while Gap Sync fills all missing blocks from most recent to earliest.

Block Numbers:  0                                                              HEAD
                │                                                                │
                ▼                                                                ▼
    ════════════╪════════════════════════════════════════════════════════════════╪═══▶ time
                │                                                                │
    INDEXED:    ░░░░░░░░░░░████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░██████████
                │          │               │                           │        │
                ▼          ▼               ▼                           ▼        ▼
              genesis    gap 2           gap 1                      tip_num   head_num
               (0)     (fills 2nd)    (fills 1st)                   (1900)    (2000)
                │                                                              │
                │◄─────────────────── GAP SYNC ───────────────────────────────►│
                │           Fills ALL gaps, most recent first                  │
                │                                                    └─────────┘
                │                                                     REALTIME
                │                                                  (following head)

                └─── Eventually reaches genesis (block 0)

Legend:
  ████  = indexed blocks
  ░░░░  = gaps (missing blocks)
OperationDescription
RealtimeFollows chain head immediately, maintains ~0 lag
Gap SyncDetects all gaps, fills from most recent to earliest

Gap sync finds discontinuities via SQL and adds the gap from genesis to the first synced block. Gaps are sorted by end block descending (most recent first) and filled one at a time. Recent gaps are prioritized so users can query recent data during initial sync.

Development

Prerequisites

Make Commands

make up                Start services (use LOCALNET=1 for localnet)
make down              Stop all services
make logs              Tail indexer logs
make build             Build Docker image
make seed              Generate transactions

make bench             Run benchmarks
make check             Run clippy lints
make test              Run tests

make clean             Stop services and clean

License

LICENSE

Acknowledgments