Skip to content

Repository files navigation

TricklePay Backend

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.

Table of Contents

How it works

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

Database schema

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 using lastEventId to 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 via npm run replay-failed-events.
  • IndexerState: Single-row bookkeeping table storing indexer progress (lastLedger), latest Stellar network height (chainLedger), and RPC paging position (cursor).

Entity Relationships

  • StreamIndexedEvent: IndexedEvent records raw event logs; applied events update Stream rows. Stream.lastEventId ensures delta updates apply idempotently.
  • StreamFailedEvent: FailedEvent logs unapplied events by streamId when available for debugging and retry.
  • IndexerStateStream & IndexedEvent: IndexerState.lastLedger records the block height through which all events and streams have been synced. IndexerState.cursor tracks the Soroban RPC event pagination marker.

For full field descriptions, data types, indexes, and relationship details, see docs/database-schema.md.

Glossary

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 (Stream and IndexedEvent).

For complete definitions and supporting terms, see docs/glossary.md.

API

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.

Pagination Parameters

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.

Error Responses

All error responses follow a consistent JSON shape:

{
  "code": "VALIDATION_ERROR",
  "error": "invalid stream id",
  "requestId": "req-1"
}

Error Fields

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

Error Codes

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)

Example Error

curl -s http://localhost:3000/streams/invalid | jq
{
  "code": "VALIDATION_ERROR",
  "error": "invalid stream id",
  "requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

Health Endpoint Semantics

The service exposes two health endpoints that answer different questions. Wire them to separate probes in your orchestration platform.

Liveness — GET /health

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.

Readiness — GET /ready

What it checks:

  1. Database connectivity (can reach Postgres)
  2. 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.

Why Separate Probes?

Probe Fires When Action
Liveness (/health) Process is unresponsive Restart container
Readiness (/ready) Database unreachable Remove from rotation, do not restart

Metrics

The service exposes Prometheus metrics at /metrics. Scrape this endpoint from a Prometheus instance or a compatible collector (Grafana Alloy, Victoria Metrics, etc.).

Indexer Metrics

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)

HTTP Metrics

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)

Example PromQL Queries

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]))

Running locally

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 reload

Running with Docker Compose

The 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).

Environment Variables for Docker

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.

Step-by-Step Instructions from a Clean Checkout

  1. Clone the repository and prepare environment configuration:

    cp .env.example .env

    Edit .env and set STREAM_CONTRACT_ID to your deployed stream contract address (e.g., STREAM_CONTRACT_ID=C...).

  2. Start the database and API containers:

    docker compose up --build

    To run the stack in detached (background) mode, use:

    docker compose up -d

    The api container waits for the postgres healthcheck to pass, runs npx prisma migrate deploy automatically, and then starts the API service.

  3. Verify and view logs:

    docker compose logs -f api

    The API endpoint will be available at http://localhost:3000.

  4. Stop the stack:

    docker compose down

    To stop the stack and delete the persistent PostgreSQL volume (pgdata), pass the -v flag:

    docker compose down -v

Building and Running with Standalone Docker

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-backend

Failed events table

The 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 RPC TOID-index that uniquely identifies the event
  • kind: the decoded event kind, or "unknown" if decoding failed first
  • streamId: the target stream, when available, encoded as a string
  • ledger: the ledger in which the event was observed
  • error: the most recent exception text from the failed attempt
  • failureCount: how many times this event has failed
  • firstFailedAt / 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.

Inspecting failed events

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 20

The 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.

Cursor behavior on a failed event

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-event replay

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 20

The 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.

Contributing

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.

Import ordering

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.

Testing

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:watch

They 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.ts

The --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.

Configuration

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.

Logging

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 incoming X-Request-Id header 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 start

Example line

{"level":30,"time":1731550800123,"pid":42,"hostname":"worker-1","module":"indexer","msg":"poll complete","lastLedger":56290013,"newEvents":3}

Metrics & alerting

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; 0 before 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])

Deployment

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>

Probes

The service exposes two separate checks; wire them to separate probes, since they answer different questions:

  • Liveness — GET /health. Returns 200 {"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 returns 200 with the current indexer lag when it succeeds, or 503 {"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.

Migrations

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.

Resetting the local database

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 deploy

This 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 -d

The -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.

Graceful termination

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.

Project structure

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

Frequently asked questions

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.

Troubleshooting

Database not running

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:

  1. If using Docker Compose, start the database:

    docker compose up -d postgres
  2. Verify the database is running:

    docker compose ps postgres
  3. Check that DATABASE_URL in your .env file matches the Docker Compose configuration:

    DATABASE_URL=postgresql://tricklepay:tricklepay@localhost:5432/tricklepay
    
  4. If the database is running but still unreachable, check that the port is not blocked and the container is healthy.


Missing contract id

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:

  1. Copy the example environment file:

    cp .env.example .env
  2. Edit .env and set STREAM_CONTRACT_ID to the deployed contract address:

    STREAM_CONTRACT_ID=C... (your deployed contract id)
    
  3. The contract id starts with C and is obtained after deploying the Soroban streaming contract. See the contracts repository for deployment instructions.


Prisma client not generated

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:

  1. Generate the Prisma client:

    npx prisma generate
  2. If you also need to apply database migrations (required for a fresh database):

    npx prisma migrate deploy
  3. The scripts/dev.sh script runs both steps automatically, so if you use that script you should not encounter this issue.


TypeScript compilation errors

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:

  1. Run the type checker to see all errors:

    npm run typecheck
  2. Ensure you have the correct Node.js version:

    nvm use
  3. Reinstall dependencies if needed:

    rm -rf node_modules
    npm install

Port already in use

Error text:

Error: listen EADDRINUSE: address already in use :::3000

Cause: Another process is already using port 3000 (the default API port).

Fix:

  1. Find the process using the port:

    lsof -i :3000
  2. Either stop the other process or configure a different port in your .env:

    PORT=3001
    

Tests failing

Error text:

FAIL tests/...
AssertionError: expected ... to equal ...

Cause: Tests are failing, possibly due to environment issues or code changes.

Fix:

  1. Run the full test suite:

    npm test
  2. Run the type checker first to ensure no type errors:

    npm run typecheck
  3. Check that your environment is set up correctly (database running, migrations applied).

  4. If a specific test is failing, run only that test file:

    npx vitest run --project unit tests/path/to/test.test.ts

Related repositories

  • 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.

Contributing

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.

License

MIT. See LICENSE.

About

Indexer and read API for TricklePay streams. Mirrors on-chain contract events into Postgres and serves stream data over HTTP with live vested and withdrawable amounts. Built with TypeScript, Fastify, and Prisma.

Resources

Code of conduct

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages