Skip to main content

Event Indexer

The Novatip backend runs a Soroban event indexer alongside the HTTP server. It polls the Soroban RPC for TipReceived events emitted by the tip_splitter contract, decodes them using @novatip/sdk, persists them to PostgreSQL, and triggers webhook and email notifications.


How It Works​

┌─────────────────────────────────────────────────┐
│ Indexer Loop │
│ (runs every 6 seconds in the same process) │
│ │
│ 1. Read cursor from IndexerCursor table │
│ 2. fetchTipEvents({ startLedger: cursor + 1 }) │
│ 3. For each decoded TipEvent: │
│ a. persistTip() -> PostgreSQL │
│ b. dispatchWebhooks() │
│ c. sendTipNotification() │
│ 4. updateCursor(lastLedger) │
│ 5. Sleep 6 seconds, repeat │
└─────────────────────────────────────────────────┘

Cursor Persistence​

The indexer stores the last successfully processed ledger sequence number in the IndexerCursor table (a single-row table with id = 1).

On startup the indexer:

  1. Reads IndexerCursor.lastLedger from the database
  2. Resumes from lastLedger + 1
  3. Falls back to INDEXER_START_LEDGER env var if the cursor has never been set

This means restarts are always safe - no events are reprocessed and none are skipped, as long as the cursor is updated after each batch.


Idempotency​

Tips are upserted using txHash as the unique key:

await db.tip.upsert({
where: { txHash },
update: {}, // already persisted - no-op
create: { ...tipData },
});

If the indexer crashes between persisting a tip and updating the cursor, the same tip may be processed again on restart. The upsert guarantees the database ends up in the correct state regardless.


Event Decoding​

The SDK handles all XDR decoding. Raw Soroban RPC events look like:

{
"type": "contract",
"ledger": 1234567,
"ledgerClosedAt": "2024-06-15T12:34:56Z",
"contractId": "C...",
"topic": ["AAAADwAAAAN0aXAAAAA=", "AAAAA...base64..."],
"value": "AAAAA...base64..."
}

fetchTipEvents() decodes this into:

{
jarId: "@alice",
from: "G...",
amount: 25000000n,
message: "Great stream!",
ledger: 1234567,
timestamp: "2024-06-15T12:34:56Z",
}

Database Schema​

Tip table​

ColumnTypeDescription
idcuidInternal primary key
txHashString (unique)Soroban transaction hash - idempotency key
ledgerIntLedger sequence of the event
ledgerAtDateTimeLedger close timestamp
fromAddressStringSender Stellar address
amountStringAmount in stroops (String to preserve i128 precision)
messageStringOptional supporter message
creatorIdStringForeign key to Creator

IndexerCursor table​

ColumnTypeDescription
idInt (always 1)Single-row table
lastLedgerIntLast ledger successfully processed
updatedAtDateTimeAuto-updated timestamp

Configuration​

Env varDefaultDescription
TIP_SPLITTER_CONTRACT_IDrequiredContract to listen on
SOROBAN_RPC_URLtestnet RPCSoroban RPC endpoint to poll
STELLAR_NETWORKtestnetNetwork preset
INDEXER_START_LEDGER0Ledger to start from if cursor is empty

Setting INDEXER_START_LEDGER:

On first deployment, set this to the current ledger sequence number to avoid replaying the entire chain history. You can find the current ledger on Stellar Expert or via:

curl https://soroban-testnet.stellar.org \
-X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger","params":{}}' \
| jq .result.sequence

Error Handling​

The indexer is designed to never crash the process:

  • RPC errors are caught, logged, and the loop backs off for 12 seconds before retrying
  • Per-event errors (bad webhook, failed email) are caught and logged but do not block other events
  • If a tip's jarId is not registered in the database, it is silently skipped (it may belong to a different application using the same contract)

Monitoring​

Check the indexer status from the server logs:

[indexer] starting - contract=C... network=testnet
[indexer] resuming from ledger 1234567
[indexer] processing 3 event(s) from ledger 1234901
[indexer] stopped

For production deployments, consider forwarding these logs to a service like Datadog, Logtail, or Grafana Loki for alerting on indexer stalls.


Running the Indexer Standalone​

The indexer starts automatically when you run the backend server. If you need to run it as a separate process (e.g. for horizontal scaling), extract the startIndexer() call into its own entry point:

// src/indexer-worker.ts
import "dotenv/config";
import { startIndexer } from "./indexer/indexer.js";

await startIndexer();

Then run:

node --loader ts-node/esm src/indexer-worker.ts
info

In a production setup with high tip volume, consider running one indexer process and multiple API server processes behind a load balancer.