Background
The shade contract emits structured events for every state change. Before any specific event can be handled, the backend needs a generic pipeline that connects to Soroban RPC, polls the configured contract for new events, decodes them, and dispatches them somewhere — without yet deciding what any individual event does to the database.
This issue is scoped strictly to that infrastructure: the RPC client, the poll loop, cursor/dedupe persistence, and an empty dispatch registry that future issues plug handlers into. No event-specific business logic (invoice status transitions, transaction records, analytics, etc.) is implemented here — that is out of scope and will be broken out into separate issues per event, one at a time.
Proposed Steps
-
Environment config — add to src/config/environment.ts and .env.example:
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
STELLAR_CONTRACT_ID=
STELLAR_INDEXER_START_LEDGER= # optional; defaults to latest ledger at boot (no backfill)
-
Cursor + dedupe persistence — add IndexerCursor and IndexerEvent models (see Schema Changes) and run prisma migrate dev. Without a persisted cursor, every restart re-polls from "now" and loses anything processed while the process was down; without a dedupe record, a crash between processing and cursor-persist causes the same event to be dispatched twice.
-
File structure — new src/indexer/ module:
src/indexer/
sorobanClient.ts — rpc.Server singleton, built from environment.stellar.rpcUrl
types.ts — generic DecodedEvent shape: { id, topic: string, ledger, txHash, data }
registry.ts — registerEventHandler(topic, handler) / dispatch(event) — empty registry, no handlers registered yet
poller.ts — poll loop: getLatestLedger → getEvents(cursor) → decode → dedupe check → dispatch → persist cursor + mark event processed
run.ts — entrypoint, calls startPolling(), run as its own process (not inside Express)
handlers/ — empty directory; populated one event at a time by future issues
Add "indexer": "tsx watch src/indexer/run.ts" to package.json scripts. This runs as a separate long-lived process from the API server — do not start it inside server.ts.
-
Poll loop mechanics (poller.ts):
- On boot:
cursor = IndexerCursor.lastLedger ?? (STELLAR_INDEXER_START_LEDGER ?? latestLedger)
- Every ~6s (ledger close time): call
sorobanServer.getEvents({ startLedger: cursor, filters: [{ type: 'contract', contractIds: [STELLAR_CONTRACT_ID] }], limit: 100 })
- For each
EventResponse: skip it if its id already exists in IndexerEvent (replay guard); otherwise decode topic[0] and value via scValToNative and call registry.dispatch(decodedEvent)
- If no handler is registered for a topic,
dispatch should log and skip — this is the expected state for every topic until its handler issue lands
- After a successful batch: persist processed event ids to
IndexerEvent and advance/persist the cursor (latestLedger.sequence + 1) — persist after processing, not before, so a crash mid-batch replays rather than skips
- Wrap each
tick() in try/catch — one bad event must not kill the loop; log and continue
Schema Changes
IndexerCursor (new model)
id String (uuid, PK)
contractId String (unique — the Soroban contract address being tracked)
lastLedger Int (last successfully processed ledger sequence)
updatedAt DateTime
IndexerEvent (new model — generic replay guard, independent of any handler)
id String (the Soroban event id from EventResponse, PK)
topic String (decoded topic name, for observability/debugging)
ledger Int
processedAt DateTime (default now)
Acceptance Criteria
Background
The
shadecontract emits structured events for every state change. Before any specific event can be handled, the backend needs a generic pipeline that connects to Soroban RPC, polls the configured contract for new events, decodes them, and dispatches them somewhere — without yet deciding what any individual event does to the database.This issue is scoped strictly to that infrastructure: the RPC client, the poll loop, cursor/dedupe persistence, and an empty dispatch registry that future issues plug handlers into. No event-specific business logic (invoice status transitions, transaction records, analytics, etc.) is implemented here — that is out of scope and will be broken out into separate issues per event, one at a time.
Proposed Steps
Environment config — add to
src/config/environment.tsand.env.example:Cursor + dedupe persistence — add
IndexerCursorandIndexerEventmodels (see Schema Changes) and runprisma migrate dev. Without a persisted cursor, every restart re-polls from "now" and loses anything processed while the process was down; without a dedupe record, a crash between processing and cursor-persist causes the same event to be dispatched twice.File structure — new
src/indexer/module:Add
"indexer": "tsx watch src/indexer/run.ts"topackage.jsonscripts. This runs as a separate long-lived process from the API server — do not start it insideserver.ts.Poll loop mechanics (
poller.ts):cursor = IndexerCursor.lastLedger ?? (STELLAR_INDEXER_START_LEDGER ?? latestLedger)sorobanServer.getEvents({ startLedger: cursor, filters: [{ type: 'contract', contractIds: [STELLAR_CONTRACT_ID] }], limit: 100 })EventResponse: skip it if itsidalready exists inIndexerEvent(replay guard); otherwise decodetopic[0]andvalueviascValToNativeand callregistry.dispatch(decodedEvent)dispatchshould log and skip — this is the expected state for every topic until its handler issue landsIndexerEventand advance/persist the cursor (latestLedger.sequence + 1) — persist after processing, not before, so a crash mid-batch replays rather than skipstick()in try/catch — one bad event must not kill the loop; log and continueSchema Changes
IndexerCursor (new model)
IndexerEvent (new model — generic replay guard, independent of any handler)
Acceptance Criteria
IndexerCursorandIndexerEventmodels added;prisma migrate devruns cleanlynpm run indexerstarts a standalone process that does not run insideserver.ts/the Express appIndexerCursorafter each processed batch and resumes correctly after restart (kill the process mid-run and confirm no gap or duplicate)IndexerEventprevents the same raw event id from being dispatched twice if the cursor causes a re-fetch of an already-seen ledger rangeregistry.dispatchon a topic with no registered handler logs and skips without throwing (expected — no handlers exist yet)STELLAR_CONTRACT_IDunset → poller fails fast with a clear startup error rather than polling with an empty filtersrc/indexer/at the end of this issue