Repository navigation
feat(indexer): implement resumable and idempotent Soroban event indexer - #5
Conversation
diiabblo
left a comment
There was a problem hiding this comment.
Review: Soroban event indexer
The architecture is solid -- clean module separation (decoder, checkpoint, engine, metrics), proper exponential backoff retries, and a dead letter quarantine. A few bugs need fixing before merge:
Critical
Checkpoint advances to chain tip, not last-processed ledger (src/lib/indexer/engine.ts:330)
const latestLedger = response.latestLedger;
// ... process events ...
await setCheckpoint(latestLedger);
latestLedger is the RPC chain tip, not the ledger of events actually indexed. If the RPC returns events for ledgers 100-102 but the chain tip is 105, you checkpoint at 105. Next poll starts at 106, permanently skipping ledgers 103-105.
Fix: set the checkpoint to the maximum ledger among events that were actually processed:
const maxEventLedger = Math.max(...events.map(e => e.ledger), checkpoint ?? 0);
await setCheckpoint(maxEventLedger);
extractAmount uses parseFloat -- loses precision for on-chain amounts (src/lib/indexer/engine.ts:68)
Soroban amounts are i128 values. parseFloat truncates at 2^53 (~9 quadrillion stroops). A value of "10000000000000000001" (about 1000 XLM) will be silently rounded. This is especially important because PR #6 is migrating the database to exact NUMERIC(36,7) -- writing already-truncated values into exact columns gives false confidence.
Use BigInt or the bignumber.js library already included via @stellar/stellar-sdk:
import BigNumber from "bignumber.js";
const extractAmount = (data): bigint | null => { ... return BigInt(new BigNumber(val).toFixed(0)); };
Same issue applies to extractProposalId and extractStreamId if those IDs can exceed 2^53.
High
Vote.create is not idempotent (src/lib/indexer/engine.ts:250)
prisma.vote.create() will throw on replay since there is no unique constraint on (proposalId, voter). Two problems:
- Replaying the same
VoteCastevent creates duplicate votes - A crash after
createbut before checkpoint causes duplicates on restart
Either add a @@unique([proposalId, voter]) constraint and use upsert, or use create inside a try/catch that handles the constraint violation.
Medium
Unknown contract events fall through to TreasuryEvent.upsert (src/lib/indexer/engine.ts:270-282)
If contractId does not match any configured contract, the fallback stores it as a treasury event with address: null, amount: null, token: null. Unknown contracts should go to the dead letter queue instead.
StreamCreated with streamId === 0 silently dropped (src/lib/indexer/engine.ts:114)
if (streamId === 0 && event.topic === "StreamCreated") return;
Silently drops events. Should be quarantined so operators can investigate.
Missing tests
The issue requires "Integration tests use deterministic ledger fixtures" and "Forced crash and restart loses no committed events." The PR contains no test files. At minimum, add tests for:
- Idempotent replay (same ledger range processed twice produces no duplicates)
- Crash recovery (simulate a crash mid-batch, restart, verify no data loss)
- Checkpoint advancement correctness
extractAmountprecision for large values
Minor
@stellar/stellar-sdk requires Node >=22 but no engines field in package.json. Add:
"engines": { "node": ">=22.0.0" }
No graceful shutdown -- the setInterval in start() has no cleanup. Add a stop() method that calls clearInterval.
After PR #6 merges, rebase this PR and update extractAmount to return Decimal or bigint values compatible with the new schema column types.
|
Pushed a fix commit addressing all review feedback: Changes in 3aa2d3a
TypeScript compiles clean ( |
diiabblo
left a comment
There was a problem hiding this comment.
Re-review: fixes verified
All issues from the previous review have been addressed in commit 3aa2d3a:
| Issue | Fix | Status |
|---|---|---|
| Checkpoint to chain tip (data loss) | Now uses maxEventLedger from processed events |
Fixed |
parseFloat precision loss |
Switched to BigInt() with Number.isSafeInteger() guard |
Fixed |
Vote.create not idempotent |
Replaced with Vote.upsert + @@unique([proposalId, voter]) constraint + migration |
Fixed |
| Unknown contract → treasury | Now quarantined via quarantineEvent() |
Fixed |
| StreamCreated id=0 silent drop | Now quarantined with descriptive error | Fixed |
Missing engines field |
Added "node": ">=22.0.0" to package.json |
Fixed |
| No graceful shutdown | Added stop() with clearInterval, SIGTERM/SIGINT handlers in indexer.ts |
Fixed |
TypeScript compiles clean. Merge conflict with PR #6 (prisma/schema.prisma -- both modify different parts) is manageable; rebase this on #6 after it merges.
One note for the rebase on PR #6: after the Decimal migration lands, remove the Number() wrappers around extractAmount() results so bigint values are passed directly to Prisma Decimal columns without precision loss.
- Rewrite indexer.ts shell with real Soroban RPC event polling via getEvents - Add ledger checkpoint persistence in Redis for crash recovery - Add idempotent event upserts keyed on tx_hash + contract stream ID - Add event decoder mapping all 4 OrbitPay contracts to Prisma models - Add DeadLetterEvent model and quarantine for decode failures - Add Prometheus metrics (observed ledger, indexed ledger, lag, failures, retries) - Expose /metrics endpoint for existing monitoring scaffold - Extend config with Stellar RPC, contract IDs, indexer tuning params Closes OBP-ORG#1
- Fix checkpoint to use max event ledger, not RPC chain tip (data loss bug) - Replace parseFloat/parseInt with BigInt in extractAmount/extractProposalId/extractStreamId - Change Vote.create to Vote.upsert with @@unique(proposalId, voter) constraint - Quarantine unknown contract events instead of falling through to TreasuryEvent - Quarantine StreamCreated events missing stream_id instead of silently dropping - Add engines.node >=22.0.0 to package.json - Add graceful shutdown (stop method + SIGTERM/SIGINT handlers)
3aa2d3a to
868718a
Compare
Closes #1
Summary
Replaces the shell
indexer.tswith a production-grade Soroban event indexer. Polls the Stellar RPCgetEventsendpoint, decodes events from all 4 OrbitPay contracts, and upserts them into the existing Prisma models.Changes
Core Engine (
src/lib/indexer/)engine.ts— polling loop with exponential backoff retry, Redis checkpoint management, idempotent upsert routing per contract, dead letter quarantinedecoder.ts— parses event topics and values fromgetEventsresponses into structuredDecodedEventrecordscheckpoint.ts— persists last-indexed ledger in Redis, checkpoint advances only after successful DB writemetrics.ts— Prometheus gauges/counters for observed ledger, indexed ledger, lag seconds, failures, retries, events processed, decode failuresConfiguration
config.tswithstellar,contracts, andindexersections.env.exampleentries for RPC URL, contract IDs, and indexer tuning paramsSchema
DeadLetterEventmodel to Prisma schema with migrationEntry Point
src/indexer.tsto use the engine, expose/healthand/metricsendpointsQuality Gate Coverage
txHashunique constraint andcontractStreamIdunique constraintupsertwith stable chain identifierseventIdbuilt fromledger-txHash; upserts keyed ontxHashDeadLetterEventtable stores raw data, error, and ledger context/metricsINDEXER_START_LEDGERenv var controls replay from any ledgermonitoring/alerts.ymlrules