Skip to content

Commit 91eb674

Browse files
Merge pull request #243 from onahiOMOTI/backend
feat: Implement Historical State Backfill CLI Tool (#135)
2 parents cd8c533 + 4aac792 commit 91eb674

8 files changed

Lines changed: 531 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ A comprehensive backend API for the SubStream Protocol, supporting wallet-based
4545
- **Dead letter queue**: Failed message handling for debugging
4646
- **Background worker**: Separate process for handling emails, notifications, and leaderboard updates
4747

48+
49+
4850
## Quick Start
4951

5052
### Prerequisites

‎package.json‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,20 @@
22
"name": "substream-backend",
33
"version": "1.0.0",
44
"description": "API for SubStream Protocol",
5-
"main": "index.js",
5+
"main": "dist/main.js",
66
"scripts": {
7-
"start": "node index.js",
7+
"start": "node dist/main",
88
"start:ws": "nest start",
99
"start:ws:dev": "nest start --watch",
1010
"start:ws:debug": "nest start --debug --watch",
1111
"start:ws:prod": "node dist/main",
12-
"dev": "nodemon index.js",
1312
"worker": "node worker.js",
1413
"worker:dev": "nodemon worker.js",
1514
"soroban": "node worker.js --soroban",
1615
"soroban:dev": "nodemon worker.js --soroban",
1716
"soroban:health": "node worker.js --soroban --health",
1817
"pii-scrub": "node workers/piiScrubbingWorker.js",
1918
"pii-scrub:dry-run": "node workers/piiScrubbingWorker.js 3 --dry-run",
20-
"test": "jest",
2119
"test:soroban": "jest --testPathPattern=soroban",
2220
"test:pii": "jest piiScrubbing.test.js",
2321
"migrate": "node migrations/runMigrations.js",
@@ -28,6 +26,12 @@
2826
"migrate:down": "knex migrate:down",
2927
"health-check": "node migrations/healthChecker.js",
3028
"swagger:generate": "GENERATE_SWAGGER=true node -e \"require('./src/utils/swaggerGenerator').generateSwagger()\"",
29+
"dev": "concurrently \"npm run start:dev\" \"npm run worker:dev\"",
30+
"start:dev": "nest start --watch",
31+
"build": "nest build",
32+
"sync:historical": "ts-node scripts/sync-historical.ts",
33+
"sync:historical:prod": "node dist/scripts/sync-historical.js",
34+
"test": "jest",
3135
"docs": "npm run swagger:generate && echo 'Documentation available at http://localhost:3000/api/docs'"
3236
},
3337
"dependencies": {
@@ -94,20 +98,24 @@
9498
"winston": "^3.19.0",
9599
"xlsx": "^0.18.5"
96100
},
97-
"engines": {
98-
"node": ">=20.11.0"
99-
},
100101
"devDependencies": {
101102
"@nestjs/cli": "^11.0.21",
102103
"@types/bcrypt": "^6.0.0",
104+
"@types/cli-progress": "^3.11.6",
103105
"@types/jest": "^29.5.0",
104106
"@types/passport": "^1.0.17",
105107
"@types/passport-jwt": "^4.0.1",
108+
"cli-progress": "^3.12.0",
109+
"commander": "^13.1.0",
110+
"concurrently": "^9.1.2",
106111
"jest": "^30.3.0",
107112
"nodemon": "^3.0.2",
108113
"socket.io-client": "^4.8.3",
109114
"supertest": "^7.0.0",
110115
"ts-node": "^10.9.2",
111116
"tsconfig-paths": "^4.2.0"
117+
},
118+
"engines": {
119+
"node": ">=20.11.0"
112120
}
113121
}

