@effectstream/sync 0.100.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.
Files changed (45) hide show
  1. package/package.json +29 -0
  2. package/src/mod.ts +3 -0
  3. package/src/sync-protocols/avail/AvailClient.ts +120 -0
  4. package/src/sync-protocols/avail/fetcher.ts +174 -0
  5. package/src/sync-protocols/avail/state.ts +134 -0
  6. package/src/sync-protocols/avail/types.ts +92 -0
  7. package/src/sync-protocols/base/fetcher.ts +43 -0
  8. package/src/sync-protocols/base/page.ts +89 -0
  9. package/src/sync-protocols/base/primitive.ts +24 -0
  10. package/src/sync-protocols/base/state.ts +143 -0
  11. package/src/sync-protocols/bitcoin/fetcher.ts +549 -0
  12. package/src/sync-protocols/bitcoin/state.ts +144 -0
  13. package/src/sync-protocols/bitcoin/types.ts +92 -0
  14. package/src/sync-protocols/celestia/CelestiaClient.ts +139 -0
  15. package/src/sync-protocols/celestia/fetcher.ts +165 -0
  16. package/src/sync-protocols/celestia/state.ts +131 -0
  17. package/src/sync-protocols/celestia/types.ts +46 -0
  18. package/src/sync-protocols/common/page-helpers.ts +65 -0
  19. package/src/sync-protocols/common/root.ts +35 -0
  20. package/src/sync-protocols/common/utils.ts +29 -0
  21. package/src/sync-protocols/evm/fetcher.ts +290 -0
  22. package/src/sync-protocols/evm/state.ts +122 -0
  23. package/src/sync-protocols/evm/types.ts +53 -0
  24. package/src/sync-protocols/midnight/MidnightClient.ts +210 -0
  25. package/src/sync-protocols/midnight/fetcher.ts +252 -0
  26. package/src/sync-protocols/midnight/state.ts +147 -0
  27. package/src/sync-protocols/midnight/types.ts +128 -0
  28. package/src/sync-protocols/midnight/zswap-decoder.ts +123 -0
  29. package/src/sync-protocols/mod.ts +29 -0
  30. package/src/sync-protocols/near/NearClient.ts +178 -0
  31. package/src/sync-protocols/near/fetcher.ts +274 -0
  32. package/src/sync-protocols/near/state.ts +130 -0
  33. package/src/sync-protocols/near/types.ts +46 -0
  34. package/src/sync-protocols/ntp/fetcher.ts +199 -0
  35. package/src/sync-protocols/ntp/state.ts +118 -0
  36. package/src/sync-protocols/ntp/types.ts +40 -0
  37. package/src/sync-protocols/orchestration/merge.ts +215 -0
  38. package/src/sync-protocols/orchestration/sync.ts +74 -0
  39. package/src/sync-protocols/types.ts +32 -0
  40. package/src/sync-protocols/utxorpc/BufferedRpc.ts +169 -0
  41. package/src/sync-protocols/utxorpc/fetcher.ts +116 -0
  42. package/src/sync-protocols/utxorpc/state.ts +186 -0
  43. package/src/sync-protocols/utxorpc/types.ts +53 -0
  44. package/src/sync-protocols/utxorpc/utils.ts +80 -0
  45. package/src/syncProtocolFactory.ts +149 -0
