Quickstart • Installation • Configuration • CLI • API • Queries
tidx indexes Tempo chain data into a hybrid PostgreSQL + ClickHouse architecture for fast point lookups (OLTP) and lightning-fast analytics (OLAP).
- Dual Storage — PostgreSQL (OLTP) + ClickHouse (OLAP), written in parallel
- 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
- Quickstart
- Overview
- Installation
- Configuration
- CLI
- HTTP API
- Tables
- Sync Architecture
- Development
- License
curl -L https://tidx.vercel.app/docker | bashThe sync engine writes to both PostgreSQL and ClickHouse in parallel. Use the engine query parameter to choose which backend to query:
┌─────────────────────┐
│ /query │
│ │
│ ?signature=... │◄─── Lazy event decoding
│ ?engine=... │ (no pre-registration)
└──────────┬──────────┘
│
┌──────────────────────────────────────────┼──────────────────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ PostgreSQL │ │ ClickHouse │ │ Materialized Views │
│ (OLTP) │ │ (OLAP) │ ─────────────────► │ (auto-updated) │
│ │ │ │ │ │
└─────────┬───────────┘ └─────────┬───────────┘ └─────────────────────┘
│ │
└──────────────────┬───────────────────────┘
│
┌───────┴───────┐
│ Dual Sink │
└───────┬───────┘
│
┌───────┴───────┐
│ Sync Engine │
└───────────────┘
# PostgreSQL (OLTP) - last 10 transfers from an address
curl "https://tidx.example.com/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://tidx.example.com/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://tidx.example.com/views?chainId=4217"
> {"ok":true,"views":[{"name":"top_holders","columns":[{"name":"token","type":"String"},{"name":"holder","type":"String"},{"name":"balance","type":"UInt256"}]}]}
curl "https://tidx.example.com/query \
?chainId=4217 \
&engine=clickhouse \
&sql=SELECT * FROM top_holders WHERE token = '0x...' LIMIT 10"docker pull ghcr.io/tempoxyz/tidx:latest
docker run -v $(pwd)/config.toml:/config.toml ghcr.io/tempoxyz/tidx upgit clone https://github.com/tempoxyz/tidx
cd tidx
cargo build --releasetidx uses a config.toml file to configure the indexer.
# 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"
pg_url = "postgres://user@tidx.example.com:5432/tidx_mainnet"
pg_password_env = "TIDX_PG_PASSWORD" # Password from environment variable
batch_size = 100
# Optional: ClickHouse for OLAP queries
[chains.clickhouse]
enabled = true
url = "http://clickhouse:8123"
[[chains]]
name = "moderato"
chain_id = 42431
rpc_url = "https://rpc.testnet.tempo.xyz"
pg_url = "postgres://user@tidx.example.com:5432/tidx_moderato"
pg_password_env = "TIDX_PG_PASSWORD"[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
├── pg_url string (required) PostgreSQL connection string
├── pg_password_env string (optional) Env var name for PostgreSQL password
├── api_pg_url string (optional) Separate PostgreSQL URL for API (e.g., read replica)
├── api_pg_password_env string (optional) Env var name for API PostgreSQL password
├── batch_size u64 = 100 Blocks per RPC batch request
└── [clickhouse] ClickHouse OLAP settings
├── enabled bool = false Enable ClickHouse OLAP queries
└── url string = "http://clickhouse:8123" ClickHouse HTTP URL
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
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
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
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
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> Force query engine (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
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
Update tidx to the latest version
Usage: tidx upgrade
Downloads and replaces the current binary from GitHub releases.
# 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
tidx views --url https://tidx.example.com list --chain-id 4217
# Create a view (must be run from trusted IP)
tidx views --url https://tidx.example.com 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 upgradetidx exposes a HTTP API for querying the indexer.
# Point lookup (auto-routed to PostgreSQL)
curl "https://tidx.example.com/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":"postgres","ok":true}
# Aggregation (auto-routed to ClickHouse)
curl "https://tidx.example.com/query?chainId=4217&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 https://tidx.example.com/status
> {"ok":true,"chains":[{"chain_id":4217,"synced_num":567890,"head_num":567890,"lag":0}]}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
?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
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).
curl "https://tidx.example.com/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"}
]
}
]
}curl -X POST "https://tidx.example.com/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"]
}'| Field | Required | Description |
|---|---|---|
chainId |
yes | Target chain ID |
name |
yes | View name (alphanumeric + underscore) |
sql |
yes | SELECT statement for the view |
orderBy |
yes | Primary key columns for table sorting |
engine |
no | ClickHouse engine (default: SummingMergeTree()) |
This creates:
- Target table
analytics_{chainId}.{name}with inferred schema - Materialized view
analytics_{chainId}.{name}_mvthat auto-populates on inserts - Backfills existing data from the source query
curl "https://tidx.example.com/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
}curl -X DELETE "https://tidx.example.com/views/whale_holders?chainId=42431"{
"ok": true,
"deleted": ["token_holders_mv", "whale_holders"]
}Views are auto-prefixed with analytics_{chainId} when using engine=clickhouse:
# Query the view (auto-prefixed)
curl "https://tidx.example.com/query?chainId=42431&engine=clickhouse&sql=SELECT * FROM whale_holders WHERE token = '0x...' ORDER BY balance DESC LIMIT 10"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, address- and token-keyed views maintained by ClickHouse on insert. Available only with
engine=clickhouse. Includes built-ins (token transfers/balances/supply/approvals/metadata, address transfers/balances/txs, contract creations) plus any user-defined views registered through the/viewsAPI.
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.
| Column | Type | Description |
|---|---|---|
num |
INT8 |
Block number |
hash |
BYTEA |
Block hash |
parent_hash |
BYTEA |
Parent block hash |
timestamp |
TIMESTAMPTZ |
Block timestamp |
timestamp_ms |
INT8 |
Block timestamp (milliseconds) |
gas_limit |
INT8 |
Gas limit |
gas_used |
INT8 |
Gas used |
miner |
BYTEA |
Block producer |
extra_data |
BYTEA |
Extra data field |
consensus_proposer |
BYTEA |
Ed25519 consensus proposer pubkey (TIP-1031, NULL pre-fork) |
| Column | Type | Description |
|---|---|---|
block_num |
INT8 |
Block number |
block_timestamp |
TIMESTAMPTZ |
Block timestamp |
idx |
INT4 |
Transaction index |
hash |
BYTEA |
Transaction hash |
type |
INT2 |
Transaction type |
from |
BYTEA |
Sender address |
to |
BYTEA |
Recipient address |
value |
TEXT |
Transfer value (wei) |
input |
BYTEA |
Calldata |
gas_limit |
INT8 |
Gas limit |
max_fee_per_gas |
TEXT |
Max fee per gas |
max_priority_fee_per_gas |
TEXT |
Max priority fee |
gas_used |
INT8 |
Gas consumed |
nonce_key |
BYTEA |
Nonce key (2D nonces) |
nonce |
INT8 |
Nonce value |
fee_token |
BYTEA |
Fee token address |
calls |
JSONB |
Batch call data |
call_count |
INT2 |
Number of calls |
valid_before |
INT8 |
Validity window start |
valid_after |
INT8 |
Validity window end |
signature_type |
INT2 |
Signature type |
| Column | Type | Description |
|---|---|---|
block_num |
INT8 |
Block number |
block_timestamp |
TIMESTAMPTZ |
Block timestamp |
log_idx |
INT4 |
Log index |
tx_idx |
INT4 |
Transaction index |
tx_hash |
BYTEA |
Transaction hash |
address |
BYTEA |
Emitting contract |
selector |
BYTEA |
Event selector (topic0) |
topics |
BYTEA[] |
All topics |
data |
BYTEA |
Event data |
| Column | Type | Description |
|---|---|---|
block_num |
INT8 |
Block number |
block_timestamp |
TIMESTAMPTZ |
Block timestamp |
tx_idx |
INT4 |
Transaction index |
tx_hash |
BYTEA |
Transaction hash |
from |
BYTEA |
Sender address |
to |
BYTEA |
Recipient address |
contract_address |
BYTEA |
Created contract (if deploy) |
gas_used |
INT8 |
Gas consumed |
cumulative_gas_used |
INT8 |
Cumulative gas in block |
effective_gas_price |
TEXT |
Actual gas price paid |
status |
INT2 |
Success (1) or failure (0) |
fee_payer |
BYTEA |
Tempo fee payer (if sponsored) |
| Column | Type | Description |
|---|---|---|
chain_id |
INT8 |
Chain identifier |
head_num |
INT8 |
Remote chain head from RPC |
synced_num |
INT8 |
Highest contiguous block (no gaps from backfill_num to here) |
tip_num |
INT8 |
Highest block near chain head (realtime follows this) |
backfill_num |
INT8 |
Lowest synced block going backwards (NULL=not started, 0=complete) |
started_at |
TIMESTAMPTZ |
Sync start time |
updated_at |
TIMESTAMPTZ |
Last update time |
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://tidx.example.com/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.
Note
All tables in this section are ClickHouse-only. Query them with engine=clickhouse.
ClickHouse maintains these on insert and prunes them on reorg. Token-keyed tables answer "for this token, …"; address-keyed tables answer "for this account, …". Both families read from the same underlying Transfer/tx/receipt streams — the duplication exists so that either filter resolves via a sort-key seek instead of a full scan.
| Name | Purpose |
|---|---|
address_balances |
Current positive balance per (holder, token). |
address_holder_deltas |
Holder-first mirror of token_holder_deltas. |
address_transfers |
Transfer feed keyed by account; 'in'/'out'. |
address_txs |
Tx feed keyed by account; 'from'/'to'. |
contract_creations |
One row per contract deployment. |
token_approvals |
Decoded Approval events. |
token_approvals_current |
Latest allowance per (token, owner, spender). |
token_balances |
Current positive balance per (token, holder). |
token_holder_deltas |
Per-event ± balance change, two rows per transfer. |
token_metadata |
Per-token first/last seen + lifetime transfer count. |
token_supply |
Per-token mints − burns (zero-address legs). |
token_transfer_stats |
Per-(day, token) count, volume, unique senders/recipients. |
token_transfers |
Decoded Transfer events. |
User-defined views registered through the /views API live alongside these in analytics_{chainId} and are queryable the same way.
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.
| Column | Type | Description |
|---|---|---|
holder |
String |
Holder address |
token |
String |
Token contract |
balance |
Int256 |
Current balance (positive only) |
curl -G "https://tidx.example.com/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"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.
| Column | Type | Description |
|---|---|---|
block_num |
Int64 |
Block number |
block_timestamp |
DateTime64(3, 'UTC') |
Block timestamp |
tx_hash |
String |
Transaction hash |
log_idx |
Int32 |
Log index |
holder |
String |
Holder address |
token |
String |
Token contract |
leg |
Int8 |
+1 for credit, -1 for debit |
balance_delta |
Int256 |
Signed delta applied to (holder, token) |
Note
Materialized view over token_transfers, kept in sync on reorg.
Address-keyed Transfer feed. Each Transfer produces up to two rows (one per non-zero side): an 'in' row for the recipient and an 'out' row for the sender. ReplacingMergeTree ordered by (address, block_num, log_idx, tx_hash, direction) so per-address pagination is a sort-key seek instead of a full scan. Zero-address legs are dropped — mints show up only on the recipient's 'in' side, burns only on the sender's 'out' side.
| Column | Type | Description |
|---|---|---|
block_num |
Int64 |
Block number |
block_timestamp |
DateTime64(3, 'UTC') |
Block timestamp |
tx_idx |
Int32 |
Transaction index |
log_idx |
Int32 |
Log index |
tx_hash |
String |
Transaction hash |
address |
String |
The account this row is scoped to |
direction |
LowCardinality(String) |
'in' or 'out' from address's perspective |
counterparty |
String |
The other side of the transfer (may be 0x0) |
token |
String |
Emitting token contract |
amount |
UInt256 |
Transfer amount |
curl -G "https://tidx.example.com/query" \
--data-urlencode "chainId=42431" \
--data-urlencode "engine=clickhouse" \
--data-urlencode "sql=SELECT block_num, direction, counterparty, token, toString(amount) AS amount
FROM address_transfers
WHERE address = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8'
ORDER BY block_num DESC, log_idx DESC
LIMIT 5"Note
Materialized view over txs, kept in sync on reorg.
Address-keyed transaction feed. Each tx produces one row per non-null side (sender always emits an 'from' row; recipient emits a 'to' row when to is non-null). ReplacingMergeTree ordered by (address, block_num, tx_idx, direction) so /addresses/:address/transactions and the direction=from|to|both filter resolve via a sort-key seek.
| Column | Type | Description |
|---|---|---|
block_num |
Int64 |
Block number |
block_timestamp |
DateTime64(3, 'UTC') |
Block timestamp |
tx_idx |
Int32 |
Transaction index |
tx_hash |
String |
Transaction hash |
address |
String |
The account this row is scoped to |
direction |
LowCardinality(String) |
'from' (this address sent) or 'to' (this address was the recipient) |
counterparty |
Nullable(String) |
The other side, NULL when the source tx had no to (contract deploy) |
curl -G "https://tidx.example.com/query" \
--data-urlencode "chainId=42431" \
--data-urlencode "engine=clickhouse" \
--data-urlencode "sql=SELECT block_num, direction, tx_hash, counterparty
FROM address_txs
WHERE address = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8'
AND direction = 'from'
ORDER BY block_num DESC, tx_idx DESC
LIMIT 5"Note
Materialized view over receipts, kept in sync on reorg.
One row per contract deployment, derived from receipts where contract_address is set. Ordered by (creator, block_num, tx_idx) so "what contracts did this address deploy?" is a sort-key seek.
| Column | Type | Description |
|---|---|---|
block_num |
Int64 |
Block number |
block_timestamp |
DateTime64(3, 'UTC') |
Block timestamp |
tx_idx |
Int32 |
Transaction index |
tx_hash |
String |
Transaction hash |
creator |
String |
Deployer address (receipts.from) |
contract |
String |
Deployed contract address |
curl -G "https://tidx.example.com/query" \
--data-urlencode "chainId=42431" \
--data-urlencode "engine=clickhouse" \
--data-urlencode "sql=SELECT contract, block_num, tx_hash
FROM contract_creations
WHERE creator = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8'
ORDER BY block_num DESC, tx_idx DESC
LIMIT 5"Note
Materialized view over logs, kept in sync on reorg.
One row per canonical Approval(address,address,uint256) log, decoded at insert time. ReplacingMergeTree keyed on (token, block_num, log_idx, tx_hash). Stores raw approval events — to get the latest allowance per (token, owner, spender), query with ORDER BY block_num DESC, log_idx DESC LIMIT 1.
| Column | Type | Description |
|---|---|---|
block_num |
Int64 |
Block number |
block_timestamp |
DateTime64(3, 'UTC') |
Block timestamp |
tx_idx |
Int32 |
Transaction index |
log_idx |
Int32 |
Log index |
tx_hash |
String |
Transaction hash |
token |
String |
Emitting token contract |
owner |
String |
Approving address (token holder) |
spender |
String |
Approved spender |
amount |
UInt256 |
Allowance set on this event |
curl -G "https://tidx.example.com/query" \
--data-urlencode "chainId=42431" \
--data-urlencode "engine=clickhouse" \
--data-urlencode "sql=SELECT owner, spender, toString(amount) AS amount, block_num
FROM token_approvals
WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'
ORDER BY block_num DESC, log_idx DESC
LIMIT 3"{"ok":true,"columns":["owner","spender","amount","block_num"],"rows":[
["0x70997970c51812dc3a010c7d01b50e0d17dc79c8","0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc","115792089237316195423570985008687907853269984665640564039457584007913129639935",1083],
["0x70997970c51812dc3a010c7d01b50e0d17dc79c8","0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc","0",1071],
["0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc","0x15d34aaf54267db7d7c367839aaf71a00a2c6a65","500",1042]
]}Note
View over token_approvals FINAL, argMax per (token, owner, spender).
Current allowance per (token, owner, spender) — collapses token_approvals history down to the last set value, filtered to amount > 0. Cheap lookup for "what can spender move on behalf of owner?"
| Column | Type | Description |
|---|---|---|
token |
String |
Token contract |
owner |
String |
Approving address |
spender |
String |
Approved spender |
amount |
UInt256 |
Latest allowance for the pair |
last_block_num |
Int64 |
Block of the latest Approval event |
last_block_timestamp |
DateTime64(3, 'UTC') |
Timestamp of that event |
last_tx_hash |
String |
Transaction hash of that event |
curl -G "https://tidx.example.com/query" \
--data-urlencode "chainId=42431" \
--data-urlencode "engine=clickhouse" \
--data-urlencode "sql=SELECT spender, toString(amount) AS amount, last_block_num
FROM token_approvals_current
WHERE token = '0x20c000000000000000000000e65cb5a40b7885ae'
AND owner = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8'
ORDER BY amount DESC"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.
| Column | Type | Description |
|---|---|---|
token |
String |
Token contract |
holder |
String |
Holder address |
balance |
Int256 |
Current balance (positive only) |
curl -G "https://tidx.example.com/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"]
]}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.
| Column | Type | Description |
|---|---|---|
block_num |
Int64 |
Block number |
block_timestamp |
DateTime64(3, 'UTC') |
Block timestamp |
tx_hash |
String |
Transaction hash |
log_idx |
Int32 |
Log index |
token |
String |
Token contract |
holder |
String |
Holder address (sender or recipient) |
leg |
Int8 |
+1 for recipient credit, -1 for sender debit |
balance_delta |
Int256 |
Signed delta applied to holder for token at this block |
curl -G "https://tidx.example.com/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 <= 1050Note
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.
| Column | Type | Description |
|---|---|---|
token |
String |
Token contract |
first_seen_block |
Int64 |
Block of the first observed Transfer |
last_seen_block |
Int64 |
Block of the most recent Transfer |
first_seen_timestamp |
DateTime64(3, 'UTC') |
Timestamp of the first Transfer |
last_seen_timestamp |
DateTime64(3, 'UTC') |
Timestamp of the most recent Transfer |
transfer_count |
UInt64 |
Total Transfer events ever emitted by this token |
curl -G "https://tidx.example.com/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"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.
| Column | Type | Description |
|---|---|---|
token |
String |
Token contract |
supply |
Int256 |
Cumulative mints − cumulative burns |
curl -G "https://tidx.example.com/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"]
]}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.
| Column | Type | Description |
|---|---|---|
day |
Date |
Day (UTC) bucket from block_timestamp |
token |
String |
Token contract |
transfer_count |
UInt64 |
Number of Transfer events on that day |
volume |
UInt256 |
Sum of amount across all transfers on that day |
unique_senders |
UInt64 |
Distinct from addresses on that day |
unique_recipients |
UInt64 |
Distinct to addresses on that day |
curl -G "https://tidx.example.com/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]
]}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).
| Column | Type | Description |
|---|---|---|
block_num |
Int64 |
Block number |
block_timestamp |
DateTime64(3, 'UTC') |
Block timestamp |
tx_idx |
Int32 |
Transaction index |
log_idx |
Int32 |
Log index |
tx_hash |
String |
Transaction hash |
token |
String |
Emitting token contract |
from |
String |
Sender address |
to |
String |
Recipient address |
amount |
UInt256 |
Transfer amount |
is_virtual_forward |
UInt8 |
1 if this transfer was inserted via a virtual-forward path |
curl -G "https://tidx.example.com/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"]
]}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)
| Operation | Description |
|---|---|
| Realtime | Follows chain head immediately, maintains ~0 lag |
| Gap Sync | Detects 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.
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- golden-axe — Inspiration for everything.