A focused Solana SPL Token transfer indexer written in Rust.
Tracks balance-changing instructions for a configured set of mints —
Transfer, TransferChecked, MintTo, MintToChecked, Burn, BurnChecked
— and writes each one as a row to Postgres, resolving each side to the
owning wallet (not just the token account).
Built on sevenlabs-hq/carbon for decoding.
The live tail runs on a custom Yellowstone gRPC datasource (published
yellowstone-grpc-client); a getBlock backfill/reconcile sweep fills history and
guarantees completeness. All sources feed one writer that dedups on
(signature, instruction_index), so overlap is harmless.
Scope: SPL Token only. Token-2022 (Token Extensions) is not yet supported (blocked on an upstream Carbon pin — see Notes).
Approve/Revoke/Delegateand token name/symbol metadata are also out of scope.
- Rust (stable; minimum version is pinned as
rust-versioninCargo.toml). - Postgres — any reachable instance, which you provide. The indexer needs durable storage (resume cursors, slot coverage, the token-account cache survive restarts), so this is not an ephemeral container.
- A Solana JSON-RPC endpoint (
rpc.http_url) — any provider. Drives the token-account resolver and thegetBlockreconcile/backfill crawls. - A Yellowstone gRPC endpoint (
rpc.grpc_url, plusgrpc_x_tokenif your provider requires it) — only for the live tail, and usually a paid/premium product (Triton, Helius, QuickNode, Shyft, …). No gRPC endpoint? Setenable_live = falseto run RPC-only (reconcile + onboarding still give the full completeness guarantee, just at higher latency). just(optional, recommended) — the bundledJustfilewraps every command and auto-strips the Homebrewbinutilspath that otherwise breaks the macOS build (see Build notes).- Docker — only for the Postgres integration tests (
just test-all); not needed to build or run the indexer.
git clone <repo> && cd transfer-lens
# 1. Configure: copy the templates, then fill in endpoints + the token list.
cp config/indexer.example.toml config/indexer.toml # set rpc.http_url and [[tokens]]
cp .env.example .env # set INDEXER__POSTGRES__URL, RPC/gRPC
# 2. Apply the schema, then run. `just` handles the macOS build quirk for you.
just migrate
just runPrefer raw cargo? cargo run -p transfer-lens-migrations then
cargo run -p transfer-lens-indexer --release — but on macOS read
Build notes first, or you'll hit the binutils/ar wall. Once
rows are landing, see Querying.
transfer-lens/
├── Cargo.toml # workspace
├── config/
│ └── indexer.example.toml # copy → indexer.toml
└── crates/
├── transfer-lens-indexer/ # main binary
└── transfer-lens-migrations/ # schema migrations runner
The schema name is configurable (default in the example config: token_transfers).
Tables created:
| Table / view | Purpose |
|---|---|
transfer_events |
One row per indexed transfer. Owner-resolved from/to; amount; mint; slot. |
block_time |
(slot, block_time) — wall-clock time per slot, recorded independently of rows (no row/time race). Join on slot. |
mint |
(mint, decimals, fetched_at) — per-mint decimals, fetched once on startup. SPL decimals are immutable, so write-once. Powers ui_amount. |
transfer_events_with_time |
View: transfer_events LEFT JOINed to block_time and mint — exposes block_time, decimals, and ui_amount = amount / 10^decimals for a flat, human-readable read shape. Self-healing: a not-yet-recorded slot/mint reads NULL and fills in once that row lands. |
indexer_cursor |
(source, last_slot) — one row per datasource so restarts resume cleanly. |
token_account_owner |
Durable token-account → (owner, mint) cache, populated lazily. Works for ATAs and any other SPL Token account. |
live_covered_slots |
Slots the live stream confirmed via blocks_meta. The reconciler diffs produced slots against this and fetches only the gaps the live tail missed. |
incomplete_slots |
Slots with a transfer that failed to resolve (RPC error). The reconciler re-crawls these until they process cleanly, so a covered slot is genuinely complete. |
token_coverage |
(mint, covered_from_slot) — per-token indexed floor, used by onboarding to auto-backfill a newly added token's history. |
Rows within a transaction are ordered by instruction_index (a packed,
prefix-preserving encoding of the instruction's CPI path). Consumers should always
ORDER BY slot ASC, instruction_index ASC for canonical execution order — the
indexer guarantees this for any number of outer instructions and CPI depths
up to 7 levels (Solana caps CPI at 4).
Mints and burns use the all-zeros pubkey (11111111111111111111111111111111) as
the counterparty in from/to — handy if you want to detect them in SQL with
from = '11111111111111111111111111111111' (mint) or the same on to (burn).
Examples use the default schema token_transfers and the
transfer_events_with_time view (rows + slot time + decimals + ui_amount).
-- A wallet's transfers, newest first, in human units.
SELECT slot, "from", "to", ui_amount, mint
FROM token_transfers.transfer_events_with_time
WHERE "from" = $1 OR "to" = $1
ORDER BY slot DESC, instruction_index DESC;
-- Mints and burns (all-zeros counterparty convention).
SELECT * FROM token_transfers.transfer_events
WHERE "from" = '11111111111111111111111111111111'; -- mints; use "to" = … for burns
-- Daily volume per mint (human units).
SELECT date_trunc('day', to_timestamp(block_time)) AS day,
mint,
sum(ui_amount) AS volume
FROM token_transfers.transfer_events_with_time
WHERE block_time IS NOT NULL
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC;
-- All transfers in one transaction, in canonical execution order.
SELECT * FROM token_transfers.transfer_events
WHERE signature = $1
ORDER BY slot ASC, instruction_index ASC;Copy config/indexer.example.toml to config/indexer.toml and fill in the
endpoints + token list. Every field can be overridden via env vars using the
prefix INDEXER__ and __ as a separator:
INDEXER__POSTGRES__SCHEMA=my_schema \
INDEXER__RPC__GRPC_X_TOKEN=secret \
cargo run -p transfer-lens-indexerThe schema name must match [A-Za-z0-9_]+.
The completeness sweep (HTTP getBlock reconcile) is always on — it is the
completeness guarantee and cannot be disabled. One optional toggle in
[indexer] (default-safe; see indexer.example.toml for the rest):
| Flag | Default | What it does |
|---|---|---|
enable_live |
true |
Live Yellowstone gRPC tail (the latency layer). Set false to run RPC-only with no gRPC endpoint. |
Per-token onboarding is also always on: on startup a background task automatically
backfills [start_slot, watermark] for any newly-added token (see Adding a new token).
The reconcile sweep self-paces via reconcile_parallel_workers (default 4): up
to N adjacent slot-chunks ahead of its cursor are crawled concurrently, so it runs
full parallel catch-up when far behind the tip and collapses to single-worker
hole-patching at the tip (where the live tail already covers most slots). Set
reconcile_parallel_workers = 1 for the old single-chunk behavior. Completeness
comes from this HTTP-RPC sweep, independent of the gRPC tail.
First-run tip — RPC rate limits. A cold catch-up fans out up to
reconcile_parallel_workers × backfill_concurrencyconcurrentgetBlockcalls (default4 × 8 = 32). On a rate-limited endpoint that will earn you 429s; lower those two and/or raisebackfill_throttle_ms(the per-request delay). All three knobs are documented inconfig/indexer.example.toml.
# 1. Apply migrations.
cargo run -p transfer-lens-migrations
# 2. Start the indexer.
cargo run -p transfer-lens-indexer --releaseUseful env vars (also see .env.example):
| Var | Default | Purpose |
|---|---|---|
INDEXER_CONFIG |
./config/indexer.toml |
Path to TOML config. |
RUST_LOG |
info |
Tracing filter. |
At RUST_LOG=info the indexer logs a periodic stats line so you can watch live
ingest and reconcile progress. To check how far the reconcile sweep has advanced
(compare against the chain's finalized tip to gauge lag):
SELECT source, last_slot FROM token_transfers.indexer_cursor;RUST_LOG=info,transfer_lens_indexer=debug (the .env.example default) adds
per-batch detail.
To add an older range (e.g. you started at slot 380M and now want [360M, 370M])
without touching live data until you've checked it:
# 1. Create + migrate a temp schema.
INDEXER__POSTGRES__SCHEMA=token_transfers_backfill cargo run -p transfer-lens-migrations
# 2. Backfill the range into the temp schema (honors the range over start_slot).
INDEXER__POSTGRES__SCHEMA=token_transfers_backfill \
cargo run -p transfer-lens-indexer --release -- backfill --start 360000000 --end 370000000
# 3. Inspect token_transfers_backfill.transfer_events, then merge + drop.
cargo run -p transfer-lens-indexer --release -- promote --from token_transfers_backfill --dropbackfill writes no resume cursor and is idempotent; promote merges on
(signature, instruction_index) so it never duplicates rows you already have. You
can also backfill straight into the main schema (skip steps 1 and 3) if you trust
the range.
The live tail subscribes to the whole SPL Token program and filters by mint, so
adding a [[tokens]] entry and restarting indexes that token from then on
automatically. Its history (transfers before the restart, up to the reconcile
watermark W) is filled automatically: on startup, a background onboarding task
detects that the new token has no token_coverage record and no existing rows
at slot <= W, then crawls [start_slot, W] for it — coalesced across multiple
new tokens, honoring each token's start_slot, resumable. Reconcile covers
(W, tip] forward with the new mint already in config, so history is complete
with no manual step and no range is crawled twice.
If you want to fill deep history below your RPC's retention (where the normal reconcile endpoint cannot reach), use the manual one-shot command:
cargo run -p transfer-lens-indexer --release -- \
backfill --start <new_token.start_slot> --end <current_tip> --mint <NEW_MINT>Overlap with ongoing coverage is always idempotent.
- Commitment: default is
finalized— rooted, no reorgs to worry about, ~13s lag.confirmedcuts latency but can reorg, and the indexer has no reorg rollback — a reverted confirmed block's rows would persist. Only use it if you accept that tradeoff. (processedis rejected:getBlock/getBlocksneed a rooted commitment.) - Token-account resolution: each side of a transfer is resolved to its owning
wallet via a four-tier lookup — Moka in-memory cache → the transaction's own
pre/post_token_balanceshint → the durabletoken_account_ownerPostgres table → JSON-RPCgetAccountas a last resort. Owner is read from the standard 165-byte Token Account layout, so any account — ATA or otherwise — resolves the same way; the balance hint also covers accounts closed mid-transaction with no RPC call. Cache size isindexer.token_account_cache_capacity. - Reconcile: always on (no flag). A bounded, resumable sweep diffs produced
slots (
getBlocks) from the lowest configuredstart_slotto the finalized tip againstlive_covered_slotsandgetBlock-fetches only the slots the live stream missed — the completeness guarantee. It self-paces: up toreconcile_parallel_workersadjacent chunks are crawled concurrently, so it does fast parallel catch-up when behind and collapses to single-worker hole-patching at the tip (where the live tail covers most slots and reconcile does almost nogetBlock). Its first contiguous pass also serves as the historical fill. For deep history below your RPC's retention, run the one-shotbackfillcommand (below) withrpc.http_urlpointed at an archival endpoint — standard providers prune old blocks, sogetBlockof an old slot fails on them. - Atomic coverage: a slot is trusted only when it's covered and not flagged
incomplete. A transfer that fails to resolve (RPC error, vs a clean "not a token
account" skip) flags its slot in
incomplete_slots; the reconciler re-crawls flagged slots every pass until they process cleanly. ThegetBlockcrawl is owned in-process (not carbon'sRpcBlockCrawler) and applies backpressure rather than dropping transactions when the pipeline is busy. So a "covered" slot is genuinely complete. - Block time: recorded per slot in the
block_timetable — live fromblocks_meta, crawls from eachgetBlock. It's keyed by slot and written independently of transfer rows, so there's no row/time ordering race. Jointransfer_events.slot = block_time.slot, or read thetransfer_events_with_timeview (aLEFT JOIN; a not-yet-recorded slot readsNULLand self-heals). - Idempotency: the writer upserts on
(signature, instruction_index), so overlap among backfill, live, and reconcile is harmless. Restarts are safe. - Out of scope:
Approve/Revoke/Delegate, Token-2022 (re-enabled oncecarbon-token-2022-decoderships with a compatiblespl-token-2022pin — upstream 0.12 pulls 10.0.0 which does not compile against current Solana releases), token name/symbol metadata.
On macOS, the yellowstone-grpc-proto build script compiles protobuf from
source via autotools. If you have Homebrew binutils on your PATH it shadows
Apple's ar and the link step fails. Two workarounds:
# Option 1 (recommended): temporarily unlink binutils for the build.
brew unlink binutils && cargo build --release && brew link binutils
# Option 2: build with a PATH that excludes /opt/homebrew/opt/binutils/bin.
# (NOTE: use `env` not `env -i` — `env -i` will also wipe any env vars you
# rely on, e.g. credentials read from your shell rc.)
PATH="$HOME/.cargo/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin" \
cargo build --releaseTwo layers, both run by CI on every push:
- Unit tests — pure logic, no Docker or network:
just test(cargo test --lib --all). Covers theinstruction_indexCPI-path packing, reconcile wave planning,verifyrange math, config validation, and mint-decimal parsing. - Integration tests — the full write path against an ephemeral Postgres
(testcontainers; needs Docker):
just test-all(cargo test --test postgres). Applies the migrations and exercises the batched UPSERT,(signature, instruction_index)dedup, cursor/coverage bookkeeping, and thetransfer_events_with_timeview.
Correctness of the I/O paths leans on design as much as tests: the reconcile sweep
plus incomplete_slots give the completeness guarantee, and idempotent writes make
overlap and replay safe. There's intentionally no coverage-percentage badge — most
lines are I/O exercised by the integration layer, so a unit-line figure would
understate it; run cargo llvm-cov locally if you want a number.
See CONTRIBUTING.md. In short: run just lint && just test
before opening a PR, and read Build notes if you're on macOS.
MIT.