@@ -0,0 +1,24 @@
1
+ import type { Operation } from "effection";
2
+ import type { PageRequest } from "./page.ts";
3
+ import type { ConfigSyncProtocolType, PrimitiveEntry } from "@effectstream/config";
4
+
5
+ export interface PrimitiveFetcher<
6
+ Input,
7
+ Page,
8
+ RawData,
9
+ PrimitiveType,
10
+ NetworkType extends ConfigSyncProtocolType,
11
+ > {
12
+ readPrimitives(
13
+ data: Input,
14
+ pageRequest: PageRequest<Page, RawData>,
15
+ primitiveEntries: Extract<
16
+ PrimitiveEntry,
17
+ { syncProtocol: NetworkType }
18
+ >[],
19
+ ): Operation<PrimitiveType[]>;
20
+
21
+ groupByPage<T extends string>(
22
+ primitives: PrimitiveType[],
23
+ ): Record<T, PrimitiveType[]>;
24
+ }
@@ -0,0 +1,143 @@
1
+ import { type Operation, until } from "effection";
2
+ import Deque from "denque";
3
+ import {
4
+ type BlockNumber,
5
+ conditionVariable,
6
+ type CondVar,
7
+ } from "@effectstream/utils";
8
+ import stableStringify from "json-stable-stringify";
9
+ import { bound } from "@effectstream/utils";
10
+ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
11
+ import type { BaseDataFetcher, DataFetched } from "./fetcher.ts";
12
+ import type { PageRelation } from "./page.ts";
13
+ import type { PoolClient } from "pg";
14
+ import { acquireDBMutex, releaseDBMutex, upsertPage } from "@effectstream/db";
15
+
16
+ export type LastPage<Page, RootPage> = {
17
+ own: Page;
18
+ ownBlockNumber: BlockNumber;
19
+ root: RootPage;
20
+ };
21
+ /**
22
+ * Since it's always possible for code to fail,
23
+ * we avoid applying any modifications to data until AFTER the block is applied
24
+ */
25
+ export type CacheCleanup = () => void;
26
+ export type OutputAndCleanup<Output> = {
27
+ output: Output;
28
+ cleanup: CacheCleanup;
29
+ };
30
+
31
+ export interface RootConversion<Output, RootOutput, RootPage> {
32
+ toRootOutput: (data: Output) => RootOutput;
33
+ toRootPage: (data: Output) => RootPage;
34
+ }
35
+
36
+ export type PageTypeOf<
37
+ SyncProtocol extends SyncState<any, any, any, any, any, any>,
38
+ > = SyncProtocol extends SyncState<any, any, infer PageType, any, any, any>
39
+ ? PageType
40
+ : never;
41
+
42
+ export abstract class SyncState<
43
+ Input,
44
+ Output,
45
+ Page,
46
+ RootOutput,
47
+ RootPage,
48
+ Fetcher extends BaseDataFetcher<Input, Output, RootOutput, Page, RootPage>,
49
+ > implements RootConversion<Output, RootOutput, RootPage> {
50
+ public readonly bufferedData: Deque<OutputAndCleanup<Output>>;
51
+ /**
52
+ * Wakes up when new data is available\
53
+ * Note: if a query returns no data, the `page` is updated,
54
+ * but this won't trigger this conditional variable.
55
+ * See {@link SyncState#newPageCondVar} if you need this.
56
+ */
57
+ public readonly newDataCondVar: CondVar<void>;
58
+ /**
59
+ * Wakes up when the `page` is available\
60
+ * Note: this will trigger even if no new data is found.
61
+ * If you don't want this, see {@link SyncState#newDataCondVar} instead.
62
+ */
63
+ public readonly newPageCondVar: CondVar<void>;
64
+ /**
65
+ * Page is kept separately because a page is not necessarily the last input you've received
66
+ * ex: it's possible that you query from timestamp [0,100] and get no data
67
+ * so you would set the lastPage at 100
68
+ * so you don't have to re-query [0,100] which you know is empty
69
+ */
70
+ public lastPage: undefined | LastPage<Page, RootPage>;
71
+ /** Timestamp of the last successful fetch (readData completed without error). */
72
+ public lastSuccessfulFetchMs: number = 0;
73
+ /** Number of consecutive errors since the last successful fetch. */
74
+ public consecutiveErrors: number = 0;
75
+ /** Timestamp of the most recent error, or 0 if no error has occurred. */
76
+ public lastErrorTimestamp: number = 0;
77
+
78
+ constructor(
79
+ public readonly name: string,
80
+ lastPage: undefined | LastPage<Page, RootPage>,
81
+ public readonly fetcher: Fetcher,
82
+ public readonly pageRelation: PageRelation<Page>,
83
+ public readonly dbConn: PoolClient | undefined = undefined,
84
+ ) {
85
+ this.bufferedData = new Deque();
86
+ this.newDataCondVar = conditionVariable<void>();
87
+ this.newPageCondVar = conditionVariable<void>();
88
+ this.lastPage = lastPage;
89
+ }
90
+
91
+ abstract stateToInput(): Operation<undefined | Input>;
92
+ abstract toPage(input: Input, data: Output[]): Page;
93
+ abstract toRootPage(data: Output): RootPage;
94
+ abstract toRootOutput(data: Output): RootOutput;
95
+ abstract getNamespace(): string[];
96
+
97
+ /**
98
+ * Start any process that is meant to fetch data asynchronously
99
+ * By default, this is a no-op, but other classes can override it
100
+ */
101
+ *startAsync(): Operation<void> {
102
+ }
103
+
104
+ /**
105
+ * Stateless function that defines how to merge a single data point into the root
106
+ */
107
+ abstract mergeDatum(ourOutput: Output, rootOutput: RootOutput): void;
108
+
109
+ @bound
110
+ *updateState(
111
+ _input: Input,
112
+ data: DataFetched<Output, Page, RootPage>,
113
+ ): Operation<void> {
114
+ if (data.output.length > 0) {
115
+ for (const datum of data.output) {
116
+ this.bufferedData.push(datum);
117
+ }
118
+ this.newDataCondVar.wake();
119
+ }
120
+
121
+ if (stableStringify(data.lastPage) !== stableStringify(this.lastPage)) {
122
+ log.remote(
123
+ ComponentNames.EFFECTSTREAM_SYNC,
124
+ this.getNamespace(),
125
+ SeverityNumber.DEBUG,
126
+ (log) =>
127
+ log(
128
+ `Update root page: ${this.lastPage?.root} -> ${data.lastPage.root}`,
129
+ ),
130
+ );
131
+
132
+ yield* acquireDBMutex(`update-state-${this.name}`);
133
+ yield* until(upsertPage.run({
134
+ protocol_name: this.name,
135
+ page_number: data.lastPage.ownBlockNumber,
136
+ page: JSON.stringify(data.lastPage),
137
+ }, this.dbConn as any)); // Client,
138
+ releaseDBMutex(`update-state-${this.name}`);
139
+ this.lastPage = data.lastPage;
140
+ this.newPageCondVar.wake();
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,549 @@
1
+ import {
2
+ BaseDataFetcher,
3
+ type DataFetched,
4
+ type PaginatedFetcher,
5
+ } from "../base/fetcher.ts";
6
+ import type { RootOutput, RootPage } from "../types.ts";
7
+ import type { Operation } from "effection";
8
+ import { call } from "effection";
9
+ import { bound } from "@effectstream/utils";
10
+ import type {
11
+ Input,
12
+ Output,
13
+ Page,
14
+ PrimitiveEntryType,
15
+ PrimitiveType,
16
+ BitcoinBlock,
17
+ BitcoinTransaction,
18
+ BitcoinVout,
19
+ } from "./types.ts";
20
+ import type { PageRequest, PageRange } from "../base/page.ts";
21
+ import { genOnDemandPageRequests } from "../base/page.ts";
22
+ import type { PrimitiveFetcher } from "../base/primitive.ts";
23
+ import type { OutputAndCleanup, RootConversion } from "../base/state.ts";
24
+ import type { ConfigSyncProtocolType } from "@effectstream/config";
25
+ import type {
26
+ BitcoinBlockHash,
27
+ BitcoinTxId,
28
+ } from "@effectstream/utils";
29
+ import { blockNumberRelation } from "../common/utils.ts";
30
+ import type { ConfigType } from "./types.ts";
31
+
32
+ const SATOSHIS_PER_BTC = 100_000_000;
33
+ type PrimitiveEventDirection = "input" | "output";
34
+ type NormalizedAddress = string;
35
+ type BitcoinPrimitiveDirection = "inputs" | "outputs" | "both";
36
+
37
+ type Watcher = {
38
+ entry: PrimitiveEntryType;
39
+ direction: BitcoinPrimitiveDirection;
40
+ normalizedAddress: NormalizedAddress;
41
+ originalAddress: string;
42
+ };
43
+
44
+ type WatcherMap = Map<NormalizedAddress, Watcher[]>;
45
+
46
+ type ResolveAddressParams = {
47
+ vout: BitcoinVout;
48
+ };
49
+
50
+ type BuildPrimitiveParams = {
51
+ watcher: Watcher;
52
+ block: BitcoinBlock;
53
+ tx: BitcoinTransaction;
54
+ txIndex: number;
55
+ direction: PrimitiveEventDirection;
56
+ index: number;
57
+ utxo: { txid: BitcoinTxId; vout: number };
58
+ valueSats: number;
59
+ };
60
+
61
+ type OutputEventParams = {
62
+ block: BitcoinBlock;
63
+ tx: BitcoinTransaction;
64
+ txIndex: number;
65
+ watchers: WatcherMap;
66
+ };
67
+
68
+ type InputEventParams = OutputEventParams & {
69
+ txCache: Map<string, BitcoinTransaction>;
70
+ };
71
+
72
+ type TransactionLookupParams = {
73
+ txid: string;
74
+ cache: Map<string, BitcoinTransaction>;
75
+ };
76
+
77
+ export class BitcoinFetcher
78
+ extends BaseDataFetcher<Input, Output, RootOutput, Page, RootPage>
79
+ implements
80
+ PrimitiveFetcher<
81
+ Input,
82
+ Page,
83
+ BitcoinBlock,
84
+ PrimitiveType,
85
+ ConfigSyncProtocolType.BITCOIN_RPC_PARALLEL
86
+ >,
87
+ PaginatedFetcher<Page> {
88
+ constructor(
89
+ readonly config: ConfigType,
90
+ readonly rpcClient: BitcoinRpcClient,
91
+ ) {
92
+ super(config.syncProtocol.name);
93
+ }
94
+
95
+ @bound
96
+ override *readData(
97
+ data: Input,
98
+ rootConversion: RootConversion<Output, RootOutput, RootPage>,
99
+ ): Operation<DataFetched<Output, Page, RootPage>> {
100
+ const pageFetcher = genOnDemandPageRequests(
101
+ data.from,
102
+ data.to,
103
+ (page) => this.rpcClient.getBlockByHeight(Number(page)),
104
+ blockNumberRelation,
105
+ );
106
+
107
+ const primitives = yield* this.readPrimitives(
108
+ data,
109
+ pageFetcher,
110
+ this.config.primitives,
111
+ );
112
+ const groupedByPage = this.groupByPage(primitives);
113
+
114
+ const outputs: OutputAndCleanup<Output>[] = [];
115
+ for (let height = Number(data.from); height <= Number(data.to); height++) {
116
+ const blockHeight = height as Page;
117
+ const block = yield* call(() => pageFetcher(blockHeight));
118
+ outputs.push({
119
+ output: {
120
+ raw: block,
121
+ primitives: groupedByPage[String(height)] ?? [],
122
+ blockHashes: [block.hash],
123
+ },
124
+ cleanup: () => {},
125
+ });
126
+ }
127
+ const lastOutput = outputs[outputs.length - 1];
128
+ if (!lastOutput) {
129
+ throw new Error("BitcoinFetcher: no block data fetched for interval");
130
+ }
131
+ const lastBlock = lastOutput.output.raw;
132
+ const lastPageRef = this.asPage(Number(data.to));
133
+ return {
134
+ output: outputs,
135
+ lastPage: {
136
+ own: lastPageRef,
137
+ ownBlockNumber: lastPageRef,
138
+ root: rootConversion.toRootPage({
139
+ raw: lastBlock,
140
+ primitives: [],
141
+ blockHashes: [lastBlock.hash],
142
+ }),
143
+ },
144
+ };
145
+ }
146
+
147
+ @bound
148
+ groupByPage(
149
+ primitives: PrimitiveType[],
150
+ ): Record<string, PrimitiveType[]> {
151
+ return primitives.reduce<Record<string, PrimitiveType[]>>(
152
+ (acc, primitive) => {
153
+ const key = String(primitive.syncProtocol.blockNumber);
154
+ (acc[key] ??= []).push(primitive);
155
+ return acc;
156
+ },
157
+ {},
158
+ );
159
+ }
160
+
161
+ @bound
162
+ *readPrimitives(
163
+ data: Input,
164
+ pageRequest: PageRequest<Page, BitcoinBlock>,
165
+ primitiveEntries: PrimitiveEntryType[],
166
+ ): Operation<PrimitiveType[]> {
167
+ if (primitiveEntries.length === 0) {
168
+ return [];
169
+ }
170
+
171
+ const watchers = this.buildWatcherMap(primitiveEntries);
172
+ if (watchers.size === 0) {
173
+ return [];
174
+ }
175
+
176
+ const primitives: PrimitiveType[] = [];
177
+ const txCache = new Map<string, BitcoinTransaction>();
178
+
179
+ for (let height = Number(data.from); height <= Number(data.to); height++) {
180
+ const blockHeight = height as Page;
181
+ const block = yield* call(() => pageRequest(blockHeight));
182
+ const transactions = block.tx ?? [];
183
+ for (let txIndex = 0; txIndex < transactions.length; txIndex++) {
184
+ const tx = transactions[txIndex]!;
185
+ const outputEvents = yield* call(() =>
186
+ this.findOutputEvents({
187
+ block,
188
+ tx,
189
+ txIndex,
190
+ watchers,
191
+ })
192
+ );
193
+ const inputEvents = yield* call(() =>
194
+ this.findInputEvents({
195
+ block,
196
+ tx,
197
+ txIndex,
198
+ watchers,
199
+ txCache,
200
+ })
201
+ );
202
+ primitives.push(...outputEvents, ...inputEvents);
203
+ }
204
+ }
205
+ return primitives;
206
+ }
207
+
208
+ private async findOutputEvents(
209
+ params: OutputEventParams,
210
+ ): Promise<PrimitiveType[]> {
211
+ const events: PrimitiveType[] = [];
212
+ for (const vout of params.tx.vout ?? []) {
213
+ const address = await this.resolveAddressFromVout({ vout });
214
+ const normalized = this.normalizeAddress(address);
215
+ if (!normalized) continue;
216
+ const watchers = params.watchers.get(normalized);
217
+ if (!watchers?.length) continue;
218
+ const valueSats = this.toSatoshis(vout.value);
219
+ for (const watcher of watchers) {
220
+ if (!this.directionEnabled(watcher.direction, "output")) {
221
+ continue;
222
+ }
223
+ events.push(
224
+ this.buildPrimitive({
225
+ watcher,
226
+ block: params.block,
227
+ tx: params.tx,
228
+ txIndex: params.txIndex,
229
+ direction: "output",
230
+ index: vout.n,
231
+ utxo: {
232
+ txid: params.tx.txid,
233
+ vout: vout.n,
234
+ },
235
+ valueSats,
236
+ }),
237
+ );
238
+ }
239
+ }
240
+ return events;
241
+ }
242
+
243
+ private async findInputEvents(
244
+ params: InputEventParams,
245
+ ): Promise<PrimitiveType[]> {
246
+ const events: PrimitiveType[] = [];
247
+ for (let vinIndex = 0; vinIndex < (params.tx.vin?.length ?? 0); vinIndex++) {
248
+ const vin = params.tx.vin![vinIndex]!;
249
+ if (!vin.txid || vin.vout == null) continue;
250
+ const prevTx = await this.getTransactionFromCache({
251
+ txid: vin.txid,
252
+ cache: params.txCache,
253
+ });
254
+ const prevVout = prevTx?.vout?.find((entry) => entry.n === vin.vout);
255
+ if (!prevVout) continue;
256
+ const address = await this.resolveAddressFromVout({ vout: prevVout });
257
+ const normalized = this.normalizeAddress(address);
258
+ if (!normalized) continue;
259
+ const watchers = params.watchers.get(normalized);
260
+ if (!watchers?.length) continue;
261
+ const valueSats = this.toSatoshis(prevVout.value);
262
+ for (const watcher of watchers) {
263
+ if (!this.directionEnabled(watcher.direction, "input")) {
264
+ continue;
265
+ }
266
+ events.push(
267
+ this.buildPrimitive({
268
+ watcher,
269
+ block: params.block,
270
+ tx: params.tx,
271
+ txIndex: params.txIndex,
272
+ direction: "input",
273
+ index: vinIndex,
274
+ utxo: {
275
+ txid: vin.txid as BitcoinTxId,
276
+ vout: vin.vout,
277
+ },
278
+ valueSats,
279
+ }),
280
+ );
281
+ }
282
+ }
283
+ return events;
284
+ }
285
+
286
+ private async resolveAddressFromVout(
287
+ params: ResolveAddressParams,
288
+ ): Promise<string | undefined> {
289
+ const direct = this.extractAddressFromScript(params.vout);
290
+ if (direct) return direct;
291
+ if (!params.vout.scriptPubKey.hex) return undefined;
292
+ const decoded = await this.rpcClient.decodeScript(
293
+ params.vout.scriptPubKey.hex,
294
+ );
295
+ return decoded.address ?? decoded.p2pkh ?? decoded.p2sh ?? undefined;
296
+ }
297
+
298
+ private extractAddressFromScript(vout: BitcoinVout): string | undefined {
299
+ const script = vout.scriptPubKey;
300
+ if (script.address) return script.address;
301
+ if (script.addresses?.length) return script.addresses[0];
302
+ return undefined;
303
+ }
304
+
305
+ private buildWatcherMap(entries: PrimitiveEntryType[]): WatcherMap {
306
+ const map: WatcherMap = new Map();
307
+ for (const entry of entries) {
308
+ const trimmedAddress = entry.primitive.watchAddress.trim();
309
+ const normalized = this.normalizeAddress(trimmedAddress);
310
+ if (!normalized) {
311
+ console.warn(
312
+ `[bitcoin] primitive ${entry.primitive.name} missing watch address`,
313
+ );
314
+ continue;
315
+ }
316
+ const watcher: Watcher = {
317
+ entry,
318
+ direction: entry.primitive.direction ?? "both",
319
+ normalizedAddress: normalized,
320
+ originalAddress: trimmedAddress,
321
+ };
322
+ const existing = map.get(normalized) ?? [];
323
+ existing.push(watcher);
324
+ map.set(normalized, existing);
325
+ }
326
+ return map;
327
+ }
328
+
329
+ private normalizeAddress(address: string | undefined): string | undefined {
330
+ if (!address) return undefined;
331
+ const trimmed = address.trim();
332
+ if (trimmed.length === 0) return undefined;
333
+ if (
334
+ trimmed.startsWith("bc1") ||
335
+ trimmed.startsWith("tb1") ||
336
+ trimmed.startsWith("bcrt1")
337
+ ) {
338
+ return trimmed.toLowerCase();
339
+ }
340
+ return trimmed;
341
+ }
342
+
343
+ private directionEnabled(
344
+ configured: BitcoinPrimitiveDirection,
345
+ actual: PrimitiveEventDirection,
346
+ ): boolean {
347
+ if (configured === "both") return true;
348
+ if (configured === "inputs") return actual === "input";
349
+ if (configured === "outputs") return actual === "output";
350
+ return false;
351
+ }
352
+
353
+ private toSatoshis(amountBtc: number): number {
354
+ return Math.round(amountBtc * SATOSHIS_PER_BTC);
355
+ }
356
+
357
+ private buildPrimitive(params: BuildPrimitiveParams): PrimitiveType {
358
+ const payloadDirection = params.direction;
359
+ return {
360
+ syncProtocol: {
361
+ name: params.watcher.entry.syncProtocol,
362
+ blockNumber: Number(params.block.height),
363
+ transactionHash: params.tx.txid,
364
+ transactionIndex: params.txIndex,
365
+ contractAddress: params.watcher.originalAddress,
366
+ logIndex: params.index,
367
+ },
368
+ primitive: params.watcher.entry.primitive.name,
369
+ output: {
370
+ payloadType: payloadDirection === "output"
371
+ ? "UTXO_CREATED"
372
+ : "UTXO_SPENT",
373
+ payload: {
374
+ direction: payloadDirection,
375
+ address: params.watcher.originalAddress,
376
+ transactionId: params.tx.txid,
377
+ index: params.index,
378
+ valueSats: params.valueSats,
379
+ utxo: params.utxo,
380
+ label: params.watcher.entry.primitive.label ?? '',
381
+ },
382
+ },
383
+ };
384
+ }
385
+
386
+ private async getTransactionFromCache(
387
+ params: TransactionLookupParams,
388
+ ): Promise<BitcoinTransaction | undefined> {
389
+ // TODO: use a more efficient and bounded cache
390
+ if (params.cache.has(params.txid)) {
391
+ return params.cache.get(params.txid);
392
+ }
393
+ const tx = await this.rpcClient.getRawTransaction(params.txid);
394
+ if (tx) {
395
+ params.cache.set(params.txid, tx);
396
+ }
397
+ return tx;
398
+ }
399
+
400
+ @bound
401
+ *getLatestPage(
402
+ knownLastPage: undefined | Page,
403
+ ): Operation<Page> {
404
+ const latestHeight = yield* call(() => this.rpcClient.getBlockCount());
405
+ const safeHeight = Math.max(
406
+ 0,
407
+ latestHeight - this.config.syncProtocol.confirmationDepth,
408
+ );
409
+ if (knownLastPage != null && safeHeight <= Number(knownLastPage)) {
410
+ return knownLastPage;
411
+ }
412
+ return this.asPage(safeHeight);
413
+ }
414
+
415
+ @bound
416
+ previousInterval(nextIntervalStart: Page): PageRange<Page> {
417
+ const step = this.stepSize();
418
+ const start = Number(nextIntervalStart) - step - 1;
419
+ return this.intervalFromStart(this.asPage(Math.max(start, 0)));
420
+ }
421
+
422
+ @bound
423
+ nextInterval(prevIntervalEnd: Page): PageRange<Page> {
424
+ const start = Number(prevIntervalEnd) + 1;
425
+ return this.intervalFromStart(this.asPage(start));
426
+ }
427
+
428
+ @bound
429
+ intervalFromStart(start: Page): PageRange<Page> {
430
+ const normalizedStart = Math.max(Number(start), 0);
431
+ const step = this.stepSize();
432
+ const fromPage = this.asPage(normalizedStart);
433
+ const toPage = this.asPage(normalizedStart + step);
434
+ return {
435
+ from: fromPage,
436
+ to: toPage,
437
+ };
438
+ }
439
+
440
+ private stepSize(): number {
441
+ return this.config.syncProtocol.stepSize ?? 25;
442
+ }
443
+
444
+ private asPage(value: number): Page {
445
+ return value as Page;
446
+ }
447
+ }
448
+
449
+ type BitcoinRpcClientOptions = {
450
+ url: string;
451
+ username?: string | null;
452
+ password?: string | null;
453
+ };
454
+
455
+ type BitcoinRpcRequest = {
456
+ method: string;
457
+ params?: unknown[];
458
+ };
459
+
460
+ type BitcoinDecodedScript = {
461
+ address?: string;
462
+ p2pkh?: string;
463
+ p2sh?: string;
464
+ };
465
+
466
+ export class BitcoinRpcClient {
467
+ private readonly options: BitcoinRpcClientOptions;
468
+ private nextId = 0;
469
+
470
+ constructor(options: BitcoinRpcClientOptions) {
471
+ this.options = options;
472
+ }
473
+
474
+ async getBlockCount(): Promise<number> {
475
+ return await this.call<number>({ method: "getblockcount" });
476
+ }
477
+
478
+ async getBlockByHeight(height: number): Promise<BitcoinBlock> {
479
+ const hash = await this.call<string>({
480
+ method: "getblockhash",
481
+ params: [height],
482
+ });
483
+ const block = await this.call<BitcoinBlock>({
484
+ method: "getblock",
485
+ params: [hash, 2],
486
+ });
487
+ return {
488
+ ...block,
489
+ hash: block.hash as BitcoinBlockHash,
490
+ height: block.height as Page,
491
+ };
492
+ }
493
+
494
+ async getRawTransaction(txid: string): Promise<BitcoinTransaction> {
495
+ return await this.call<BitcoinTransaction>({
496
+ method: "getrawtransaction",
497
+ params: [txid, true],
498
+ });
499
+ }
500
+
501
+ async decodeScript(scriptHex: string): Promise<BitcoinDecodedScript> {
502
+ return await this.call<BitcoinDecodedScript>({
503
+ method: "decodescript",
504
+ params: [scriptHex],
505
+ });
506
+ }
507
+
508
+ private async call<TResult>(
509
+ request: BitcoinRpcRequest,
510
+ ): Promise<TResult> {
511
+ const response = await fetch(this.options.url, {
512
+ method: "POST",
513
+ headers: this.buildHeaders(),
514
+ body: JSON.stringify({
515
+ jsonrpc: "2.0",
516
+ id: ++this.nextId,
517
+ method: request.method,
518
+ params: request.params ?? [],
519
+ }),
520
+ });
521
+ if (!response.ok) {
522
+ const body = await response.text();
523
+ throw new Error(
524
+ `Bitcoin RPC ${request.method} failed (${response.status}): ${body}`,
525
+ );
526
+ }
527
+ const payload = await response.json();
528
+ if (payload.error) {
529
+ throw new Error(
530
+ `Bitcoin RPC error for ${request.method}: ${
531
+ JSON.stringify(payload.error)
532
+ }`,
533
+ );
534
+ }
535
+ return payload.result as TResult;
536
+ }
537
+
538
+ private buildHeaders(): HeadersInit {
539
+ const headers: HeadersInit = {
540
+ "Content-Type": "application/json",
541
+ };
542
+ if (this.options.username && this.options.password) {
543
+ const credentials = `${this.options.username}:${this.options.password}`;
544
+ headers.Authorization = `Basic ${btoa(credentials)}`;
545
+ }
546
+ return headers;
547
+ }
548
+ }
549
+