@apibara/protocol 2.1.0-beta.40 → 2.1.0-beta.42
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/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -1
- package/dist/rpc/index.cjs +12 -0
- package/dist/rpc/index.cjs.map +1 -0
- package/dist/rpc/index.d.cts +6 -0
- package/dist/rpc/index.d.mts +6 -0
- package/dist/rpc/index.d.ts +6 -0
- package/dist/rpc/index.mjs +3 -0
- package/dist/rpc/index.mjs.map +1 -0
- package/dist/shared/protocol.6a091343.d.mts +102 -0
- package/dist/shared/protocol.91a69be4.cjs +518 -0
- package/dist/shared/protocol.91a69be4.cjs.map +1 -0
- package/dist/shared/protocol.aec6eac7.d.ts +102 -0
- package/dist/shared/protocol.d171ebd2.d.cts +102 -0
- package/dist/shared/protocol.ea189418.mjs +512 -0
- package/dist/shared/protocol.ea189418.mjs.map +1 -0
- package/package.json +7 -1
- package/src/index.ts +2 -0
- package/src/rpc/chain-tracker.ts +310 -0
- package/src/rpc/client.ts +51 -0
- package/src/rpc/config.ts +86 -0
- package/src/rpc/data-stream.ts +348 -0
- package/src/rpc/helpers.ts +9 -0
- package/src/rpc/index.ts +13 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { StreamDataOptions } from "../client";
|
|
2
|
+
import type { Cursor } from "../common";
|
|
3
|
+
import type { StreamDataRequest, StreamDataResponse } from "../stream";
|
|
4
|
+
import { type ChainTracker, createChainTracker } from "./chain-tracker";
|
|
5
|
+
import type { RpcStreamConfig } from "./config";
|
|
6
|
+
import { blockInfoToCursor } from "./helpers";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
type State<TFilter, TBlock> = {
|
|
11
|
+
// The network-specific config.
|
|
12
|
+
config: RpcStreamConfig<TFilter, TBlock>;
|
|
13
|
+
// The current cursor, that is the last block that was filtered.
|
|
14
|
+
cursor: Cursor;
|
|
15
|
+
// When the finalized block was last refreshed.
|
|
16
|
+
lastFinalizedRefresh: number;
|
|
17
|
+
// When the last heartbeat was sent.
|
|
18
|
+
lastHeartbeat: number;
|
|
19
|
+
// Track the chain's state.
|
|
20
|
+
chainTracker: ChainTracker;
|
|
21
|
+
// Heartbeat interval in milliseconds.
|
|
22
|
+
heartbeatIntervalMs: number;
|
|
23
|
+
// The request filter.
|
|
24
|
+
filter: TFilter;
|
|
25
|
+
// The request options.
|
|
26
|
+
options?: StreamDataOptions;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class RpcDataStream<TFilter, TBlock> {
|
|
30
|
+
private heartbeatIntervalMs: number;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private config: RpcStreamConfig<TFilter, TBlock>,
|
|
34
|
+
private request: StreamDataRequest<TFilter>,
|
|
35
|
+
private options?: StreamDataOptions,
|
|
36
|
+
) {
|
|
37
|
+
this.heartbeatIntervalMs = request.heartbeatInterval
|
|
38
|
+
? Number(request.heartbeatInterval.seconds) * 1000
|
|
39
|
+
: DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async *[Symbol.asyncIterator](): AsyncIterator<StreamDataResponse<TBlock>> {
|
|
43
|
+
const startingState = await this.initialize();
|
|
44
|
+
yield* dataStreamLoop(startingState);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async initialize(): Promise<State<TFilter, TBlock>> {
|
|
48
|
+
if (this.request.filter.length === 0) {
|
|
49
|
+
throw new Error("Request.filter: empty.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (this.request.filter.length > 1) {
|
|
53
|
+
throw new Error("Request.filter: only one filter is supported.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [head, finalized] = await Promise.all([
|
|
57
|
+
this.config.fetchCursor({ blockTag: "latest" }),
|
|
58
|
+
this.config.fetchCursor({ blockTag: "finalized" }),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
if (finalized === null) {
|
|
62
|
+
throw new Error("EvmRpcStream requires a finalized block");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (head === null) {
|
|
66
|
+
throw new Error("EvmRpcStream requires a chain with blocks.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const chainTracker = createChainTracker({
|
|
70
|
+
head,
|
|
71
|
+
finalized,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let cursor: Cursor;
|
|
75
|
+
if (this.request.startingCursor) {
|
|
76
|
+
cursor = this.request.startingCursor;
|
|
77
|
+
|
|
78
|
+
const { canonical, reason, fullCursor } =
|
|
79
|
+
await chainTracker.initializeStartingCursor({
|
|
80
|
+
cursor,
|
|
81
|
+
fetchCursor: (blockNumber) =>
|
|
82
|
+
this.config.fetchCursor({ blockNumber }),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!canonical) {
|
|
86
|
+
throw new Error(`Starting cursor is not canonical: ${reason}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
cursor = fullCursor;
|
|
90
|
+
} else {
|
|
91
|
+
cursor = { orderKey: -1n };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
cursor,
|
|
96
|
+
lastHeartbeat: Date.now(),
|
|
97
|
+
lastFinalizedRefresh: Date.now(),
|
|
98
|
+
chainTracker,
|
|
99
|
+
config: this.config,
|
|
100
|
+
heartbeatIntervalMs: this.heartbeatIntervalMs,
|
|
101
|
+
filter: this.request.filter[0],
|
|
102
|
+
options: this.options,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function* dataStreamLoop<TFilter, TBlock>(
|
|
108
|
+
state: State<TFilter, TBlock>,
|
|
109
|
+
): AsyncGenerator<StreamDataResponse<TBlock>> {
|
|
110
|
+
while (shouldContinue(state)) {
|
|
111
|
+
const { cursor, chainTracker } = state;
|
|
112
|
+
|
|
113
|
+
// Always check for heartbeats first to ensure we don't miss any.
|
|
114
|
+
if (shouldSendHeartbeat(state)) {
|
|
115
|
+
state.lastHeartbeat = Date.now();
|
|
116
|
+
yield { _tag: "heartbeat" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (shouldRefreshFinalized(state)) {
|
|
120
|
+
const finalizedInfo = await state.config.fetchCursor({
|
|
121
|
+
blockTag: "finalized",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (finalizedInfo === null) {
|
|
125
|
+
throw new Error("Failed to fetch finalized cursor");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const finalized = blockInfoToCursor(finalizedInfo);
|
|
129
|
+
const finalizedChanged =
|
|
130
|
+
state.chainTracker.updateFinalized(finalizedInfo);
|
|
131
|
+
|
|
132
|
+
// Only send finalized if it's needed.
|
|
133
|
+
if (finalizedChanged && state.cursor.orderKey > finalized.orderKey) {
|
|
134
|
+
yield { _tag: "finalize", finalize: { cursor: finalized } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
state.lastFinalizedRefresh = Date.now();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const finalized = chainTracker.finalized();
|
|
141
|
+
|
|
142
|
+
// console.log(
|
|
143
|
+
// `Loop: c=${cursor.orderKey} f=${finalized.orderKey} h=${chainTracker.head().orderKey}`,
|
|
144
|
+
// );
|
|
145
|
+
|
|
146
|
+
if (cursor.orderKey < finalized.orderKey) {
|
|
147
|
+
yield* backfillFinalizedBlocks(state);
|
|
148
|
+
} else {
|
|
149
|
+
// If we're at the head, wait for a change.
|
|
150
|
+
//
|
|
151
|
+
// We don't want to produce a block immediately, but re-run the loop so
|
|
152
|
+
// that it's like any other iteration.
|
|
153
|
+
if (isAtHead(state)) {
|
|
154
|
+
yield* waitForHeadChange(state);
|
|
155
|
+
} else {
|
|
156
|
+
yield* produceNextBlock(state);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function* backfillFinalizedBlocks<TFilter, TBlock>(
|
|
163
|
+
state: State<TFilter, TBlock>,
|
|
164
|
+
): AsyncGenerator<StreamDataResponse<TBlock>> {
|
|
165
|
+
const { cursor, chainTracker, config, filter } = state;
|
|
166
|
+
const finalized = chainTracker.finalized();
|
|
167
|
+
|
|
168
|
+
const filterData = await config.fetchBlockRange({
|
|
169
|
+
startBlock: cursor.orderKey + 1n,
|
|
170
|
+
finalizedBlock: finalized.orderKey,
|
|
171
|
+
filter,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (filterData.endBlock > finalized.orderKey) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"Network-specific stream returned invalid data, crossing the finalized block.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const data of filterData.data) {
|
|
181
|
+
state.lastHeartbeat = Date.now();
|
|
182
|
+
yield {
|
|
183
|
+
_tag: "data",
|
|
184
|
+
data: {
|
|
185
|
+
cursor: data.cursor,
|
|
186
|
+
endCursor: data.endCursor,
|
|
187
|
+
data: [data.block],
|
|
188
|
+
finality: "finalized",
|
|
189
|
+
production: "backfill",
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (filterData.endBlock === finalized.orderKey) {
|
|
195
|
+
// Prepare for transition to non-finalized data.
|
|
196
|
+
state.cursor = finalized;
|
|
197
|
+
} else {
|
|
198
|
+
state.cursor = { orderKey: filterData.endBlock };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// This is a generator to possibly produce data for the next block.
|
|
203
|
+
//
|
|
204
|
+
// It's a generator because it's not guaranteed to produce data for the next block.
|
|
205
|
+
async function* produceNextBlock<TFilter, TBlock>(
|
|
206
|
+
state: State<TFilter, TBlock>,
|
|
207
|
+
): AsyncGenerator<StreamDataResponse<TBlock>> {
|
|
208
|
+
const currentBlockHash = state.cursor.uniqueKey;
|
|
209
|
+
|
|
210
|
+
if (currentBlockHash === undefined) {
|
|
211
|
+
throw new Error("Live production phase without cursor's hash.");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result = await state.config.fetchBlockByNumber({
|
|
215
|
+
blockNumber: state.cursor.orderKey + 1n,
|
|
216
|
+
expectedParentBlockHash: currentBlockHash,
|
|
217
|
+
filter: state.filter,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// TODO: use the output of result to update the chain tracker.
|
|
221
|
+
|
|
222
|
+
if (result.status === "reorg") {
|
|
223
|
+
throw new Error("Reorg not implemented");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { data, blockInfo } = result;
|
|
227
|
+
|
|
228
|
+
state.cursor = {
|
|
229
|
+
orderKey: blockInfo.blockNumber,
|
|
230
|
+
uniqueKey: blockInfo.blockHash,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const { status: headUpdateStatus } =
|
|
234
|
+
state.chainTracker.addToCanonicalChain(blockInfo);
|
|
235
|
+
|
|
236
|
+
if (headUpdateStatus !== "success") {
|
|
237
|
+
throw new Error("Failed to update head. Would cause reorg.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (data.block !== null) {
|
|
241
|
+
state.lastHeartbeat = Date.now();
|
|
242
|
+
const production = isAtHead(state) ? "live" : "backfill";
|
|
243
|
+
|
|
244
|
+
yield {
|
|
245
|
+
_tag: "data",
|
|
246
|
+
data: {
|
|
247
|
+
cursor: data.cursor,
|
|
248
|
+
endCursor: data.endCursor,
|
|
249
|
+
data: [data.block],
|
|
250
|
+
finality: "accepted",
|
|
251
|
+
production,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function* waitForHeadChange<TBlock>(
|
|
258
|
+
state: State<unknown, TBlock>,
|
|
259
|
+
): AsyncGenerator<StreamDataResponse<TBlock>> {
|
|
260
|
+
const { chainTracker, config } = state;
|
|
261
|
+
|
|
262
|
+
const heartbeatDeadline = state.lastHeartbeat + state.heartbeatIntervalMs;
|
|
263
|
+
const finalizedRefreshDeadline =
|
|
264
|
+
state.lastFinalizedRefresh + config.finalizedRefreshIntervalMs();
|
|
265
|
+
|
|
266
|
+
while (true) {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
// Allow the outer loop to send the heartbeat message or refresh finalized blocks.
|
|
269
|
+
if (now >= heartbeatDeadline || now >= finalizedRefreshDeadline) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const maybeNewHead = await config.fetchCursor({ blockTag: "latest" });
|
|
274
|
+
|
|
275
|
+
if (maybeNewHead === null) {
|
|
276
|
+
throw new Error("Failed to fetch the latest block");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const result = await chainTracker.updateHead({
|
|
280
|
+
newHead: maybeNewHead,
|
|
281
|
+
fetchCursorByHash: (blockHash) => config.fetchCursor({ blockHash }),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
switch (result.status) {
|
|
285
|
+
case "unchanged": {
|
|
286
|
+
const heartbeatTimeout = heartbeatDeadline - now;
|
|
287
|
+
const finalizedTimeout = finalizedRefreshDeadline - now;
|
|
288
|
+
|
|
289
|
+
// Wait until whatever happens next.
|
|
290
|
+
await sleep(
|
|
291
|
+
Math.min(
|
|
292
|
+
heartbeatTimeout,
|
|
293
|
+
finalizedTimeout,
|
|
294
|
+
config.headRefreshIntervalMs(),
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case "reorg": {
|
|
301
|
+
const { cursor } = result;
|
|
302
|
+
// Only handle reorgs if they involve blocks already processed.
|
|
303
|
+
if (cursor.orderKey < state.cursor.orderKey) {
|
|
304
|
+
state.cursor = cursor;
|
|
305
|
+
|
|
306
|
+
yield {
|
|
307
|
+
_tag: "invalidate",
|
|
308
|
+
invalidate: { cursor },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case "success": {
|
|
315
|
+
// Chain grew without any issues. Go back to the top-level loop to produce data.
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function shouldSendHeartbeat(state: State<unknown, unknown>): boolean {
|
|
323
|
+
const { heartbeatIntervalMs, lastHeartbeat } = state;
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
return now - lastHeartbeat >= heartbeatIntervalMs;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function shouldContinue(state: State<unknown, unknown>): boolean {
|
|
329
|
+
const { endingCursor } = state.options || {};
|
|
330
|
+
if (endingCursor === undefined) return true;
|
|
331
|
+
|
|
332
|
+
return state.cursor.orderKey < endingCursor.orderKey;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function shouldRefreshFinalized(state: State<unknown, unknown>): boolean {
|
|
336
|
+
const { lastFinalizedRefresh, config } = state;
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
return now - lastFinalizedRefresh >= config.finalizedRefreshIntervalMs();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function isAtHead(state: State<unknown, unknown>): boolean {
|
|
342
|
+
const head = state.chainTracker.head();
|
|
343
|
+
return state.cursor.orderKey === head.orderKey;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function sleep(duration: number): Promise<void> {
|
|
347
|
+
return new Promise((resolve) => setTimeout(resolve, duration));
|
|
348
|
+
}
|
package/src/rpc/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type BlockInfo,
|
|
3
|
+
type FetchBlockRangeArgs,
|
|
4
|
+
type FetchBlockResult,
|
|
5
|
+
type FetchBlockRangeResult,
|
|
6
|
+
type FetchBlockByNumberArgs,
|
|
7
|
+
type FetchBlockByNumberResult,
|
|
8
|
+
type ValidateFilterResult,
|
|
9
|
+
type FetchCursorArgs,
|
|
10
|
+
RpcStreamConfig,
|
|
11
|
+
} from "./config";
|
|
12
|
+
export { RpcClient, createRpcClient } from "./client";
|
|
13
|
+
export { RpcDataStream } from "./data-stream";
|