soroban-events 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # soroban-events
2
+
3
+ Lightweight Soroban RPC event ingestion, pagination, retries, deduplication, and ScVal decoding for Node.js.
4
+
5
+ Build Soroban dashboards, activity feeds, lightweight indexers, analytics tools, monitoring services, bots, and event-driven backends without implementing RPC pagination and event decoding yourself.
6
+
7
+ ## Features
8
+
9
+ - Ledger-range windowing
10
+ - RPC cursor pagination
11
+ - Event deduplication
12
+ - Rate-limit and transient retries
13
+ - Soroban `ScVal` decoding
14
+ - Large integer precision preservation
15
+ - Contract event filtering
16
+ - Recent-event lookup with `tail()`
17
+ - Async event streaming with `stream()`
18
+ - AbortSignal support
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install soroban-events
24
+ ```
25
+
26
+ Requires Node.js 20+.
27
+
28
+ ## Quick start
29
+
30
+ ```js
31
+ import { SorobanEventStreamer } from 'soroban-events';
32
+
33
+ const streamer = new SorobanEventStreamer(
34
+ 'https://soroban-testnet.stellar.org'
35
+ );
36
+
37
+ const latest = await streamer.getLatestLedger();
38
+
39
+ const events = await streamer.getEventsWindowed({
40
+ startLedger: latest - 100,
41
+ endLedger: latest,
42
+ filters: [{ type: 'contract' }],
43
+ limit: 10
44
+ });
45
+
46
+ for (const event of events) {
47
+ console.log(event);
48
+ }
49
+ ```
50
+
51
+ ## Filter by contract
52
+
53
+ ```js
54
+ const events = await streamer.getEventsWindowed({
55
+ startLedger: latest - 1000,
56
+ endLedger: latest,
57
+ filters: [
58
+ {
59
+ type: 'contract',
60
+ contractIds: ['YOUR_CONTRACT_ID']
61
+ }
62
+ ],
63
+ limit: 100
64
+ });
65
+ ```
66
+
67
+ ## Get recent events
68
+
69
+ ```js
70
+ const events = await streamer.tail({
71
+ contractId: 'YOUR_CONTRACT_ID',
72
+ limit: 20
73
+ });
74
+
75
+ console.log(events);
76
+ ```
77
+
78
+ ## Stream events
79
+
80
+ ```js
81
+ for await (const event of streamer.stream({
82
+ startLedger: 4750000,
83
+ filters: [
84
+ {
85
+ type: 'contract',
86
+ contractIds: ['YOUR_CONTRACT_ID']
87
+ }
88
+ ]
89
+ })) {
90
+ console.log('New event:', event);
91
+ }
92
+ ```
93
+
94
+ Stop a stream with `AbortController`:
95
+
96
+ ```js
97
+ const controller = new AbortController();
98
+
99
+ setTimeout(() => controller.abort(), 30_000);
100
+
101
+ for await (const event of streamer.stream({
102
+ startLedger: 4750000,
103
+ signal: controller.signal
104
+ })) {
105
+ console.log(event);
106
+ }
107
+ ```
108
+
109
+ ## Decode ScVal
110
+
111
+ The decoder can also be used independently:
112
+
113
+ ```js
114
+ import { unwrapScVal } from 'soroban-events';
115
+
116
+ const value = unwrapScVal(scVal);
117
+ console.log(value);
118
+ ```
119
+
120
+ Common Soroban values supported include integers, symbols, strings, booleans, bytes, addresses, vectors, and maps.
121
+
122
+ Large integer values are normalized without relying on JavaScript `Number` precision.
123
+
124
+ ## Event format
125
+
126
+ Events are normalized into a consistent object containing fields such as:
127
+
128
+ ```js
129
+ {
130
+ id,
131
+ type,
132
+ ledger,
133
+ ledgerClosedAt,
134
+ contractId,
135
+ transactionIndex,
136
+ operationIndex,
137
+ txHash,
138
+ topics,
139
+ value,
140
+ inSuccessfulContractCall
141
+ }
142
+ ```
143
+
144
+ ## Configuration
145
+
146
+ ```js
147
+ const streamer = new SorobanEventStreamer(RPC_URL, {
148
+ pollInterval: 3000,
149
+ windowSize: 9500,
150
+ pageSize: 1000,
151
+ maxRetries: 3,
152
+ retryBaseMs: 500
153
+ });
154
+ ```
155
+
156
+ | Option | Default | Description |
157
+ |---|---:|---|
158
+ | `pollInterval` | `3000` | Polling interval for `stream()` |
159
+ | `windowSize` | `9500` | Maximum ledger window |
160
+ | `pageSize` | `1000` | RPC page size |
161
+ | `maxRetries` | `3` | Maximum retry attempts |
162
+ | `retryBaseMs` | `500` | Base retry delay |
163
+
164
+ ## How it works
165
+
166
+ ```text
167
+ Soroban RPC
168
+ |
169
+ v
170
+ ledger windows
171
+ |
172
+ v
173
+ cursor pagination
174
+ |
175
+ v
176
+ retry handling
177
+ |
178
+ v
179
+ deduplication
180
+ |
181
+ v
182
+ ScVal decoding
183
+ |
184
+ v
185
+ your application
186
+ ```
187
+
188
+ No database is required.
189
+
190
+ No full indexer stack is required.
191
+
192
+ Use the normalized events in whatever application or storage layer you need.
193
+
194
+ ## Testing
195
+
196
+ Run the unit tests:
197
+
198
+ ```bash
199
+ npm test
200
+ ```
201
+
202
+ Live Stellar Testnet verification:
203
+
204
+ ```bash
205
+ npm run test:live
206
+ ```
207
+
208
+ The project currently includes 22 automated tests covering windowing, pagination, deduplication, limits, retries, abort handling, filtering, ScVal decoding, large integers, and event normalization.
209
+
210
+ The live test has also been verified against Stellar Testnet RPC.
211
+
212
+ ## Status
213
+
214
+ **v0.1.0**
215
+
216
+ The current release focuses on the core RPC ingestion and ScVal decoding layer.
217
+
218
+ This is intentionally a lightweight library, not a database-backed blockchain indexer.
219
+
220
+ Potential future work includes durable cursor persistence, stronger recovery strategies, webhook delivery, richer filtering helpers, and production indexing integrations.
221
+
222
+ ## Contributing
223
+
224
+ Bug reports, edge cases, documentation improvements, test cases, and pull requests are welcome.
225
+
226
+ When reporting an RPC issue, include the endpoint/network, ledger range, relevant RPC error or response, expected behavior, and actual behavior.
227
+
228
+ ## License
229
+
230
+ MIT
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "soroban-events",
3
+ "version": "0.1.0",
4
+ "description": "Resilient, windowed event streamer and XDR decoder for Soroban RPC",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test tests/*.test.js",
15
+ "test:live": "node tests/test-engine.js",
16
+ "pack:check": "npm pack --dry-run"
17
+ },
18
+ "keywords": [
19
+ "soroban",
20
+ "stellar",
21
+ "events",
22
+ "event-streaming",
23
+ "event-indexer",
24
+ "xdr",
25
+ "rpc"
26
+ ],
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "dependencies": {
32
+ "@stellar/stellar-sdk": "^17.1.0"
33
+ }
34
+ }
package/src/decoder.js ADDED
@@ -0,0 +1,142 @@
1
+ import { scValToNative } from '@stellar/stellar-sdk';
2
+
3
+ const DEFAULT_MAX_DEPTH = 50;
4
+
5
+ export function unwrapScVal(scVal, options = {}) {
6
+ if (scVal == null) return null;
7
+
8
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
9
+
10
+ try {
11
+ return normalizeNative(scValToNative(scVal), 0, maxDepth);
12
+ } catch {
13
+ return parseScValDirect(scVal);
14
+ }
15
+ }
16
+
17
+ function normalizeNative(value, depth, maxDepth) {
18
+ if (depth > maxDepth) {
19
+ throw new Error(`ScVal nesting exceeds maximum depth of ${maxDepth}`);
20
+ }
21
+
22
+ if (value === null || value === undefined) return null;
23
+
24
+ if (typeof value === 'bigint') return value.toString();
25
+
26
+ if (value instanceof Uint8Array || Buffer.isBuffer(value)) {
27
+ return Buffer.from(value).toString('hex');
28
+ }
29
+
30
+ if (Array.isArray(value)) {
31
+ return value.map(v => normalizeNative(v, depth + 1, maxDepth));
32
+ }
33
+
34
+ if (value instanceof Map) {
35
+ return new Map(
36
+ [...value.entries()].map(([k, v]) => [
37
+ normalizeNative(k, depth + 1, maxDepth),
38
+ normalizeNative(v, depth + 1, maxDepth)
39
+ ])
40
+ );
41
+ }
42
+
43
+ if (typeof value === 'object') {
44
+ const result = Object.create(null);
45
+
46
+ for (const [key, val] of Object.entries(value)) {
47
+ result[key] = normalizeNative(val, depth + 1, maxDepth);
48
+ }
49
+
50
+ return result;
51
+ }
52
+
53
+ return value;
54
+ }
55
+
56
+ function parseScValDirect(scVal) {
57
+ try {
58
+ if (!scVal?.switch) return null;
59
+
60
+ const name = scVal.switch().name;
61
+
62
+ switch (name) {
63
+ case 'scvVoid': return null;
64
+ case 'scvBool': return scVal.b();
65
+ case 'scvU32': return scVal.u32();
66
+ case 'scvI32': return scVal.i32();
67
+ case 'scvU64': return scVal.u64().toString();
68
+ case 'scvI64': return scVal.i64().toString();
69
+ case 'scvU128': return scVal.u128().toString();
70
+ case 'scvI128': return scVal.i128().toString();
71
+ case 'scvU256': return scVal.u256().toString();
72
+ case 'scvI256': return scVal.i256().toString();
73
+ case 'scvSymbol': return scVal.sym().toString();
74
+ case 'scvString': return scVal.str().toString();
75
+ case 'scvBytes':
76
+ return Buffer.from(
77
+ scVal.bytes?.() ?? scVal.bin?.() ?? []
78
+ ).toString('hex');
79
+ case 'scvAddress':
80
+ return scVal.address().toString();
81
+ default:
82
+ return `[Unresolved ScVal: ${name}]`;
83
+ }
84
+ } catch (error) {
85
+ return `[ScVal decode error: ${error.message}]`;
86
+ }
87
+ }
88
+
89
+ function normalizeContractId(value) {
90
+ if (value == null || typeof value === 'string') return value;
91
+
92
+ if (typeof value.toString === 'function') {
93
+ const text = value.toString();
94
+
95
+ if (
96
+ text &&
97
+ text !== '[object Object]' &&
98
+ !/^\[object Object\]$/.test(text)
99
+ ) {
100
+ return text;
101
+ }
102
+ }
103
+
104
+ return value;
105
+ }
106
+
107
+ export function decodeEvent(rawEvent, options = {}) {
108
+ const topics = Array.isArray(rawEvent.topic)
109
+ ? rawEvent.topic.map(topic => {
110
+ try {
111
+ return unwrapScVal(topic, options);
112
+ } catch {
113
+ return topic;
114
+ }
115
+ })
116
+ : [];
117
+
118
+ let value = null;
119
+
120
+ if (rawEvent.value != null) {
121
+ try {
122
+ value = unwrapScVal(rawEvent.value, options);
123
+ } catch {
124
+ value = rawEvent.value;
125
+ }
126
+ }
127
+
128
+ return {
129
+ id: rawEvent.id,
130
+ type: rawEvent.type,
131
+ ledger: rawEvent.ledger,
132
+ ledgerClosedAt: rawEvent.ledgerClosedAt,
133
+ contractId: normalizeContractId(rawEvent.contractId),
134
+ transactionIndex: rawEvent.transactionIndex,
135
+ operationIndex: rawEvent.operationIndex,
136
+ txHash: rawEvent.txHash,
137
+ topics,
138
+ value,
139
+ inSuccessfulContractCall:
140
+ rawEvent.inSuccessfulContractCall ?? true
141
+ };
142
+ }
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ SorobanEventStreamer,
3
+ MAX_SAFE_LEDGER_SPAN,
4
+ DEFAULT_PAGE_SIZE
5
+ } from './streamer.js';
6
+
7
+ export {
8
+ unwrapScVal,
9
+ decodeEvent
10
+ } from './decoder.js';
@@ -0,0 +1,312 @@
1
+ import { rpc } from '@stellar/stellar-sdk';
2
+ import { decodeEvent } from './decoder.js';
3
+
4
+ export const MAX_SAFE_LEDGER_SPAN = 9500;
5
+ export const DEFAULT_PAGE_SIZE = 1000;
6
+
7
+ function sleep(ms) {
8
+ return new Promise(resolve => setTimeout(resolve, ms));
9
+ }
10
+
11
+ function classifyError(error) {
12
+ const message = String(error?.message ?? error).toLowerCase();
13
+
14
+ const status =
15
+ error?.response?.status ??
16
+ error?.status ??
17
+ error?.statusCode;
18
+
19
+ if (status === 429 || message.includes('429') || message.includes('rate limit')) {
20
+ return 'rate-limit';
21
+ }
22
+
23
+ if (
24
+ status >= 500 ||
25
+ message.includes('timeout') ||
26
+ message.includes('timed out') ||
27
+ message.includes('econnreset') ||
28
+ message.includes('socket')
29
+ ) {
30
+ return 'transient';
31
+ }
32
+
33
+ return 'fatal';
34
+ }
35
+
36
+ export class SorobanEventStreamer {
37
+ constructor(rpcUrl, options = {}) {
38
+ if (!rpcUrl) {
39
+ throw new TypeError('rpcUrl is required');
40
+ }
41
+
42
+ this.server = new rpc.Server(
43
+ rpcUrl,
44
+ options.serverOptions || {}
45
+ );
46
+
47
+ this.pollInterval = options.pollInterval ?? 3000;
48
+ this.windowSize = Math.min(
49
+ options.windowSize ?? MAX_SAFE_LEDGER_SPAN,
50
+ MAX_SAFE_LEDGER_SPAN
51
+ );
52
+
53
+ this.pageSize = Math.min(
54
+ options.pageSize ?? DEFAULT_PAGE_SIZE,
55
+ 10000
56
+ );
57
+
58
+ this.maxRetries = options.maxRetries ?? 3;
59
+ this.retryBaseMs = options.retryBaseMs ?? 500;
60
+ }
61
+
62
+ async getLatestLedger() {
63
+ const response = await this.server.getLatestLedger();
64
+ return response.sequence;
65
+ }
66
+
67
+ async getEventsWindowed({
68
+ startLedger,
69
+ endLedger,
70
+ filters = [],
71
+ limit = 100,
72
+ signal
73
+ }) {
74
+ if (!Number.isInteger(startLedger) || startLedger < 1) {
75
+ throw new TypeError('startLedger must be a positive integer');
76
+ }
77
+
78
+ if (endLedger != null && (!Number.isInteger(endLedger) || endLedger < startLedger)) {
79
+ throw new TypeError('endLedger must be >= startLedger');
80
+ }
81
+
82
+ const targetEnd = endLedger ?? await this.getLatestLedger();
83
+
84
+ if (signal?.aborted) {
85
+ throw new DOMException('Operation aborted', 'AbortError');
86
+ }
87
+
88
+ const results = [];
89
+ const seen = new Set();
90
+
91
+ let currentStart = startLedger;
92
+
93
+ while (currentStart <= targetEnd && results.length < limit) {
94
+ if (signal?.aborted) {
95
+ throw new DOMException('Operation aborted', 'AbortError');
96
+ }
97
+
98
+ const currentEndExclusive = Math.min(
99
+ currentStart + this.windowSize,
100
+ targetEnd + 1
101
+ );
102
+
103
+ const remaining = limit - results.length;
104
+
105
+ const events = await this.fetchWindow(
106
+ currentStart,
107
+ currentEndExclusive,
108
+ filters,
109
+ remaining,
110
+ signal
111
+ );
112
+
113
+ for (const raw of events) {
114
+ if (!raw?.id || seen.has(raw.id)) continue;
115
+
116
+ seen.add(raw.id);
117
+ results.push(decodeEvent(raw));
118
+
119
+ if (results.length >= limit) break;
120
+ }
121
+
122
+ currentStart = currentEndExclusive;
123
+ }
124
+
125
+ return results;
126
+ }
127
+
128
+ async fetchWindow(
129
+ startLedger,
130
+ endLedgerExclusive,
131
+ filters,
132
+ limit,
133
+ signal
134
+ ) {
135
+ const rawEvents = [];
136
+ let cursor;
137
+
138
+ while (rawEvents.length < limit) {
139
+ if (signal?.aborted) {
140
+ throw new DOMException('Operation aborted', 'AbortError');
141
+ }
142
+
143
+ const pagination = {
144
+ limit: Math.min(this.pageSize, limit - rawEvents.length)
145
+ };
146
+
147
+ if (cursor) {
148
+ pagination.cursor = cursor;
149
+ }
150
+
151
+ const params = {
152
+ filters,
153
+ pagination
154
+ };
155
+
156
+ if (!cursor) {
157
+ params.startLedger = startLedger;
158
+ params.endLedger = endLedgerExclusive;
159
+ }
160
+
161
+ const response = await this.requestWithRetry(params, signal);
162
+
163
+ const events = response?.events ?? [];
164
+
165
+ rawEvents.push(...events);
166
+
167
+ const nextCursor = response?.cursor;
168
+
169
+ if (!nextCursor || events.length === 0) {
170
+ break;
171
+ }
172
+
173
+ if (nextCursor === cursor) {
174
+ throw new Error(
175
+ `RPC pagination cursor did not advance for ledger range ${startLedger}-${endLedgerExclusive}`
176
+ );
177
+ }
178
+
179
+ cursor = nextCursor;
180
+ }
181
+
182
+ return rawEvents.slice(0, limit);
183
+ }
184
+
185
+ async requestWithRetry(params, signal) {
186
+ let attempt = 0;
187
+
188
+ while (true) {
189
+ if (signal?.aborted) {
190
+ throw new DOMException('Operation aborted', 'AbortError');
191
+ }
192
+
193
+ try {
194
+ return await this.server.getEvents(params);
195
+ } catch (error) {
196
+ const kind = classifyError(error);
197
+
198
+ if (
199
+ (kind !== 'rate-limit' && kind !== 'transient') ||
200
+ attempt >= this.maxRetries
201
+ ) {
202
+ throw error;
203
+ }
204
+
205
+ const delay =
206
+ this.retryBaseMs *
207
+ 2 ** attempt *
208
+ (kind === 'rate-limit' ? 2 : 1);
209
+
210
+ await sleep(delay);
211
+ attempt++;
212
+ }
213
+ }
214
+ }
215
+
216
+ async tail({
217
+ contractId,
218
+ limit = 10,
219
+ maxLookbackLedgers = 50000,
220
+ filters
221
+ } = {}) {
222
+ if (limit <= 0) return [];
223
+
224
+ const latest = await this.getLatestLedger();
225
+
226
+ const effectiveFilters =
227
+ filters ??
228
+ (contractId
229
+ ? [{ type: 'contract', contractIds: [contractId] }]
230
+ : [{ type: 'contract' }]);
231
+
232
+ const collected = [];
233
+ const seen = new Set();
234
+
235
+ let endExclusive = latest + 1;
236
+ const minimumLedger = Math.max(
237
+ 1,
238
+ latest - maxLookbackLedgers
239
+ );
240
+
241
+ while (
242
+ endExclusive > minimumLedger &&
243
+ collected.length < limit
244
+ ) {
245
+ const start = Math.max(
246
+ minimumLedger,
247
+ endExclusive - this.windowSize
248
+ );
249
+
250
+ const events = await this.fetchWindow(
251
+ start,
252
+ endExclusive,
253
+ effectiveFilters,
254
+ this.pageSize
255
+ );
256
+
257
+ for (let i = events.length - 1; i >= 0; i--) {
258
+ const event = events[i];
259
+
260
+ if (!event?.id || seen.has(event.id)) continue;
261
+
262
+ seen.add(event.id);
263
+ collected.unshift(decodeEvent(event));
264
+
265
+ if (collected.length >= limit) break;
266
+ }
267
+
268
+ endExclusive = start;
269
+ }
270
+
271
+ return collected.slice(-limit);
272
+ }
273
+
274
+ async *stream({
275
+ startLedger,
276
+ filters = [],
277
+ pollInterval = this.pollInterval,
278
+ signal
279
+ } = {}) {
280
+ let cursorLedger =
281
+ startLedger ?? await this.getLatestLedger();
282
+
283
+ while (!signal?.aborted) {
284
+ const latest = await this.getLatestLedger();
285
+
286
+ if (cursorLedger <= latest) {
287
+ const events = await this.getEventsWindowed({
288
+ startLedger: cursorLedger,
289
+ endLedger: latest,
290
+ filters,
291
+ limit: 10000,
292
+ signal
293
+ });
294
+
295
+ for (const event of events) {
296
+ if (signal?.aborted) return;
297
+
298
+ yield event;
299
+
300
+ cursorLedger = Math.max(
301
+ cursorLedger,
302
+ event.ledger + 1
303
+ );
304
+ }
305
+ }
306
+
307
+ if (cursorLedger > latest) {
308
+ await sleep(pollInterval);
309
+ }
310
+ }
311
+ }
312
+ }