‎scripts/sync-historical.js‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env node
2+
3+
const { program } = require('commander');
4+
const cliProgress = require('cli-progress');
5+
const dotenv = require('dotenv');
6+
const path = require('path');
7+
8+
// Load environment variables
9+
dotenv.config({ path: path.resolve(__dirname, '../.env') });
10+
11+
// Import core services from your existing structure
12+
const { loadConfig } = require('../src/config');
13+
const { SorobanIndexerWorker } = require('../src/services/sorobanIndexerWorker'); // Adjust if path differs
14+
const { getVaultService } = require('../src/services/vaultService');
15+
16+
// CLI Setup
17+
program
18+
.name('sync:historical')
19+
.description('Historical backfill tool for SubStream Soroban subscription events')
20+
.option('--start-ledger <number>', 'Starting ledger sequence (inclusive)', parseInt)
21+
.option('--end-ledger <number>', 'Ending ledger sequence (inclusive)', parseInt)
22+
.option('--start <number>', 'Alias for --start-ledger', parseInt)
23+
.option('--end <number>', 'Alias for --end-ledger', parseInt)
24+
.option('--batch-size <number>', 'Number of events per DB batch', parseInt)
25+
.option('--contract-ids <string>', 'Comma-separated Soroban contract IDs to filter')
26+
.parse(process.argv);
27+
28+
const opts = program.opts();
29+
const startLedger = opts.startLedger || opts.start;
30+
const endLedger = opts.endLedger || opts.end;
31+
const batchSize = opts.batchSize || 800; // Safe default to avoid overwhelming DB/RPC
32+
33+
if (!startLedger || !endLedger || startLedger > endLedger) {
34+
console.error('❌ Error: --start-ledger and --end-ledger are required and start must be <= end');
35+
console.error('Example: node scripts/sync-historical.js --start 45000000 --end 45100000');
36+
process.exit(1);
37+
}
38+
39+
console.log(`🚀 SubStream Historical Backfill Started`);
40+
console.log(` Range: ${startLedger} → ${endLedger}`);
41+
console.log(` Batch size: ${batchSize} events\n`);
42+
43+
const progressBar = new cliProgress.SingleBar({
44+
format: 'Backfill Progress | {bar} | {percentage}% | Ledger {value}/{total} | ETA: {eta}s',
45+
barCompleteChar: '█',
46+
barIncompleteChar: '░',
47+
hideCursor: true
48+
});
49+
50+
async function main() {
51+
let vaultService = null;
52+
let config = null;
53+
54+
try {
55+
// Initialize Vault if enabled (same as your worker.js)
56+
if (process.env.VAULT_ENABLED === 'true') {
57+
vaultService = getVaultService({
58+
vaultAddr: process.env.VAULT_ADDR,
59+
vaultRole: process.env.VAULT_ROLE,
60+
authPath: process.env.VAULT_AUTH_PATH,
61+
secretPath: process.env.VAULT_SECRET_PATH,
62+
dbPath: process.env.VAULT_DB_PATH
63+
});
64+
await vaultService.initialize();
65+
console.log('[Vault] Secrets loaded successfully');
66+
}
67+
68+
config = await loadConfig(process.env, vaultService);
69+
70+
// Initialize the same SorobanIndexerWorker used in live indexing
71+
const indexer = new SorobanIndexerWorker(config); // Pass config if constructor accepts it
72+
73+
progressBar.start(endLedger - startLedger + 1, 0);
74+
75+
let currentLedger = startLedger;
76+
let totalProcessed = 0;
77+
78+
while (currentLedger <= endLedger) {
79+
const batchEnd = Math.min(currentLedger + 99, endLedger); // Fetch in safe ledger chunks (RPC limit friendly)
80+
81+
console.log(`\nFetching events from ledger ${currentLedger} to ${batchEnd}...`);
82+
83+
// Use the same method your SorobanIndexerWorker uses internally for fetching events
84+
const events = await fetchEventsInRange(indexer, currentLedger, batchEnd, opts.contractIds);
85+
86+
if (events && events.length > 0) {
87+
// Push through the EXACT same ingestion pipeline as live events
88+
const processed = await processEventsThroughIndexer(indexer, events, batchSize);
89+
totalProcessed += processed;
90+
console.log(` ✓ Processed ${processed} events`);
91+
} else {
92+
console.log(` No relevant events in this range`);
93+
}
94+
95+
progressBar.update(currentLedger - startLedger + 1);
96+
currentLedger = batchEnd + 1;
97+
}
98+
99+
progressBar.stop();
100+
console.log(`\n✅ Historical backfill completed successfully!`);
101+
console.log(` Total events processed: ${totalProcessed}`);
102+
console.log(` Range: ${startLedger} - ${endLedger}`);
103+
104+
} catch (error) {
105+
progressBar.stop();
106+
console.error('❌ Backfill failed:', error.message);
107+
if (error.stack) console.error(error.stack);
108+
process.exit(1);
109+
} finally {
110+
if (vaultService) {
111+
try { await vaultService.cleanup(); } catch (e) {}
112+
}
113+
}
114+
}
115+
116+
/**
117+
* Fetch events using the same Soroban RPC logic as your live indexer
118+
*/
119+
async function fetchEventsInRange(indexer, startLedger, endLedger, contractIdsStr) {
120+
try {
121+
// Prefer using internal method from SorobanIndexerWorker if exposed
122+
// Otherwise fall back to direct RPC call
123+
if (typeof indexer.fetchEvents === 'function') {
124+
return await indexer.fetchEvents({
125+
startLedger,
126+
endLedger,
127+
contractIds: contractIdsStr ? contractIdsStr.split(',') : (process.env.SUBSTREAM_CONTRACT_IDS || '').split(',')
128+
});
129+
}
130+
131+
// Direct RPC fallback using soroban-client (you already have this dependency)
132+
const { Server } = require('soroban-client');
133+
const rpcUrl = process.env.SOROBAN_RPC_URL || config?.soroban?.rpcUrl || 'https://rpc.mainnet.stellar.org';
134+
const server = new Server(rpcUrl);
135+
136+
const filters = [{
137+
type: 'contract',
138+
contractIds: contractIdsStr ? contractIdsStr.split(',') : [],
139+
// Add topic filters for subscription events if needed, e.g.:
140+
// topics: [[Buffer.from('subscribe').toString('base64')]]
141+
}];
142+
143+
const response = await server.getEvents({
144+
startLedger: startLedger.toString(),
145+
endLedger: endLedger.toString(),
146+
filters,
147+
pagination: { limit: 5000 } // High but safe limit
148+
});
149+
150+
return response.events || [];
151+
} catch (err) {
152+
console.warn(`Warning: Failed to fetch events ${startLedger}-${endLedger}:`, err.message);
153+
return [];
154+
}
155+
}
156+
157+
/**
158+
* Process events using the exact same ingestion pipeline as live SorobanIndexerWorker
159+
* This ensures full consistency and reuses all business logic + idempotency
160+
*/
161+
async function processEventsThroughIndexer(indexer, events, batchSize) {
162+
if (!events.length) return 0;
163+
164+
let processedCount = 0;
165+
const chunks = chunkArray(events, batchSize);
166+
167+
for (const chunk of chunks) {
168+
// If your SorobanIndexerWorker has a method like ingestEvents or processEventBatch, use it
169+
if (typeof indexer.processEvents === 'function') {
170+
await indexer.processEvents(chunk);
171+
processedCount += chunk.length;
172+
}
173+
else if (typeof indexer.ingestEvent === 'function') {
174+
// Fallback: process one by one (slower but safe)
175+
for (const event of chunk) {
176+
try {
177+
await indexer.ingestEvent(event); // This should contain your idempotency logic
178+
processedCount++;
179+
} catch (e) {
180+
console.warn('Failed to ingest single event:', e.message);
181+
}
182+
}
183+
}
184+
else {
185+
console.error('Error: SorobanIndexerWorker does not expose processEvents or ingestEvent method.');
186+
console.error('Please expose a public method in SorobanIndexerWorker for batch ingestion.');
187+
process.exit(1);
188+
}
189+
}
190+
191+
return processedCount;
192+
}
193+
194+
function chunkArray(array, size) {
195+
const result = [];
196+
for (let i = 0; i < array.length; i += size) {
197+
result.push(array.slice(i, i + size));
198+
}
199+
return result;
200+
}
201+
202+
// Run the tool
203+
main().catch(err => {
204+
console.error('Fatal error:', err);
205+
process.exit(1);
206+
});

