Skip to content

Core Soroban Indexer Infrastructure #24

Description

@codebestia

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

  1. 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)
    
  2. 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.

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

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

  • IndexerCursor and IndexerEvent models added; prisma migrate dev runs cleanly
  • npm run indexer starts a standalone process that does not run inside server.ts/the Express app
  • Poller connects to testnet RPC, fetches the latest ledger, and logs every decoded event (topic + value) for the configured contract without erroring
  • Cursor persists to IndexerCursor after each processed batch and resumes correctly after restart (kill the process mid-run and confirm no gap or duplicate)
  • IndexerEvent prevents the same raw event id from being dispatched twice if the cursor causes a re-fetch of an already-seen ledger range
  • registry.dispatch on a topic with no registered handler logs and skips without throwing (expected — no handlers exist yet)
  • One bad/unparseable event logs an error and does not stop the poll loop
  • STELLAR_CONTRACT_ID unset → poller fails fast with a clear startup error rather than polling with an empty filter
  • No event-specific Prisma writes (Invoice, Transaction, MerchantAnalytics, etc.) exist anywhere in src/indexer/ at the end of this issue

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions