Skip to content

Commit 5c66d50

Browse files
authored
Merge pull request #222 from fathiaoyinloye/feature/stellar-event-indexer
feat: add Stellar event indexing service
2 parents ece47b1 + d6a2c47 commit 5c66d50

8 files changed

Lines changed: 1548 additions & 38 deletions

File tree

backend/SETUP_GUIDE.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,79 @@ MONITORING_PROCESSING_TIME_THRESHOLD=30000
101101
MONITORING_GAS_FEE_SPIKE_THRESHOLD=2.0
102102
MONITORING_NETWORK_CONGESTION_THRESHOLD=0.8
103103

104+
# ─────────────────────────────────────────────────────────────────
105+
# Event Indexer Configuration (NEW)
106+
# ─────────────────────────────────────────────────────────────────
107+
# Master switch – set to "true" to enable the on-chain event indexer.
108+
# When disabled the service starts normally but no Soroban events are polled.
109+
EVENT_INDEXER_ENABLED=false
110+
111+
# Soroban RPC endpoint the indexer uses to fetch events.
112+
# Defaults to the public Stellar testnet RPC if not set.
113+
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
114+
115+
# Comma-separated list of Soroban contract addresses to watch.
116+
# Example: CAABC...XYZ,CBBDE...QRS
117+
# Leave empty to disable indexing even when EVENT_INDEXER_ENABLED=true.
118+
EVENT_INDEXER_CONTRACT_IDS=
119+
120+
# How often (in milliseconds) the indexer polls for new events.
121+
# Default: 5000 (5 seconds). Minimum recommended: 2000.
122+
EVENT_INDEXER_POLL_INTERVAL=5000
123+
124+
# Number of ledgers to process per poll batch.
125+
# Larger values reduce HTTP round-trips but increase per-batch latency.
126+
# Default: 100
127+
EVENT_INDEXER_BATCH_SIZE=100
128+
129+
# Optional: override the starting ledger for back-fills or initial sync.
130+
# If not set the indexer resumes from the last saved checkpoint (or ledger 0).
131+
# EVENT_INDEXER_START_LEDGER=
132+
104133
# Logging
105134
LOG_LEVEL=info
106135
LOG_FILE_PATH=./logs
107136
```
108137

138+
#### Event Indexer Quick Start
139+
140+
1. Set `EVENT_INDEXER_ENABLED=true` in your `.env`
141+
2. Set `EVENT_INDEXER_CONTRACT_IDS` to your deployed Soroban contract address(es)
142+
3. Confirm `SOROBAN_RPC_URL` points to the right network (testnet or mainnet)
143+
4. Run the database migration to create the required tables:
144+
```bash
145+
npm run migrate:up
146+
```
147+
5. Start the server – the indexer will boot automatically:
148+
```bash
149+
npm run dev
150+
```
151+
6. Verify via the health endpoint:
152+
```bash
153+
curl http://localhost:3001/health | jq '.eventIndexer'
154+
# Expected: {"status":"running","lastLedger":12345,"eventsProcessed":42,"lag":3}
155+
```
156+
157+
#### Admin API (requires admin JWT)
158+
159+
| Method | Path | Description |
160+
|--------|------|-------------|
161+
| `GET` | `/api/v1/indexer/status` | Current indexer status |
162+
| `POST` | `/api/v1/indexer/start` | Start the indexer |
163+
| `POST` | `/api/v1/indexer/stop` | Gracefully stop the indexer |
164+
165+
#### Indexed Event Types
166+
167+
| Event Type | Soroban topic key | Domain side-effect |
168+
|---|---|---|
169+
| `CredentialIssued` | `cred:issued` | Upserts row in `credentials` table |
170+
| `CredentialRevoked` | `cred:revoked` | Sets `status='revoked'` in `credentials` |
171+
| `CourseCreated` | `course:created` | Inserts row in `courses` table |
172+
| `EnrollmentCreated` | `enroll:created` | Inserts row in `enrollments` table |
173+
| `AchievementMinted` | `ach:minted` | Logged only |
174+
| `PaymentReceived` | `pay:received` | Logged only |
175+
| `ProfileUpdated` | `profile:update` | Logged only |
176+
109177
### 4. Redis Setup
110178

111179
#### Option 1: Local Redis Installation
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- UP
2+
-- Migration: Create indexed_events table for Stellar/Soroban contract event indexing
3+
-- Each event is uniquely keyed by (contract_id, ledger, event_index) to prevent duplicates.
4+
5+
CREATE TABLE IF NOT EXISTS indexed_events (
6+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7+
contract_id VARCHAR(64) NOT NULL,
8+
ledger BIGINT NOT NULL,
9+
event_index INTEGER NOT NULL,
10+
event_type VARCHAR(64) NOT NULL,
11+
topic TEXT[] NOT NULL DEFAULT '{}',
12+
payload JSONB NOT NULL DEFAULT '{}',
13+
processed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
14+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
15+
16+
-- Deduplication: same event on same contract at same ledger position must not appear twice
17+
CONSTRAINT uq_indexed_events_key UNIQUE (contract_id, ledger, event_index)
18+
);
19+
20+
-- Index for efficient range queries by ledger (used by the indexer resume logic)
21+
CREATE INDEX IF NOT EXISTS idx_indexed_events_ledger
22+
ON indexed_events (ledger ASC);
23+
24+
-- Index for querying by contract
25+
CREATE INDEX IF NOT EXISTS idx_indexed_events_contract_id
26+
ON indexed_events (contract_id);
27+
28+
-- Index for querying by event type
29+
CREATE INDEX IF NOT EXISTS idx_indexed_events_event_type
30+
ON indexed_events (event_type);
31+
32+
-- Index to support last-processed-ledger checkpoint lookups
33+
CREATE INDEX IF NOT EXISTS idx_indexed_events_processed_at
34+
ON indexed_events (processed_at);
35+
36+
-- Table to persist the indexer checkpoint (last successfully indexed ledger per contract set)
37+
CREATE TABLE IF NOT EXISTS indexer_checkpoints (
38+
id SERIAL PRIMARY KEY,
39+
checkpoint_key VARCHAR(64) NOT NULL UNIQUE, -- e.g. 'default' or contract group name
40+
last_ledger BIGINT NOT NULL DEFAULT 0,
41+
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
42+
);
43+
44+
-- Seed a default checkpoint row so the indexer can always UPDATE rather than INSERT/UPDATE
45+
INSERT INTO indexer_checkpoints (checkpoint_key, last_ledger)
46+
VALUES ('default', 0)
47+
ON CONFLICT (checkpoint_key) DO NOTHING;
48+
49+
-- @undo
50+
DROP TABLE IF EXISTS indexer_checkpoints;
51+
DROP INDEX IF EXISTS idx_indexed_events_processed_at;
52+
DROP INDEX IF EXISTS idx_indexed_events_event_type;
53+
DROP INDEX IF EXISTS idx_indexed_events_contract_id;
54+
DROP INDEX IF EXISTS idx_indexed_events_ledger;
55+
DROP TABLE IF EXISTS indexed_events;

backend/src/index.js

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const transactionProcessor = require('./workers/transactionProcessor');
1919
const transactionEvents = require('./events/transactionEvents');
2020
const emailWorker = require('./workers/emailWorker');
2121

22+
// Event Indexer – polls Soroban for on-chain events and syncs them to PostgreSQL
23+
let eventIndexerInstance = null;
24+
const EVENT_INDEXER_ENABLED = process.env.EVENT_INDEXER_ENABLED === 'true';
25+
2226
// Import security middleware
2327
const {
2428
securityPerformanceTracker,
@@ -216,22 +220,46 @@ v1Router.use('/cross-protocol-bridge', crossProtocolBridgeRoutes);
216220
const adminRoutes = require('./routes/admin');
217221
v1Router.use('/admin', adminRoutes);
218222

219-
// Schemas helper for versioned responses
220-
const { createVersionedResponse } = require('./utils/schemas');
221-
const { errorHandler } = require('./middleware/errorHandler');
222-
const { ValidationError } = require('./utils/errors');
223-
const { getCompressionStats } = require('./middleware/compression');
223+
// Event Indexer admin routes (start / stop / status)
224+
const indexerAdminRouter = require('express').Router();
224225

225-
// Health check under v1 for OpenAPI spec compatibility
226-
v1Router.get('/health', (req, res) => {
227-
const version = req.apiVersion || 'v1';
228-
res.json(createVersionedResponse({
229-
status: 'healthy',
230-
uptime: process.uptime(),
231-
supportedVersions: SUPPORTED_VERSIONS,
232-
}, version));
226+
indexerAdminRouter.get('/status', (req, res) => {
227+
try {
228+
const { getIndexerStatus } = require('./services/eventIndexer');
229+
res.json({ eventIndexer: getIndexerStatus() });
230+
} catch (err) {
231+
res.json({ eventIndexer: { status: 'stopped', error: err.message } });
232+
}
233233
});
234234

235+
indexerAdminRouter.post('/start', async (req, res) => {
236+
try {
237+
if (!eventIndexerInstance) {
238+
return res.status(400).json({ error: 'Indexer not initialized' });
239+
}
240+
await eventIndexerInstance.start();
241+
const { getIndexerStatus } = require('./services/eventIndexer');
242+
res.json({ message: 'Indexer started', status: getIndexerStatus() });
243+
} catch (err) {
244+
res.status(500).json({ error: err.message });
245+
}
246+
});
247+
248+
indexerAdminRouter.post('/stop', async (req, res) => {
249+
try {
250+
if (!eventIndexerInstance) {
251+
return res.status(400).json({ error: 'Indexer not initialized' });
252+
}
253+
await eventIndexerInstance.stop();
254+
const { getIndexerStatus } = require('./services/eventIndexer');
255+
res.json({ message: 'Indexer stopped', status: getIndexerStatus() });
256+
} catch (err) {
257+
res.status(500).json({ error: err.message });
258+
}
259+
});
260+
261+
v1Router.use('/indexer', require('./middleware/auth').requireAdmin, indexerAdminRouter);
262+
235263
// Mount v1 router at /api/v1
236264
app.use('/api/v1', v1Router);
237265

@@ -304,6 +332,27 @@ async function startServer() {
304332
await transactionEvents.startListening();
305333
emailWorker.getEmailWorker().start();
306334

335+
// Start the event indexer if enabled
336+
if (EVENT_INDEXER_ENABLED) {
337+
try {
338+
const { Pool } = require('pg');
339+
const { getEventIndexer } = require('./services/eventIndexer');
340+
const indexerPool = new Pool({
341+
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/starked',
342+
max: 5, // dedicated small pool for the indexer
343+
idleTimeoutMillis: 30000,
344+
connectionTimeoutMillis: 5000,
345+
});
346+
eventIndexerInstance = getEventIndexer(indexerPool);
347+
await eventIndexerInstance.start();
348+
console.log('🔗 Event Indexer started – polling Soroban for on-chain events');
349+
} catch (indexerErr) {
350+
console.error('⚠️ Event Indexer failed to start (non-fatal):', indexerErr.message);
351+
}
352+
} else {
353+
console.log('ℹ️ Event Indexer disabled. Set EVENT_INDEXER_ENABLED=true to enable.');
354+
}
355+
307356
server.listen(PORT, () => {
308357
console.log(`🚀 StarkEd Education Backend running on port ${PORT}`);
309358
console.log(`📚 Quiz Management API available at /api/v1/quizzes`);
@@ -317,6 +366,7 @@ async function startServer() {
317366
console.log(`🌐 Federated Learning API available at /api/v1/federated-learning`);
318367
console.log(`🧠 AGI Tutor API available at /api/v1/agi-tutor`);
319368
console.log(`🔐 Quantum-Resistant Secure Communication API available at /api/v1/secure-comm`);
369+
console.log(`🔗 Event Indexer API available at /api/v1/indexer (admin-only)`);
320370
console.log(`🏥 Health check available at /api/health`);
321371
console.log(`✅ Transaction Queue System initialized successfully`);
322372
});
@@ -328,7 +378,14 @@ async function startServer() {
328378

329379
process.on('SIGINT', async () => {
330380
console.log('SIGINT received, shutting down gracefully...');
331-
emailWorker.getEmailWorker().stop();
381+
if (eventIndexerInstance) {
382+
try {
383+
await eventIndexerInstance.stop();
384+
console.log('Event Indexer stopped cleanly.');
385+
} catch (err) {
386+
console.error('Error stopping event indexer:', err.message);
387+
}
388+
}
332389
await transactionQueue.stopProcessing();
333390
await transactionProcessor.stop();
334391
await transactionEvents.stopListening();

0 commit comments

Comments
 (0)