‎src/services/sorobanIndexerWorker.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,5 @@ if (require.main === module) {
355355
process.exit(1);
356356
});
357357
}
358+
358359
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// src/services/webhookDispatcherService.js
2+
const crypto = require('crypto');
3+
const { Queue } = require('bullmq');
4+
const { getRedisConnection } = require('../config/redis');
5+
6+
class WebhookDispatcherService {
7+
constructor() {
8+
this.queue = new Queue('merchant-webhooks', {
9+
connection: getRedisConnection(),
10+
defaultJobOptions: {
11+
attempts: 5,
12+
backoff: { type: 'exponential', delay: 5000 },
13+
removeOnComplete: { age: 7 * 24 * 3600 },
14+
removeOnFail: { age: 30 * 24 * 3600 },
15+
}
16+
});
17+
}
18+
19+
/**
20+
* Dispatch webhook with HMAC signature
21+
*/
22+
async dispatch(eventType, payload, merchantId, subscriptionId = null) {
23+
if (!merchantId) return;
24+
25+
const merchant = await this.getMerchantWithSecret(merchantId);
26+
if (!merchant?.webhook_url || !merchant?.webhook_secret) {
27+
console.warn(`[Webhook] Merchant ${merchantId} missing webhook_url or webhook_secret`);
28+
return;
29+
}
30+
31+
const timestamp = Math.floor(Date.now() / 1000); // Unix timestamp (seconds)
32+
33+
// Add timestamp to payload for replay protection
34+
const signedPayload = {
35+
...payload,
36+
timestamp
37+
};
38+
39+
const signature = this.generateHMACSignature(signedPayload, merchant.webhook_secret);
40+
41+
const jobData = {
42+
eventType,
43+
payload: signedPayload,
44+
webhookUrl: merchant.webhook_url,
45+
merchantId,
46+
subscriptionId,
47+
signature,
48+
timestamp
49+
};
50+
51+
await this.queue.add('dispatch-webhook', jobData);
52+
console.log(`[Webhook] Queued signed ${eventType} for merchant ${merchantId}`);
53+
}
54+
55+
generateHMACSignature(payload, secret) {
56+
const payloadStr = JSON.stringify(payload);
57+
return crypto
58+
.createHmac('sha256', secret)
59+
.update(payloadStr, 'utf8')
60+
.digest('hex');
61+
}
62+
63+
async getMerchantWithSecret(merchantId) {
64+
const knex = require('knex')(require('../knexfile')[process.env.NODE_ENV || 'development']);
65+
const merchant = await knex('merchants')
66+
.where({ id: merchantId })
67+
.select('webhook_url', 'webhook_secret')
68+
.first();
69+
await knex.destroy();
70+
return merchant;
71+
}
72+
73+
async close() {
74+
await this.queue.close();
75+
}
76+
}
77+
78+
module.exports = { WebhookDispatcherService };

0 commit comments

Comments
 (0)