Indexer and read API for TricklePay token streams on Stellar.
This service has two halves that run in one process:
- An indexer that polls the stream contract's events over Soroban RPC and keeps a Postgres mirror of every stream's state.
- A read API that serves that data over HTTP, computing live vested and withdrawable amounts on each request so clients never have to query the chain directly.
It backs the TricklePay web client and pairs with the contracts repository, which holds the on-chain logic. For a record of API and indexer behavior changes, see the Changelog.
- How it works
- Database schema
- Glossary
- API
- Error Responses
- Health Endpoint Semantics
- Metrics
- Running locally
- Testing
- Configuration
- Deployment
- Contributing
- Project structure
- Frequently asked questions
- Troubleshooting
- Related repositories
- License
The contract emits Created, Withdrawn, and Cancelled events, and between
them they carry everything the mirror needs. Created holds a whole stream —
sender, recipient, token, total, and the startTime/endTime/cliffTime
schedule, with withdrawn at zero and cancelled false by definition — while
Withdrawn and Cancelled carry the deltas that move one. So the indexer
writes each event straight to Postgres: one query per event and no chain
round-trip. That is what makes a backfill cheap, since an historical event costs
a database write rather than an RPC simulation.
Applying an event twice therefore has to be harmless, because the indexer saves
its cursor only after finishing a page and re-reads that page if it restarts
mid-way. Each row records the id of the last event applied to it — the RPC's
zero-padded TOID-index, which compares as a string in chain order. Created
inserts only when the stream is absent, so a replay cannot reset withdrawn on
a stream that has since paid out, and a delta applies only to a row whose last
event predates it. Replaying a page changes nothing.
get_stream remains for reconciliation. When a delta arrives for a stream that
is not stored — an indexer backfilling from a ledger after that stream was
created, say — there is nothing to apply it to, so full contract state is read
once and the row written whole. That is one read per stream, not per event: from
then on the stream is back on the event path.
Alongside the stream rows the indexer keeps one row of bookkeeping: the RPC
cursor to resume from (cursor), the highest ledger it has actually applied
events through (lastLedger), and the chain's head as of its last poll
(chainLedger). These are not the same thing, and the difference matters:
cursor is an opaque RPC paging token that advances on every poll — including
one whose page has no matching events — because it only marks how far the RPC
has been asked to scan. lastLedger only moves forward when an event is
actually applied to Postgres, so a page with no events leaves it exactly where
it was. Lag is therefore computed as chainLedger - lastLedger, never from the
cursor: a cursor sailing through a stretch of quiet ledgers would otherwise look
identical to real progress, letting a genuinely backlogged indexer read as level
with the chain. cursor and lastLedger are what let the indexer crash and
resume without reprocessing; lastLedger and chainLedger are what /status
subtracts to report lag.
The API reads only from Postgres, and not every field it returns is a stored
column. id, sender, recipient, token, totalAmount, withdrawn,
startTime, endTime, cliffTime, and cancelled are stored — copied
straight from the indexed row. vested, withdrawable, locked, progress,
and status are derived: computed on every request, against the current clock,
using the same linear vesting formula the contract itself evaluates on-chain.
That means these figures track wall-clock time rather than the last indexed
event — a stream's vested amount can be higher on a second request than the
first even though the indexer applied nothing in between — and they agree with
The service uses PostgreSQL via Prisma. Database state is divided into four tables:
Stream: Stores the current state of each indexed token stream (amounts as wide fixed-point decimals, schedule timestamps as Unix seconds). Updated idempotently usinglastEventIdto guard against duplicate or out-of-order event application.IndexedEvent: An immutable log of raw decoded contract events (Created,Withdrawn,Cancelled) processed by the indexer.FailedEvent: Records contract events that failed during processing or database application, allowing operators to inspect and retry failed events vianpm run replay-failed-events.IndexerState: Single-row bookkeeping table storing indexer progress (lastLedger), latest Stellar network height (chainLedger), and RPC paging position (cursor).
Stream↔IndexedEvent:IndexedEventrecords raw event logs; applied events updateStreamrows.Stream.lastEventIdensures delta updates apply idempotently.Stream↔FailedEvent:FailedEventlogs unapplied events bystreamIdwhen available for debugging and retry.IndexerState↔Stream&IndexedEvent:IndexerState.lastLedgerrecords the block height through which all events and streams have been synced.IndexerState.cursortracks the Soroban RPC event pagination marker.
For full field descriptions, data types, indexes, and relationship details, see docs/database-schema.md.
Core indexer terms used across code and documentation include:
- Cursor: Opaque Soroban RPC pagination marker (
IndexerState.cursor). Advances on every poll tick regardless of whether events were found. - Ledger: Sequential block height on the Stellar network (e.g.
lastLedger,chainLedger). - Backfill: Indexer catch-up phase scanning historical events from an earlier ledger up to chain head.
- Lag: Calculated difference in ledgers between chain height and indexer applied position (
chainLedger - lastLedger). - Applied Event: Contract event whose state updates have been successfully persisted to PostgreSQL (
StreamandIndexedEvent).
For complete definitions and supporting terms, see docs/glossary.md.
| Method | Path | Description |
|---|---|---|
GET |
/ |
Service index: name, version, and a list of endpoints. |
GET |
/health |
Liveness check. Returns 200 with the service version; performs no database read. |
GET |
/ready |
Readiness check. Verifies database connectivity and reports indexer lag; returns 503 when the database is unavailable. |
GET |
/status |
Indexer progress against the chain. |
GET |
/streams |
List streams. Query params: sender, recipient, token, limit, offset, includeTotal, cancelled, cursor. Address filters accept lowercase and padded spellings and are normalized before matching. total is only included when includeTotal=true; cancelled filters by cancellation status when given, and is omitted to return both. |
The GET /streams endpoint supports pagination through the following query parameters:
| Parameter | Type | Default | Maximum | Description |
|---|---|---|---|---|
limit |
integer | 50 | 100 | Maximum number of streams to return per page |
offset |
integer | 0 | 10,000 | Zero-based index of the first stream to return |
cursor |
string | - | - | Opaque cursor from a previous response for stable pagination |
includeTotal |
boolean | false | - | When true, includes the total count of matching streams |
Note: When cursor is provided, offset is ignored and offset ceiling checks are skipped. Use cursor-based pagination for stable results under concurrent inserts.
| GET | /streams/summary | Counts and exact amount totals per status (pending, streaming, completed, cancelled). |
| GET | /streams/:id | A single stream by id. |
| GET | /metrics | Prometheus metrics. |
| GET | /docs | Interactive Swagger UI; the raw OpenAPI spec is served at /docs/json and /docs/yaml. |
Each stream is returned with its stored fields plus derived vested,
withdrawable, locked, progress, and status (pending, streaming,
completed, or cancelled). progress is vesting progress in basis points
(0–10000).
Data Types and Precision
- Amounts (
totalAmount,withdrawn,vested,withdrawable,locked) are returned as strings holding integer base units. - Times (
startTime,endTime,cliffTime) are returned as Unix seconds encoded as strings.
Strings are used rather than JSON numbers to safely preserve full 64-bit and 128-bit integer precision. If they were returned as numbers, clients could silently lose precision when parsing them as IEEE 754 floating-point values.
/status reports the indexer's own position and the chain's head as two
separate figures, because only the distance between them means anything:
{
"indexer": {
"initialized": true,
"lastLedger": 56290013,
"cursor": "0241763764928512000-0000000001",
"updatedAt": "2025-11-14T03:00:00.000Z"
},
"chain": { "latestLedger": 56999999 },
"lagLedgers": 709986,
"failedEventCount": 0
}Field reference
| Field | Type | Description |
|---|---|---|
indexer.initialized |
boolean | true once the indexer has completed its first poll and recorded a position. false before that — the API returns zeros for the other fields in this state. |
indexer.lastLedger |
number | Highest ledger sequence whose events have been fully applied to the database. Only advances when an event is actually written; a poll that finds no events leaves it unchanged. |
indexer.cursor |
string | null | Opaque Soroban RPC paging token. The indexer resumes from this token on the next poll. Advances on every poll — including empty ones — so it must not be compared to the chain head to derive lag. null before the first poll. |
indexer.updatedAt |
string | null | ISO-8601 timestamp of when the position was last written. A lag that stops growing combined with a stale updatedAt indicates a stalled indexer; a growing updatedAt with a steady lag indicates a caught-up indexer on a quiet chain. null before the first poll. |
chain.latestLedger |
number | The chain's head ledger as of the indexer's last completed poll. The API never queries the chain directly — this value is read from Postgres, where the poller stored it. |
lagLedgers |
number | null | chain.latestLedger - indexer.lastLedger. The number of ledgers between the chain head and the indexer's position. null before the first poll. Never negative. |
failedEventCount |
number | Count of contract events that could not be applied and remain in the FailedEvent table. A non-zero value signals events that need operator attention or replay (see Failed-event replay). |
What lagLedgers is
lagLedgers is the gap since the last processed event — the distance between the chain's head and the highest ledger the indexer actually applied. It tells you how far the indexer's mirror is behind the chain as of the last poll.
What lagLedgers is not
It is not a measure of indexing delay or processing latency. A chain that has produced no new events since the last poll still reports a non-zero lag if the indexer started from an older ledger. Conversely, a freshly started indexer that has caught up to the head will report zero lag even if the poll took several seconds. The figure only changes when either the chain produces a new ledger or the indexer applies an event — it is a positional gap, not a time-based metric.
Both figures are as of the last completed poll — the API never queries the chain — and updatedAt says when that was, which is how a stalled indexer, whose lag stops growing, is told apart from one that is genuinely level. lagLedgers is null until the first poll has recorded something to measure.
All error responses follow a consistent JSON shape:
{
"code": "VALIDATION_ERROR",
"error": "invalid stream id",
"requestId": "req-1"
}| Field | Type | Description |
|---|---|---|
code |
string | Machine-readable error category |
error |
string | Human-readable error message |
requestId |
string | Request id from x-request-id header, for matching to server logs |
| Code | HTTP Status | Description |
|---|---|---|
VALIDATION_ERROR |
400 | Invalid input parameters or malformed request |
NOT_FOUND |
404 | Requested resource does not exist |
REQUEST_ERROR |
400 | General client-side request error |
INTERNAL_SERVER_ERROR |
500 | Server-side failure (see server logs with requestId) |
curl -s http://localhost:3000/streams/invalid | jq{
"code": "VALIDATION_ERROR",
"error": "invalid stream id",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}The service exposes two health endpoints that answer different questions. Wire them to separate probes in your orchestration platform.
What it checks: Whether the Node.js process is running and responsive.
What it deliberately does NOT check:
- Database connectivity
- Indexer status
- Chain connectivity
- Any external dependency
Response:
{
"status": "ok",
"version": "1.0.0"
}Intended use: This is a liveness probe. Use it to detect when the process itself is wedged (e.g., deadlocked, crashed, or otherwise unresponsive). If this endpoint stops responding, your orchestration platform should restart the container. The endpoint performs no I/O — it returns immediately from memory — so it stays green even when the database is unreachable or the chain is down.
What it checks:
- Database connectivity (can reach Postgres)
- Indexer lag (how far behind the chain)
What it does NOT check:
- Chain connectivity
- Whether the indexer is currently running
Response (healthy):
{
"status": "ready",
"database": "up",
"indexer": {
"lagLedgers": 1234
}
}Response (unhealthy):
{
"status": "not_ready",
"database": "down",
"error": "Connection refused"
}Intended use: This is a readiness probe. Use it to determine whether the instance should receive traffic. If this returns 503, take the instance out of load-balancer rotation — the process is fine, but a dependency isn't. Do NOT restart the container on a readiness failure; wait for the dependency to recover.
| Probe | Fires When | Action |
|---|---|---|
Liveness (/health) |
Process is unresponsive | Restart container |
Readiness (/ready) |
Database unreachable | Remove from rotation, do not restart |
The service exposes Prometheus metrics at /metrics. Scrape this endpoint from a Prometheus instance or a compatible collector (Grafana Alloy, Victoria Metrics, etc.).
| Metric | Type | Labels | Description |
|---|---|---|---|
tricklepay_indexer_events_applied |
Counter | kind, outcome |
Total contract events applied to the database |
tricklepay_indexer_pages_fetched |
Counter | — | Total event pages fetched from the Soroban RPC |
tricklepay_rpc_errors |
Counter | operation |
Total Soroban RPC calls that resulted in an error |
tricklepay_indexer_poll_errors |
Counter | — | Total poll iterations that failed with an unhandled error |
tricklepay_indexer_poll_success_total |
Counter | — | Total successful indexer poll iterations |
tricklepay_indexer_events_failed |
Counter | kind |
Total individual events that failed to apply and were skipped |
tricklepay_indexer_lag_ledgers |
Gauge | — | Gap between the chain's latest ledger and the highest ledger the indexer has applied (-1 before first poll) |
tricklepay_indexer_poll_last_success_timestamp_seconds |
Gauge | — | Unix timestamp of the last successful poll (0 before first success) |
| Metric | Type | Labels | Description |
|---|---|---|---|
tricklepay_http_requests_total |
Counter | method, route, status |
Total HTTP requests handled |
tricklepay_http_request_duration_ms |
Histogram | method, route, status |
HTTP request duration in milliseconds (buckets: 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000) |
Poll throughput:
rate(tricklepay_indexer_poll_success_total[5m])
Stalled poller alert (no successful poll in 5 minutes):
tricklepay_indexer_poll_last_success_timestamp_seconds < time() - 300
HTTP request error rate:
sum(rate(tricklepay_http_requests_total{status=~"5.."}[5m])) / sum(rate(tricklepay_http_requests_total[5m]))
Requires Node.js 20.12 or later, Docker, and the deployed contract id. The minimum is Node 20.12 because the service uses the built-in node: protocol imports and native fetch, which are only fully stable from that release onward — older versions will fail at install or startup. The engines.node field in package.json encodes this requirement (>=20.12), and the repository also includes a .nvmrc pinned to 20.12.0 so nvm use drops you onto the right version automatically.
nvm use
cp .env.example .env # then set STREAM_CONTRACT_ID
npm install
./scripts/dev.sh # starts Postgres, syncs schema, runs with reloadThe repository includes a multi-container setup in docker-compose.yml that starts both the PostgreSQL database and the API service (which automatically runs pending database migrations on startup).
When running with Docker Compose, the service requires or configures the following environment variables:
| Variable | Required | Description / Default |
|---|---|---|
STREAM_CONTRACT_ID |
Yes | Deployed Soroban contract address (56 characters starting with C). Must be provided in .env or set in shell environment. |
DATABASE_URL |
Yes | Pre-configured in compose to postgresql://tricklepay:tricklepay@postgres:5432/tricklepay. |
NETWORK |
No | Stellar network (testnet or mainnet). Default in compose: testnet. |
PORT |
No | Container HTTP listen port. Default: 3000. |
HOST |
No | Container listen address. Default: 0.0.0.0. |
-
Clone the repository and prepare environment configuration:
cp .env.example .env
Edit
.envand setSTREAM_CONTRACT_IDto your deployed stream contract address (e.g.,STREAM_CONTRACT_ID=C...). -
Start the database and API containers:
docker compose up --build
To run the stack in detached (background) mode, use:
docker compose up -d
The
apicontainer waits for thepostgreshealthcheck to pass, runsnpx prisma migrate deployautomatically, and then starts the API service. -
Verify and view logs:
docker compose logs -f api
The API endpoint will be available at
http://localhost:3000. -
Stop the stack:
docker compose down
To stop the stack and delete the persistent PostgreSQL volume (
pgdata), pass the-vflag:docker compose down -v
If you already have a PostgreSQL instance running, you can build and run the backend image directly:
# Build the Docker image
docker build -t tricklepay-backend .
# Run the container
docker run -d \
--name tricklepay-api \
-e DATABASE_URL="postgresql://tricklepay:tricklepay@host.docker.internal:5432/tricklepay" \
-e STREAM_CONTRACT_ID="C..." \
-p 3000:3000 \
tricklepay-backendThe FailedEvent table is the indexer's operator-facing safety net. When an
individual event cannot be decoded or applied, the poller records a row so the
rest of the page can continue without stopping indexing. Each row stores:
eventId: the Soroban RPCTOID-indexthat uniquely identifies the eventkind: the decoded event kind, or"unknown"if decoding failed firststreamId: the target stream, when available, encoded as a stringledger: the ledger in which the event was observederror: the most recent exception text from the failed attemptfailureCount: how many times this event has failedfirstFailedAt/lastFailedAt: timestamps for the first and latest failure
The row is upserted on each failure, so repeated attempts refresh the error and
increment failureCount without creating duplicate rows. A successful replay or a
subsequent clean apply clears the record.
You can inspect the table directly with PostgreSQL or by using the built-in replay command as a dry run:
# see the oldest unresolved failures first
psql "$DATABASE_URL" -c 'SELECT "eventId", "kind", "streamId", "ledger", "failureCount", "error" FROM "FailedEvent" ORDER BY "ledger" ASC LIMIT 20;'
# count unresolved failures
psql "$DATABASE_URL" -c 'SELECT COUNT(*) FROM "FailedEvent";'
# preview the next 20 failed rows without changing state
npm run replay-failed-events -- --dry-run --limit 20The SQL view is useful when you need to trace the exact failed event, ledger, and exception text. The replay command is useful when you want a bounded retry without waiting for a full poller sweep.
When a single event fails, the indexer does not stop the page or rewind the
cursor. It logs the error, records the failed row, and continues processing the
remaining events in the same page. The cursor is saved only after the page is
finished and saveIndexerPosition runs, so the next poll resumes from the page's
cursor rather than from the bad event itself.
lastLedger is advanced only after a successful apply, which means a failed
event never counts as processed. In other words, one bad event is skipped, the
cursor still advances past that page, and the indexer continues without being
left stranded behind a single invalid record.
Failed events are stored in the database with their event id, ledger, kind, and most recent error. Operators can retry a bounded batch without waiting for a full poller sweep:
# inspect the next 20 failed rows without changing state
npm run replay-failed-events -- --dry-run --limit 20
# retry the next 20 rows and clear any that apply cleanly
npm run replay-failed-events -- --limit 20The command replays failed rows in ledger order, resolves the matching RPC
contract event for each row, retries it independently, clears the record when
it succeeds, and keeps the retry set bounded so one permanently invalid event
cannot stall the rest.
For instructions on setting up your local environment, running required checks (npm run typecheck, npm test, npm run build), and submitting pull requests, see CONTRIBUTING.md.
For global contributor guidelines across the organization, refer to the shared TricklePay Documentation Guide.
Keep import blocks in one canonical order across the source and tests so file edits do not create noisy diffs:
- Node built-ins first, such as
node:*. - Third-party packages next, alphabetized by package name.
- Relative imports last, alphabetized by path (
./...before../...). - Keep one blank line between groups and no extra empty lines inside a block.
This convention applies to both application code and tests.
Unit tests run under Vitest and need neither a database nor a network connection:
npm test # single run, as CI does it
npm run test:watchThey cover the parts of the service that have to agree with something outside
it: lib/vesting.ts, which mirrors the contract's vesting math case for case,
and chain/events.ts, which is decoded from a stored Soroban RPC getEvents
response — see tests/fixtures/ for its provenance
and how to refresh it. They also cover what the indexer reports about itself,
since a progress figure that is wrong is worse than none: indexer/poller.ts
for which ledger a poll records as reached, and routes/status.ts for the lag
derived from it. npm run typecheck covers the tests as well as src.
Running a single file or test
Pass a file path to run only that file, and -t to further filter by test name
(substring or regex matched against the full test title):
# run one file
npx vitest run --project unit tests/lib/vesting.test.ts
# run tests whose name contains "cliff"
npx vitest run --project unit tests/lib/vesting.test.ts -t "cliff"
# watch a single file while iterating
npx vitest --project unit tests/lib/vesting.test.tsThe --project unit flag is required when targeting a specific file because
the config defines named projects; omitting it makes Vitest search across all
projects and may produce unexpected results.
All configuration is read from the environment; .env.example is the complete,
current template — copy it and fill in the required values.
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | - | Postgres connection string. |
STREAM_CONTRACT_ID |
Yes | - | Deployed Soroban contract address. |
NETWORK |
No | testnet |
Stellar network (testnet or mainnet). |
SOROBAN_RPC_URL |
No | network dependent | Soroban RPC endpoint. Defaults to the public endpoint for the selected network. |
PORT |
No | 3000 |
HTTP server listen port. |
HOST |
No | 0.0.0.0 |
HTTP server bind address. |
CORS_ORIGIN |
No | - | Allowed browser origin for the web client. |
LOG_LEVEL |
No | info |
Log verbosity (trace, debug, info, warn, error, fatal). |
BODY_LIMIT |
No | 1048576 |
Largest accepted request body in bytes. |
QUERY_STRING_LIMIT |
No | 2048 |
Longest accepted URL query string in bytes. |
TRUSTED_PROXIES |
No | - | Comma-separated list of trusted reverse-proxy addresses. |
INDEXER_POLL_INTERVAL_MS |
No | 5000 |
Milliseconds between polls of the chain. |
INDEXER_BACKOFF_MAX_MS |
No | 60000 |
Maximum poll retry delay (ms) on RPC failures. |
INDEXER_START_LEDGER |
No | 0 |
Ledger to begin indexing from. 0 starts at the chain's latest ledger. |
INDEXER_MAX_PAGES_PER_TICK |
No | 1000 |
Maximum number of event pages fetched per poll tick. |
All log output is structured JSON using pino. Every line is a single JSON object — one log event per line — making it easy to forward to any log aggregator (Datadog, Loki, CloudWatch Logs, etc.) without a parsing step.
Fields present on every line
| Field | Type | Description |
|---|---|---|
level |
number | Pino numeric level: 10 trace · 20 debug · 30 info · 40 warn · 50 error · 60 fatal |
time |
number | Unix timestamp in milliseconds |
pid |
number | Process id |
hostname |
string | Machine hostname |
msg |
string | Human-readable log message |
Additional context fields
module— present on every line emitted by the indexer ("indexer"), added via a pino child logger so indexer output can be filtered or routed separately.reqId— present on every Fastify request log line; derived from the incomingX-Request-Idheader when present, otherwise a generated UUID.- Other fields (e.g.
err,signal,ledger,eventId) are attached ad-hoc to individual lines and document the event's context.
Log levels
The valid values for LOG_LEVEL are trace, debug, info, warn,
error, and fatal. The default is info, which logs normal operational
events — server start, poll ticks, and stream writes — without the
high-frequency trace output. Use debug or trace in development when
tracing a specific behaviour:
LOG_LEVEL=debug ./scripts/dev.sh
# or, for a one-off run:
LOG_LEVEL=trace npm startExample line
{"level":30,"time":1731550800123,"pid":42,"hostname":"worker-1","module":"indexer","msg":"poll complete","lastLedger":56290013,"newEvents":3}The indexer exposes Prometheus metrics at /metrics. To tell a quiet chain
(still polling successfully) from a stalled poller (no successful poll), watch
the poll heartbeat:
tricklepay_indexer_poll_success_total— number of successful poll iterations.tricklepay_indexer_poll_last_success_timestamp_seconds— Unix timestamp (seconds) of the last successful poll;0before the first one.
Example alert — fire when the poller has not completed a successful poll in five minutes (a stalled poller), while a quiet chain keeps ticking and never trips it:
tricklepay_indexer_poll_last_success_timestamp_seconds < time() - 300
Poll throughput can be graphed with:
rate(tricklepay_indexer_poll_success_total[5m])
The same image runs on any container platform; only the environment differs.
Everything in Configuration applies unchanged — set
DATABASE_URL and STREAM_CONTRACT_ID at minimum. Treat every value below as
a placeholder to replace, never as a literal to ship with:
DATABASE_URL=postgresql://<user>:<password>@<host>:5432/<database>
STREAM_CONTRACT_ID=<deployed-contract-id>
NETWORK=mainnet
CORS_ORIGIN=https://<frontend-host>
The service exposes two separate checks; wire them to separate probes, since they answer different questions:
- Liveness —
GET /health. Returns200 {"status":"ok","version":...}as soon as the process is up and performs no database read. A liveness probe should restart the container if this stops responding — it means the process itself is wedged. - Readiness —
GET /ready. Checks the database connection and returns200with the current indexer lag when it succeeds, or503 {"status":"not_ready","database":"down"}when Postgres is unreachable. A readiness probe should take the instance out of load-balancer rotation on a 503 without restarting it — the process is fine, a dependency isn't.
Apply pending Prisma migrations before the process starts serving traffic —
npx prisma migrate deploy, never prisma migrate dev, which is interactive.
docker-compose.yml shows the pattern: the migration runs as a startup step
ahead of node dist/index.js, in the same container command. On a platform
with a dedicated pre-deploy hook or init-container step, use that instead of
chaining commands; either way, migrations must complete before the app process
accepts connections.
This is for local development only. Resetting the database permanently deletes all indexed stream data, application state, and any local PostgreSQL data in the project's development environment. There is no recovery step in the app itself.
If the local stack is already running, use this to clear the database without removing the containers:
docker compose exec postgres psql -U tricklepay -d tricklepay -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec api npx prisma migrate deployThis drops the existing schema and then applies the Prisma migrations again, leaving the database empty and ready for a fresh local run.
If you want to recreate the entire local environment from scratch instead, run:
docker compose down -v
docker compose up -dThe -v flag removes the named PostgreSQL volume, so all local data is wiped.
After startup, the API's container command runs npx prisma migrate deploy
automatically before starting the app.
On SIGTERM or SIGINT the process shuts down in a fixed order
(index.ts): it stops the indexer poller first (no new poll
ticks start), then closes the HTTP server (Fastify stops accepting new
connections and waits for in-flight requests to finish), then drains the
Postgres connection pool, then exits 0. Give the platform's termination
grace period enough headroom for in-flight requests to finish — the process
does not force-exit early on its own.
The src/ directory is split across several modules:
chain/: Soroban RPC integration, decoding contract events, and querying on-chain state.indexer/: Polling the blockchain and applying streamed events to the database.lib/: Shared utilities and domain logic like vesting math.repositories/: Database access layer for reading and writing models.routes/: HTTP API endpoints served by Fastify.
src/
config.ts environment loading and validation
logger.ts shared structured logger
db.ts Prisma client singleton
server.ts Fastify instance and health route
index.ts bootstrap: start API and indexer together
chain/
rpc.ts Soroban RPC client and event fetcher
events.ts decode contract events from ScVal
contract.ts read full stream state via get_stream, to reconcile
indexer/
apply.ts apply a decoded event to the database
poller.ts poll loop with cursor persistence
repositories/
streams.ts stream upserts and queries
indexer-state.ts indexer position and cursor bookkeeping
lib/
vesting.ts linear vesting math, mirroring the contract
routes/
streams.ts GET /streams and GET /streams/:id
status.ts GET /status: indexer position against chain head
tests/
lib/
vesting.test.ts vesting math, mirroring the contract's Rust tests
chain/
events.test.ts event decoding against captured RPC payloads
indexer/
poller.test.ts which ledger a poll records as reached
routes/
status.test.ts reported progress and lag
apply.test.ts event to database write routing, with no chain calls
fixtures/
get-events.json a Soroban RPC getEvents response, as the RPC returns it
prisma/
schema.prisma Stream and IndexerState models
Why does this service exist — can't a client just query the contract directly?
Querying the contract directly requires an RPC simulation for every field read,
which is slow and puts load on public RPC endpoints. This service mirrors all
stream state to Postgres so the API can serve reads in a single
database query. Derived fields like vested, withdrawable, and progress
are computed on every request against the current clock, so they are always
up to date without any chain round-trip. See How it works for
the full picture.
What happens when the indexer falls behind the chain?
It catches up automatically. The poller resumes from its saved cursor on every
tick, fetching up to INDEXER_MAX_PAGES_PER_TICK pages before yielding so
a deep backlog is spread across ticks rather than blocking indefinitely. While
the indexer is behind, stored fields like withdrawn and cancelled reflect
the last applied event, but the derived fields vested and progress still
track wall-clock time accurately. You can monitor the gap with /status, which
reports lagLedgers as chainLedger - lastLedger. See
Configuration and API for the relevant knobs and
endpoints.
How do I know if the indexer is stalled versus just on a quiet part of the chain?
Check the Prometheus metrics. tricklepay_indexer_poll_last_success_timestamp_seconds
records when the last successful poll completed; if that timestamp stops
advancing, the poller itself is stuck. A quiet chain still produces successful
polls and keeps that timestamp current, so it never trips a stale-poller alert.
The ready example alert and metric queries are in
Metrics & alerting.
Why are amounts and timestamps returned as strings instead of numbers?
Stellar amounts are 64-bit integers and some internal values are 128-bit. JSON numbers are IEEE 754 doubles, which can only represent integers exactly up to 2^53. Returning large values as numbers would silently corrupt them in any client that parses standard JSON. Strings preserve the full value without requiring a special parser. See API under Data Types and Precision.
What does it mean when an event ends up in the FailedEvent table, and how do I recover?
The indexer writes a row to FailedEvent whenever it cannot apply a contract
event — for example, due to a transient database error or an unexpected event
shape. Those rows are kept so nothing is silently dropped, and they can be
retried without a full re-index using the replay-failed-events command
described in Failed-event replay. For how long to keep
those rows and how to prune them on a long-running instance, see
docs/failed-events-retention.md.
Is it safe to restart the service mid-backfill?
Yes. The poller saves its cursor after every page of events, so a restart picks
up from the last saved cursor rather than the beginning. Event application is
idempotent — replaying a page that was already applied changes nothing, because
Created only inserts a stream that is absent and each delta only applies when
the row's last-event id predates it. See How it works for the
full idempotency guarantee.
How do I apply database migrations in production?
Run npx prisma migrate deploy before the application process starts. This
command is non-interactive and safe to run on every deploy; it applies only
pending migrations. Never use prisma migrate dev in production — it is
interactive and intended for local development only. The
Deployment — Migrations section shows the recommended patterns
for init-containers and pre-deploy hooks.
Error text:
Database connectivity check failed: connection refused
or
PrismaClientKnownRequestError: Error in PostgreSQL connection pool: server closed the connection unexpectedly
Cause: The PostgreSQL database is not running or is not reachable at the address specified in DATABASE_URL.
Fix:
-
If using Docker Compose, start the database:
docker compose up -d postgres
-
Verify the database is running:
docker compose ps postgres
-
Check that
DATABASE_URLin your.envfile matches the Docker Compose configuration:DATABASE_URL=postgresql://tricklepay:tricklepay@localhost:5432/tricklepay -
If the database is running but still unreachable, check that the port is not blocked and the container is healthy.
Error text:
Missing required environment variable: STREAM_CONTRACT_ID
or
Configuration error: STREAM_CONTRACT_ID is required
Cause: The STREAM_CONTRACT_ID environment variable is not set. This is the only required variable besides DATABASE_URL.
Fix:
-
Copy the example environment file:
cp .env.example .env
-
Edit
.envand setSTREAM_CONTRACT_IDto the deployed contract address:STREAM_CONTRACT_ID=C... (your deployed contract id) -
The contract id starts with
Cand is obtained after deploying the Soroban streaming contract. See the contracts repository for deployment instructions.
Error text:
TypeError: Cannot find module '@prisma/client'
or
Error: @prisma/client did not initialize yet. Please run "prisma generate"
Cause: The Prisma client has not been generated from the schema. This is required before the application can interact with the database.
Fix:
-
Generate the Prisma client:
npx prisma generate
-
If you also need to apply database migrations (required for a fresh database):
npx prisma migrate deploy
-
The
scripts/dev.shscript runs both steps automatically, so if you use that script you should not encounter this issue.
Error text:
error TSxxxx: Cannot find name '...'
or
error TSxxxx: Type '...' is not assignable to type '...'
Cause: The codebase has TypeScript errors that prevent compilation.
Fix:
-
Run the type checker to see all errors:
npm run typecheck
-
Ensure you have the correct Node.js version:
nvm use
-
Reinstall dependencies if needed:
rm -rf node_modules npm install
Error text:
Error: listen EADDRINUSE: address already in use :::3000
Cause: Another process is already using port 3000 (the default API port).
Fix:
-
Find the process using the port:
lsof -i :3000
-
Either stop the other process or configure a different port in your
.env:PORT=3001
Error text:
FAIL tests/...
AssertionError: expected ... to equal ...
Cause: Tests are failing, possibly due to environment issues or code changes.
Fix:
-
Run the full test suite:
npm test -
Run the type checker first to ensure no type errors:
npm run typecheck
-
Check that your environment is set up correctly (database running, migrations applied).
-
If a specific test is failing, run only that test file:
npx vitest run --project unit tests/path/to/test.test.ts
- tricklepay-contracts — the Soroban streaming contract this service indexes.
- tricklepay-frontend — web client built on this API.
- tricklepay-docs — architecture, security model, and contributor guides.
Contributions are welcome! Please review CONTRIBUTING.md for setup and development guidelines.
All contributors are expected to adhere to the project's Code of Conduct.
MIT. See LICENSE.