Wallet indexer — Reference
The complete reference for the standalone wallet indexer: architecture, the stable JSON-RPC API contract, configuration, and the security posture. For a gentle introduction, start with the Quick guide.
The canonical source of truth for the running service lives in the repository (blurt/blurt-wallet-indexer); this page mirrors it for readers of the manual.
Architecture
Blurt Wallet derives wallet-facing history from chain-history RPC. For old or active accounts that is slow: the client fetches many history pages and filters operations in the browser. This service moves that work off the client.
blurtd block stream
-> wallet-indexer (decode wallet-relevant ops into per-account events)
-> PostgreSQL (normalized wallet_operation_events)
-> wallet-api (read-only JSON-RPC)
-> wallet UIComponents
- wallet-indexer — tails blocks from a
blurtdnode, decodes the operations relevant to wallet history, expands each into one or more per-account events, and writes them to PostgreSQL. It persists a checkpoint so it resumes cleanly after a restart, and exposes a progress-based liveness endpoint used by the container health check. - wallet-backfill — a one-shot, profile-gated loader that populates a fresh database in parallel. It shards the historical range across workers and a pool of nodes, and is idempotent, so it can be re-run and can overlap the live tail safely.
- PostgreSQL — stores the normalized events. Two least-privilege roles separate concerns (see Security).
- wallet-api — a read-only JSON-RPC service over the same store. It exposes stable, wallet-specific methods and never writes, signs, or broadcasts.
Ingestion model
The wallet does not need raw blockchain operations; it needs the per-account impact of each operation, already decoded, so it can be painted directly with no client-side parsing. A single blockchain operation expands into one or more wallet events — one per affected account and role.
Blocks are read with get_ops_in_block, which returns both real and virtual operations (author_reward, curation_reward, comment_benefactor_reward, fill_vesting_withdraw, fill_transfer_from_savings, return_vesting_delegation, interest, …).
Why not plain block reads
A plain get_block returns only signed transactions and omits every virtual operation — which are exactly the reward and fill events the wallet needs. A get_block-based indexer would silently return empty reward history.
The indexer reads each block once from a (preferably local/dedicated) blurtd. After that, every wallet history and reward query is served from PostgreSQL and never touches blurtd: a constant per-block cost replaces thousands of per-user history calls. Live tail uses small batches near the head; backfill uses large batches against a node pool. Both write through the same decode + store path, so live and historical rows are identical in shape.
Operation expansion
Each supported operation becomes one event per affected account/role:
| Operation | Events (role) |
|---|---|
transfer, transfer_to_savings, transfer_from_savings, cancel_transfer_from_savings | from as sender, to as receiver |
fill_transfer_from_savings (virtual) | to as receiver |
transfer_to_vesting | from as sender, to as receiver |
withdraw_vesting | account as owner |
fill_vesting_withdraw (virtual) | to_account as receiver |
delegate_vesting_shares | delegator, delegatee |
return_vesting_delegation (virtual) | account as owner |
claim_reward_balance | account as owner |
interest (virtual) | owner as owner |
author_reward (virtual) | author as author (author/permlink) |
curation_reward (virtual) | curator as curator (author/permlink) |
comment_benefactor_reward (virtual) | benefactor as beneficiary (author/permlink) |
Roles: sender, receiver, delegator, delegatee, author, curator, owner, beneficiary.
Blurt specifics
On Blurt there is no SBD/BBD, and author/curation rewards are paid entirely in VESTS (no liquid component), carried in the vesting_amount column.
Data model
The hot table is wallet_operation_events: one row per account per relevant operation, already decoded (role, amount/symbol, vesting_amount, counterparty, …), with the raw payload retained for fidelity but kept off the list responses. Supporting tables hold the indexer checkpoint, backfill shard state, and schema version.
Amounts are stored as exact NUMERIC(30,6) — never float. VESTS uses 6 decimals, BLURT uses 3; both fit exactly. VESTS is stored raw (the VESTS↔BP conversion is the wallet's job).
Three indexes serve the wallet's access patterns directly:
- a unique index on the positional dedup key makes writes idempotent — re-processing a block never duplicates rows;
- an account/time index serves the newest-first history pages via keyset pagination;
- an account/op-type/time index serves reward listings and the server-side reward totals.
The dedup key is positional — (block_num, trx_in_block, op_in_trx, virtual_op, account, role) — rather than trx_id, because virtual ops have no real trx_id (it comes back all-zeros). trx_id is stored for display only.
Freshness, reorg and failure model
- The service is eventually consistent;
wallet_history.get_statusexposes the indexed block/time, chain head, and lag so a consumer can decide whether to trust it or fall back. - The indexer targets the last irreversible block (configurable trail), so reorgs on indexed data are rare by construction.
- On a detected fork, it deletes forward (
block_num > fork_point) and re-indexes; the unique index keeps this clean and idempotent. - Restarts are safe: the indexer resumes from its checkpoint.
- If the node reports no last-irreversible block, the indexer refuses to advance rather than index unsafe data, and its liveness endpoint surfaces the stall.
- API failures never affect chain operation, and wallet clients keep a transparent fallback to chain history.
API contract
The read-only, wallet-facing contract exposed by wallet-api. This section is the authoritative contract for consumers (wallet front-ends, other UIs, third-party tooling). Anything not described here is an implementation detail and may change without notice.
Stability and versioning
- Namespace. All methods live under the
wallet_history.prefix — the stable, versionless entry point. - Additive evolution. New methods and new optional response fields may be added. Consumers MUST ignore unknown fields and MUST NOT depend on field ordering.
- Breaking changes are namespaced. A change that removes/repurposes a field or alters a method's meaning ships under a new, explicitly versioned method name; the previous method keeps its contract.
- Normalized, pre-decoded events. Items come already decoded into a fixed shape; the wallet does not parse raw operation arrays on the fast path.
- Exact decimals. All monetary and vesting amounts are JSON strings holding exact decimal values, never JSON numbers.
Transport
JSON-RPC 2.0 over HTTP POST; a single JSON-RPC object per request (batching is not part of the contract). A deployment fronts it with an RPC endpoint that routes wallet_history.* to this service, so the wallet reuses the same RPC URL it already uses for the chain — no new origin, no separate CORS setup.
POST /
Content-Type: application/jsonMethods
wallet_history.get_wallet_history financial events for an account (paged)
wallet_history.get_author_rewards author_reward events (paged)
wallet_history.get_curation_rewards curation_reward events (paged)
wallet_history.get_reward_totals server-side reward sums over a window
wallet_history.get_status indexer freshness (head / indexed / lag)get_author_rewards and get_curation_rewards are get_wallet_history pre-filtered to the reward op-type they name.
Common request parameters
| Field | Type | Notes |
|---|---|---|
account | string | Required. Blurt account name. |
from | string | Optional. ISO-8601 UTC, inclusive lower bound. |
to | string | Optional. ISO-8601 UTC, exclusive upper bound. |
limit | integer | Optional. Page size; clamped to the server maximum. |
cursor | string | Optional. Opaque pagination cursor. |
op_types | string[] | Optional. Restrict get_wallet_history to these op-types (bounded list). |
Date window semantics. If from/to are omitted, the query spans all indexed history — there is no implicit recent-only window. Paged methods return the newest limit events first and the cursor pages back through the whole history; get_reward_totals sums are lifetime. An explicit two-sided range is capped at the server maximum (see Limits).
The wallet event object
Every item in a paged response has this exact shape. Nullable fields are null when not applicable to the op-type.
{
"account": "alice", // the account this event belongs to
"role": "receiver", // sender | receiver | author | curator | ...
"op_type": "transfer", // blockchain operation type
"block_num": 62317132, // block containing the operation
"block_seq": 3, // ordinal within the block; stable tiebreaker
"trx_id": "a1b2...", // transaction id, or null for virtual ops
"op_in_trx": 0, // operation index within its transaction
"virtual_op": 0, // virtual-op sequence (0 for real ops)
"timestamp": "2026-05-19T21:05:36Z",
"amount": "10.000", // liquid amount (string) or null
"symbol": "BLURT", // liquid symbol or null
"vesting_amount": null, // vesting amount (string) or null
"vesting_symbol": null, // vesting symbol or null
"counterparty": "bob", // other party (e.g. transfer peer) or null
"memo": "thanks", // memo or null
"author": null, // author (reward ops) or null
"permlink": null // permlink (reward ops) or null
}Raw operation payloads are not included in list responses; they are retained in storage for fidelity but kept off the hot path.
Pagination
Cursor-based only; offsets are never used (they degrade badly for high-activity accounts).
- A paged response includes
next_cursor; resend the same request withcursorset to it to fetch the next (older) page.next_cursorisnullwhen there are no more rows. - The cursor is opaque — a keyset over
(timestamp, block_num, block_seq). Consumers MUST treat it as opaque and MUST NOT parse or construct it.
Paged response envelope:
{
"account": "alice",
"items": [ /* wallet event objects, newest first */ ],
"next_cursor": "b3J...=", // or null
"indexer": { "indexed_block": 62317132 }
}Example — curation rewards for a window
Request:
{
"jsonrpc": "2.0",
"method": "wallet_history.get_curation_rewards",
"params": { "account": "alice", "from": "2026-05-01T00:00:00Z", "to": "2026-05-23T00:00:00Z", "limit": 100 },
"id": 1
}Response:
{
"jsonrpc": "2.0",
"result": {
"account": "alice",
"items": [
{
"account": "alice", "role": "curator", "op_type": "curation_reward",
"block_num": 62317132, "block_seq": 1, "trx_id": null, "op_in_trx": 0, "virtual_op": 2,
"timestamp": "2026-05-19T21:05:36Z",
"amount": null, "symbol": null,
"vesting_amount": "123.456789", "vesting_symbol": "VESTS",
"counterparty": null, "memo": null,
"author": "alice", "permlink": "an-example-post"
}
],
"next_cursor": null,
"indexer": { "indexed_block": 62317140 }
},
"id": 1
}Reward totals
get_reward_totals returns server-side SUM()s over the window, so the wallet does not reduce over a list in the browser. With no from/to, totals are lifetime.
{
"account": "alice",
"from": "2026-05-01T00:00:00Z", // present only if requested
"to": "2026-05-23T00:00:00Z", // present only if requested
"totals": {
"author_reward": { "vesting_amount": "1234.567890", "vesting_symbol": "VESTS", "count": 42 },
"curation_reward": { "vesting_amount": "987.654321", "vesting_symbol": "VESTS", "count": 310 }
},
"indexer": { "indexed_block": 62317132 }
}Status and freshness
get_status (no params) reports how current the indexer is:
{
"indexed_block": 62317132, // last fully-indexed block
"indexed_time": "2026-05-19T21:05:36Z",
"head_block": 62317140, // present when the node head is reachable
"lag_blocks": 8, // head_block - indexed_block
"lag_seconds": 24
}head_block/lag_blocks are omitted if the service cannot reach a chain node at that moment; indexed_block/indexed_time are always present. Treat a large or growing lag as a signal to fall back.
Health
GET /health -> { "ok": true }A plain, unauthenticated liveness check for load balancers and uptime monitors. It is not part of the data contract.
Errors
Standard JSON-RPC 2.0 error objects: { "jsonrpc": "2.0", "error": { "code", "message" }, "id" }.
| Code | Meaning | Typical cause |
|---|---|---|
-32600 | Invalid request | Non-POST method; malformed envelope; rate-limited ("rate limit exceeded"). |
-32601 | Method not found | Unknown wallet_history.* method. |
-32602 | Invalid params | Missing/invalid account; bad date range; over-long op_types. |
-32603 | Internal error | Unexpected server error; load-shed ("server busy"). |
Errors are intentionally generic and never leak internal details. Malformed client requests (an over-long range, a bad cursor) are client bugs and MUST NOT trigger fallback; unavailability or staleness SHOULD.
Server-enforced limits
A deployment enforces these to protect the shared node and database (defaults shown; operators may tune them — see Configuration):
- Page size —
limitclamped to a maximum (default 200; default page 50). - Explicit date range — a two-sided
from/torange capped (default 366 days). Lifetime queries (no range) are bounded by the statement timeout. op_types— bounded to a small number of entries.- Request body — capped; oversized bodies rejected.
- Timeouts — per-request deadline plus a database statement timeout.
- Load shedding — per-client rate limiting and a global in-flight cap; over the limit the service returns a rate-limit or
server busyerror.
Client fallback contract
This API is an optional accelerator, never a hard dependency. A conforming consumer:
- uses it only when configured and when
get_statuslag is acceptable; - falls back to the chain node's
get_account_historywhen the service is unavailable, unhealthy, or too far behind; - runs a shared client-side adapter so the fallback yields the same normalized event shape — one render path, whichever source served the data;
- does not fall back on malformed-request errors (those are client bugs).
This guarantees a wallet keeps working, with correct data, even if no operator runs this service.
Configuration
Every setting is an environment variable, supplied through docker/.env (see docker/.env.example for a template). Values marked required have no safe default. Secrets (*_PASSWORD) must be strong and distinct.
Database
| Variable | Default | Description |
|---|---|---|
POSTGRES_USER | postgres | Superuser role name for the PostgreSQL container. |
POSTGRES_PASSWORD | required | Superuser password. |
POSTGRES_DB | wallet | Database name. |
WALLET_WRITER_PASSWORD | required | Password for the non-superuser wallet_writer role the indexer/backfill use. |
WALLET_API_READER_PASSWORD | required | Password for the SELECT-only wallet_api_reader role the API uses. |
DB_BIND_HOST | 127.0.0.1 | Host address the database port binds to. Keep on loopback; PostgreSQL must not be public. |
DB_BIND_PORT | 5432 | Host port for the database. |
Chain source
| Variable | Default | Description |
|---|---|---|
BLURT_RPC_URL | http://blurtd:8091 | blurtd JSON-RPC endpoint the indexer reads blocks from. A local/dedicated node is preferred. |
BLURT_RPC_TIMEOUT_MS | 10000 | Per-request timeout against the node. |
Indexer
| Variable | Default | Description |
|---|---|---|
INDEXER_START_BLOCK | 0 | First block to index. 0 = genesis. Set to a recent block to catch up the live tail immediately and backfill the rest separately. |
INDEXER_TRAIL_BLOCKS | 2 | Blocks to stay behind the target to avoid indexing not-yet-final data. |
INDEXER_TARGET_IRREVERSIBLE | true | Index only up to the last irreversible block, so reorgs are rare. |
INDEXER_MAX_FORK_DEPTH | 25 | Bound on reorg reconciliation depth. |
INDEXER_POLL_INTERVAL_MS | 3000 | Delay between polls when caught up. |
INDEXER_HEALTH_ADDR | 127.0.0.1:8091 | Loopback address of the liveness endpoint (container-internal). Empty disables it. |
INDEXER_HEALTH_MAX_STALL_MS | 120000 | Max time with no progress before liveness reports unhealthy. |
INDEXER_DATABASE_URL | derived | Full DSN override. By default built from the writer role + POSTGRES_*. |
Read-only API
| Variable | Default | Description |
|---|---|---|
API_BIND_HOST | 127.0.0.1 | Host address the API binds to. Keep on loopback and front with a proxy. |
API_BIND_PORT | 8090 | Host port for the API. |
WALLET_API_DEFAULT_LIMIT | 50 | Default page size when limit is omitted. |
WALLET_API_MAX_LIMIT | 200 | Maximum page size; larger values are clamped. |
WALLET_API_MAX_RANGE_DAYS | 366 | Cap on an explicit two-sided date range. Lifetime queries are bounded by the statement timeout instead. |
WALLET_API_STATEMENT_TIMEOUT_MS | 5000 | Database statement timeout per query. |
WALLET_API_DB_MAX_CONNS | 10 | Maximum database connections in the API pool. |
WALLET_API_RATE_RPS | 10 | Sustained per-client request rate. 0 disables per-client limiting. |
WALLET_API_RATE_BURST | 20 | Token-bucket burst for the per-client limiter. |
WALLET_API_MAX_INFLIGHT | 64 | Global cap on concurrent in-flight requests. 0 disables it. |
WALLET_API_TRUST_PROXY | false | Whether to trust X-Forwarded-For. Only enable behind a proxy that appends the peer it saw. |
WALLET_API_TRUSTED_PROXY_HOPS | 0 | Trusted proxies in front; the client IP is read that many entries from the right of X-Forwarded-For. |
WALLET_API_ALLOWED_ORIGINS | empty | Comma-separated CORS allowlist. Leave empty when reached same-origin through the RPC proxy. |
API_DATABASE_URL | derived | Full DSN override. By default built from the reader role + POSTGRES_*. |
Backfill (profile-gated)
Only used by the one-shot historical loader (--profile backfill).
| Variable | Default | Description |
|---|---|---|
BACKFILL_RPC_URLS | http://blurtd:8091 | Comma-separated node pool to spread historical load across. Historical blocks are identical across nodes, so mixing is safe. |
BACKFILL_RPC_TIMEOUT_MS | 30000 | Per-request timeout during backfill. |
BACKFILL_START_BLOCK | 1 | First block to backfill. |
BACKFILL_END_BLOCK | 0 | Last block to backfill; 0 = current head. |
BACKFILL_SHARD_SIZE | 10000 | Blocks per shard. |
BACKFILL_BATCH_SIZE | 100 | Blocks per JSON-RPC batch within a shard. |
BACKFILL_WORKERS | 6 | Concurrent shard workers. Raise for speed, lower to reduce node/database load. |
Deployment/resource caps (image tag, per-container memory/CPU/PID limits) are documented in the repository's configuration reference; the authoritative defaults live in the compose file.
Security
The guiding principle: a wallet-history indexer is a read-only accelerator, so a compromise of any component must never be able to move funds or corrupt anything that is not cheaply rebuildable from the chain.
Trust boundary
wallet-api is a strictly read-only surface: it reads wallet events, rewards, reward totals, and indexer status, and answers health checks. It never signs, broadcasts, handles private keys, authenticates accounts, exposes administrative actions, or executes arbitrary queries. The worst outcome of a fully compromised API is serving wrong or stale read data — which a client detects via freshness and cross-checking — never a loss of funds.
Least-privilege database roles
The stack never connects to PostgreSQL as the cluster superuser for its work:
wallet_writer— a non-superuser role that owns the wallet schema; the indexer and backfill connect as this role. It has no cluster-wide powers. A compromised indexer can at worst corrupt the wallet schema, which is fully rebuildable from the chain.wallet_api_reader— aSELECT-only role with a statement timeout; the API connects as this role and can only read.
On a fresh database these roles are provisioned automatically; an existing database is migrated to them with a provided SQL script. The database binds to loopback and is never exposed directly.
Hardening posture
- Load shedding & limits — per-client rate limiting, a global in-flight cap, clamped page sizes, capped date ranges, bounded
op_types, capped request bodies, a size cap on upstream node responses, and timeouts on every request and database statement. - Input validation — account names, ISO-8601 timestamps, the opaque cursor,
op_types, andlimitare all validated; all SQL is parameterized (no string concatenation of client input). - Client identity behind a proxy —
X-Forwarded-Foris trusted only whenWALLET_API_TRUSTED_PROXY_HOPSis set, and read from the right, so a client cannot forge left-most entries to mint unlimited identities. - Container hardening — read-only root filesystem, all Linux capabilities dropped (minimal set added back only where the database entrypoint needs it),
no-new-privileges, and per-service memory/CPU/PID limits. - Deploy integrity — a deployment that ships images over SSH should pin the target's host key and enforce strict host-key checking, using a dedicated, least-privilege deploy key.
- Logging — client-facing errors are generic; logs avoid full memos, raw payloads, and large filters, keeping only what is needed to operate.
Network exposure
Both the database and the API bind to loopback by design. Expose the API only through a reverse proxy or RPC gateway you control; never publish PostgreSQL.
Operating the service
Install, first run, loading history, exposing the API, monitoring, backups, and upgrades are covered in the repository's operator runbook. This manual keeps the contract and design; the runbook keeps the step-by-step operations.
