The indexer is a background service that polls Stellar for Soroban contract events and writes them to PostgreSQL. This keeps the REST API fast without hitting the blockchain on every request.
Soroban contracts don't store easily queryable lists. You can read individual records by key, but you can't ask "give me all escrows for address X" efficiently on-chain. The indexer solves this by listening to events and maintaining a relational DB mirror.
Stellar Network Indexer Service PostgreSQL
│ │ │
│ ← poll events ─────────┤ │
│ │ │
│ ─ EscrowCreated ──────►│ │
│ ─ MilestoneAdded ─────►│ ─ INSERT escrow ────►│
│ ─ MilestoneApproved ──►│ ─ UPDATE milestone ─►│
│ ─ ReputationUpdated ──►│ ─ UPSERT reputation ►│
│ │ │
startIndexer()
│
├─► Load lastProcessedLedger from DB (indexer_state table)
│
└─► setInterval(every 5s):
│
├─► fetchAndProcessEvents(server)
│ │
│ ├─► server.getEvents({ startLedger, contractIds: [CONTRACT_ADDRESS] })
│ │
│ └─► for each event:
│ │
│ └─► dispatchEvent(event)
│ │
│ ├─► parse topic[0] → event name
│ │
│ └─► call handler (handleEscrowCreated, etc.)
│
└─► update lastProcessedLedger in DB
Each Soroban event has:
topics: array of ScVal —[event_name_symbol, primary_id, ...]data: ScVal — the event payload
// Example: EscrowCreated event
// topic[0]: symbol "esc_crt"
// topic[1]: u64 escrow_id
// data: tuple (client_address, freelancer_address, amount)
const eventName = scValToNative(event.topic[0]); // → "esc_crt"
const escrowId = scValToNative(event.topic[1]); // → 42n
const [client, freelancer, amount] = scValToNative(event.data);| Event Symbol | Handler | DB Action |
|---|---|---|
esc_crt |
handleEscrowCreated |
escrow.create() |
mil_add |
handleMilestoneAdded |
milestone.create() |
mil_sub |
handleMilestoneSubmitted |
milestone.update(status: Submitted) |
mil_apr |
handleMilestoneApproved |
milestone.update(status: Approved) |
funds_rel |
handleFundsReleased |
escrow.update(remaining_balance) |
esc_can |
handleEscrowCancelled |
escrow.update(status: Cancelled) |
dis_rai |
handleDisputeRaised |
escrow.update(status: Disputed), dispute.create() |
dis_res |
handleDisputeResolved |
escrow.update(status: Completed), dispute.update() |
rep_upd |
handleReputationUpdated |
reputationRecord.upsert() |
The indexer starts automatically when the backend server starts (once implemented). To test it in isolation:
cd backend
node -e "require('./services/escrowIndexer').startIndexer()"- Define the event in
contracts/escrow_contract/src/events.rs - Add the topic symbol to the
TOPICSmap inescrowIndexer.js - Add a case in
dispatchEvent:case 'my_event': return handleMyEvent(event);
- Implement the handler:
const handleMyEvent = async (event) => { const id = scValToNative(event.topic[1]); const data = scValToNative(event.data); await prisma.someTable.update({ where: { id }, data: { ... } }); };
- Export the handler from
escrowIndexer.jsfor unit testing - Write a test that mocks the event and verifies the DB update
The indexer should never crash on a single bad event. Each handler should:
const handleMyEvent = async (event) => {
try {
// ... process event
} catch (err) {
console.error('[Indexer] handleMyEvent failed:', err.message, event);
// TODO: send to error tracking (Sentry, etc.) — see Issue #44
// Do NOT re-throw — let the indexer continue
}
};The indexer_state table stores the last processed ledger:
SELECT * FROM indexer_state;
-- id | last_processed_ledger
-- 1 | 7234891On startup, the indexer reads this and resumes from there. If the table is empty,
it starts from INDEXER_START_LEDGER in .env.
| Issue | Task |
|---|---|
| #27 | Implement startIndexer, fetchAndProcessEvents, dispatchEvent, and all event handlers |
| #43 | Implement stellarService.getContractEvents |
| #42 | This documentation file (you're reading it!) |