Skip to content

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.

text
  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 UI

Components

  • wallet-indexer — tails blocks from a blurtd node, 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:

OperationEvents (role)
transfer, transfer_to_savings, transfer_from_savings, cancel_transfer_from_savingsfrom as sender, to as receiver
fill_transfer_from_savings (virtual)to as receiver
transfer_to_vestingfrom as sender, to as receiver
withdraw_vestingaccount as owner
fill_vesting_withdraw (virtual)to_account as receiver
delegate_vesting_sharesdelegator, delegatee
return_vesting_delegation (virtual)account as owner
claim_reward_balanceaccount 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_status exposes 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.

http
POST /
Content-Type: application/json

Methods

text
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

FieldTypeNotes
accountstringRequired. Blurt account name.
fromstringOptional. ISO-8601 UTC, inclusive lower bound.
tostringOptional. ISO-8601 UTC, exclusive upper bound.
limitintegerOptional. Page size; clamped to the server maximum.
cursorstringOptional. Opaque pagination cursor.
op_typesstring[]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.

jsonc
{
  "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 with cursor set to it to fetch the next (older) page. next_cursor is null when 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:

jsonc
{
  "account":     "alice",
  "items":       [ /* wallet event objects, newest first */ ],
  "next_cursor": "b3J...=",             // or null
  "indexer":     { "indexed_block": 62317132 }
}
Example — curation rewards for a window

Request:

json
{
  "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:

json
{
  "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.

jsonc
{
  "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:

jsonc
{
  "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

http
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" }.

CodeMeaningTypical cause
-32600Invalid requestNon-POST method; malformed envelope; rate-limited ("rate limit exceeded").
-32601Method not foundUnknown wallet_history.* method.
-32602Invalid paramsMissing/invalid account; bad date range; over-long op_types.
-32603Internal errorUnexpected 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 sizelimit clamped to a maximum (default 200; default page 50).
  • Explicit date range — a two-sided from/to range 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 busy error.

Client fallback contract

This API is an optional accelerator, never a hard dependency. A conforming consumer:

  • uses it only when configured and when get_status lag is acceptable;
  • falls back to the chain node's get_account_history when 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

VariableDefaultDescription
POSTGRES_USERpostgresSuperuser role name for the PostgreSQL container.
POSTGRES_PASSWORDrequiredSuperuser password.
POSTGRES_DBwalletDatabase name.
WALLET_WRITER_PASSWORDrequiredPassword for the non-superuser wallet_writer role the indexer/backfill use.
WALLET_API_READER_PASSWORDrequiredPassword for the SELECT-only wallet_api_reader role the API uses.
DB_BIND_HOST127.0.0.1Host address the database port binds to. Keep on loopback; PostgreSQL must not be public.
DB_BIND_PORT5432Host port for the database.

Chain source

VariableDefaultDescription
BLURT_RPC_URLhttp://blurtd:8091blurtd JSON-RPC endpoint the indexer reads blocks from. A local/dedicated node is preferred.
BLURT_RPC_TIMEOUT_MS10000Per-request timeout against the node.

Indexer

VariableDefaultDescription
INDEXER_START_BLOCK0First block to index. 0 = genesis. Set to a recent block to catch up the live tail immediately and backfill the rest separately.
INDEXER_TRAIL_BLOCKS2Blocks to stay behind the target to avoid indexing not-yet-final data.
INDEXER_TARGET_IRREVERSIBLEtrueIndex only up to the last irreversible block, so reorgs are rare.
INDEXER_MAX_FORK_DEPTH25Bound on reorg reconciliation depth.
INDEXER_POLL_INTERVAL_MS3000Delay between polls when caught up.
INDEXER_HEALTH_ADDR127.0.0.1:8091Loopback address of the liveness endpoint (container-internal). Empty disables it.
INDEXER_HEALTH_MAX_STALL_MS120000Max time with no progress before liveness reports unhealthy.
INDEXER_DATABASE_URLderivedFull DSN override. By default built from the writer role + POSTGRES_*.

Read-only API

VariableDefaultDescription
API_BIND_HOST127.0.0.1Host address the API binds to. Keep on loopback and front with a proxy.
API_BIND_PORT8090Host port for the API.
WALLET_API_DEFAULT_LIMIT50Default page size when limit is omitted.
WALLET_API_MAX_LIMIT200Maximum page size; larger values are clamped.
WALLET_API_MAX_RANGE_DAYS366Cap on an explicit two-sided date range. Lifetime queries are bounded by the statement timeout instead.
WALLET_API_STATEMENT_TIMEOUT_MS5000Database statement timeout per query.
WALLET_API_DB_MAX_CONNS10Maximum database connections in the API pool.
WALLET_API_RATE_RPS10Sustained per-client request rate. 0 disables per-client limiting.
WALLET_API_RATE_BURST20Token-bucket burst for the per-client limiter.
WALLET_API_MAX_INFLIGHT64Global cap on concurrent in-flight requests. 0 disables it.
WALLET_API_TRUST_PROXYfalseWhether to trust X-Forwarded-For. Only enable behind a proxy that appends the peer it saw.
WALLET_API_TRUSTED_PROXY_HOPS0Trusted proxies in front; the client IP is read that many entries from the right of X-Forwarded-For.
WALLET_API_ALLOWED_ORIGINSemptyComma-separated CORS allowlist. Leave empty when reached same-origin through the RPC proxy.
API_DATABASE_URLderivedFull DSN override. By default built from the reader role + POSTGRES_*.

Backfill (profile-gated)

Only used by the one-shot historical loader (--profile backfill).

VariableDefaultDescription
BACKFILL_RPC_URLShttp://blurtd:8091Comma-separated node pool to spread historical load across. Historical blocks are identical across nodes, so mixing is safe.
BACKFILL_RPC_TIMEOUT_MS30000Per-request timeout during backfill.
BACKFILL_START_BLOCK1First block to backfill.
BACKFILL_END_BLOCK0Last block to backfill; 0 = current head.
BACKFILL_SHARD_SIZE10000Blocks per shard.
BACKFILL_BATCH_SIZE100Blocks per JSON-RPC batch within a shard.
BACKFILL_WORKERS6Concurrent 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 — a SELECT-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, and limit are all validated; all SQL is parameterized (no string concatenation of client input).
  • Client identity behind a proxyX-Forwarded-For is trusted only when WALLET_API_TRUSTED_PROXY_HOPS is 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.

Released under the MIT License.