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 +230 -0
- package/package.json +34 -0
- package/src/decoder.js +142 -0
- package/src/index.js +10 -0
- package/src/streamer.js +312 -0
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
package/src/streamer.js
ADDED
|
@@ -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
|
+
}
|