@effectstream/sync 0.101.1 → 0.103.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 (41) hide show
  1. package/CLAUDE.md +82 -18
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +63 -4
  5. package/docs/ADDING-A-SYNC-PROTOCOL.md +16 -9
  6. package/package.json +8 -8
  7. package/src/sync-protocols/avail/AvailClient.ts +34 -6
  8. package/src/sync-protocols/avail/fetcher.ts +2 -0
  9. package/src/sync-protocols/avail/state.ts +2 -0
  10. package/src/sync-protocols/base/state.ts +51 -0
  11. package/src/sync-protocols/bitcoin/fetcher.ts +18 -10
  12. package/src/sync-protocols/celestia/CelestiaClient.ts +16 -2
  13. package/src/sync-protocols/celestia/fetcher.ts +5 -1
  14. package/src/sync-protocols/celestia/state.ts +5 -1
  15. package/src/sync-protocols/common/http.ts +77 -0
  16. package/src/sync-protocols/midnight/MidnightClient.ts +11 -3
  17. package/src/sync-protocols/midnight/fetcher.ts +18 -11
  18. package/src/sync-protocols/midnight/types.ts +1 -1
  19. package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
  20. package/src/sync-protocols/near/NearClient.ts +16 -6
  21. package/src/sync-protocols/near/fetcher.ts +5 -1
  22. package/src/sync-protocols/near/state.ts +5 -1
  23. package/src/sync-protocols/orchestration/sync.ts +101 -13
  24. package/src/sync-protocols/solana/SolanaClient.ts +188 -0
  25. package/src/sync-protocols/solana/fetcher.ts +456 -0
  26. package/src/sync-protocols/solana/program-logs.test.ts +113 -0
  27. package/src/sync-protocols/solana/program-logs.ts +84 -0
  28. package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
  29. package/src/sync-protocols/solana/read-primitives.test.ts +435 -0
  30. package/src/sync-protocols/solana/root-page.test.ts +66 -0
  31. package/src/sync-protocols/solana/state.ts +167 -0
  32. package/src/sync-protocols/solana/types.ts +58 -0
  33. package/src/sync-protocols/types.ts +2 -0
  34. package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
  35. package/src/sync-protocols/utxorpc/state.ts +3 -0
  36. package/src/syncProtocolFactory.ts +15 -0
  37. package/test/poll-loop-spin.test.ts +112 -0
  38. package/test/rpc-timeout.test.ts +150 -0
  39. package/test/spin-probe.ts +102 -0
  40. package/test/start-async-supervision.test.ts +139 -0
  41. package/test/zswap-decoder.test.ts +74 -0
@@ -0,0 +1,167 @@
1
+ import { call, type Operation } from "effection";
2
+ import { bound } from "@effectstream/utils";
3
+ import type { PoolClient } from "pg";
4
+ import { type LastPage, SyncState } from "../base/state.ts";
5
+ import type { RootOutput, RootPage } from "../types.ts";
6
+ import type { Input, Output, Page } from "./types.ts";
7
+ import { toMsTimestamp } from "./types.ts";
8
+ import { blockNumberRelation } from "../common/utils.ts";
9
+ import type { SolanaFetcher } from "./fetcher.ts";
10
+ import type {
11
+ ConfigNetworkType,
12
+ SyncProtocolWithNetwork,
13
+ } from "@effectstream/config";
14
+ import { getPage } from "@effectstream/db";
15
+ import { SolanaClient } from "./SolanaClient.ts";
16
+ import { applyDelay } from "../common/utils.ts";
17
+ import { bufferAtCap } from "../common/page-helpers.ts";
18
+ import { requestTimeoutOf } from "../common/http.ts";
19
+
20
+ export class SolanaSyncState extends SyncState<
21
+ Input,
22
+ Output,
23
+ Page,
24
+ RootOutput,
25
+ RootPage,
26
+ SolanaFetcher
27
+ > {
28
+ constructor(
29
+ lastPage: LastPage<Page, RootPage> | undefined,
30
+ readonly config: Extract<
31
+ SyncProtocolWithNetwork,
32
+ { networkType: ConfigNetworkType.SOLANA }
33
+ >,
34
+ fetcher: SolanaFetcher,
35
+ public readonly client: SolanaClient,
36
+ dbConn: PoolClient,
37
+ ) {
38
+ super(
39
+ config.syncProtocol.name,
40
+ lastPage,
41
+ fetcher,
42
+ blockNumberRelation,
43
+ dbConn,
44
+ );
45
+ }
46
+
47
+ @bound
48
+ override toPage(_input: Input, data: Output[]): Page {
49
+ const lastBlock = data[data.length - 1];
50
+ return lastBlock.slot as Page;
51
+ }
52
+
53
+ @bound
54
+ override toRootPage(data: Output): RootPage {
55
+ // Solana blockTime is monotonically non-decreasing (enforced by the
56
+ // cluster) — exactly what the merge gate needs: it pulls buffered parallel
57
+ // outputs in slot order while their root timestamp is <= the main chain's,
58
+ // so it disambiguates duplicate blockTimes by buffer/slot order, NOT by
59
+ // this value. We therefore must NOT add a slot-derived offset: the previous
60
+ // `(slot % 1000) * 0.001` was unnecessary AND broke monotonicity (it wrapped
61
+ // every 1000 slots, so slot 1000's root could sort before slot 999's).
62
+ return applyDelay(
63
+ toMsTimestamp(data.blockTime),
64
+ this.config.syncProtocol.delayMs,
65
+ );
66
+ }
67
+
68
+ @bound
69
+ override toRootOutput(_data: Output): RootOutput {
70
+ throw new Error("Only main chains create root outputs");
71
+ }
72
+
73
+ @bound
74
+ override *stateToInput(): Operation<Input | undefined> {
75
+ // Pause fetching while the merge drains our buffer (CLAUDE.md finding #1).
76
+ // Every other protocol gates on this first; skipping it lets the Deque grow
77
+ // toward the whole backlog during catch-up.
78
+ if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
79
+ // Query the chain tip directly (like NearSyncState) rather than via
80
+ // genInputRange, which requires the fetcher to implement PaginatedFetcher
81
+ // (getLatestPage/nextInterval/...) — SolanaFetcher does not.
82
+ const latestSlot = yield* call(() => this.client.getSlot());
83
+ const finalizedSlot = latestSlot - this.config.syncProtocol.confirmationDepth;
84
+
85
+ const lastSlot = this.lastPage?.own
86
+ ?? ((this.config.syncProtocol.startBlockHeight as Page) - 1);
87
+
88
+ if (lastSlot >= finalizedSlot) {
89
+ return undefined;
90
+ }
91
+
92
+ const from = (lastSlot + 1) as Page;
93
+ const to = Math.min(
94
+ Number(lastSlot) + this.config.syncProtocol.stepSize,
95
+ finalizedSlot,
96
+ ) as Page;
97
+
98
+ return {
99
+ from,
100
+ to,
101
+ isPresync: false,
102
+ };
103
+ }
104
+
105
+ @bound
106
+ override mergeDatum(ourOutput: Output, rootOutput: RootOutput): void {
107
+ const primitives = ourOutput.primitives.map((p) => ({
108
+ ...p,
109
+ source: this.config.syncProtocol.name,
110
+ }));
111
+ const blockInfo = [{
112
+ protocol_name: this.config.syncProtocol.name,
113
+ block_number: ourOutput.slot,
114
+ blockHash: ourOutput.blockhash,
115
+ }];
116
+ rootOutput.blockInfo.push(...blockInfo);
117
+ rootOutput.primitives.push(...primitives);
118
+ }
119
+
120
+ /**
121
+ * Resume marker for a single slot. `Page` is a flat slot number, so `own` and
122
+ * `ownBlockNumber` are the same value (cf. EvmSyncState). Declared `abstract`
123
+ * on SyncState and called unconditionally by the merge for every protocol that
124
+ * contributes data to a block — see CLAUDE.md design idea #5.
125
+ */
126
+ @bound
127
+ override outputToLastPage(data: Output): LastPage<Page, RootPage> {
128
+ return {
129
+ own: data.slot as Page,
130
+ ownBlockNumber: data.slot as Page,
131
+ root: this.toRootPage(data),
132
+ };
133
+ }
134
+
135
+ @bound
136
+ override getNamespace(): string[] {
137
+ return [this.config.network.name, this.config.syncProtocol.name];
138
+ }
139
+
140
+ static *restoreState(
141
+ dbConn: PoolClient,
142
+ config: Extract<
143
+ SyncProtocolWithNetwork,
144
+ { networkType: ConfigNetworkType.SOLANA }
145
+ >,
146
+ fetcher: SolanaFetcher,
147
+ ): Operation<SolanaSyncState> {
148
+ const [result] = yield* call(async () =>
149
+ await getPage.run({
150
+ protocol_name: config.syncProtocol.name,
151
+ }, dbConn)
152
+ );
153
+ const page = result
154
+ ? result.page as unknown as LastPage<Page, RootPage>
155
+ : undefined;
156
+ return new SolanaSyncState(
157
+ page,
158
+ config,
159
+ fetcher,
160
+ new SolanaClient(
161
+ config.network.rpcUrl,
162
+ requestTimeoutOf(config.syncProtocol),
163
+ ),
164
+ dbConn,
165
+ );
166
+ }
167
+ }
@@ -0,0 +1,58 @@
1
+ import type {
2
+ BlockNumber,
3
+ TimestampMs,
4
+ } from "@effectstream/utils";
5
+ import type { PageSyncRange } from "../common/page-helpers.ts";
6
+ import type {
7
+ ConfigNetworkType,
8
+ ConfigSyncProtocolType,
9
+ FlattenSyncProtocolIOFor,
10
+ PrimitiveEntry,
11
+ SyncProtocolWithNetwork,
12
+ } from "@effectstream/config";
13
+
14
+ /** Solana page type: a slot number (block height) */
15
+ export type Page = BlockNumber;
16
+
17
+ export type ConfigType = Extract<
18
+ SyncProtocolWithNetwork,
19
+ { networkType: ConfigNetworkType.SOLANA }
20
+ >;
21
+
22
+ export type PrimitiveEntryType = Extract<
23
+ PrimitiveEntry,
24
+ { syncProtocol: ConfigSyncProtocolType.SOLANA_RPC_PARALLEL }
25
+ >;
26
+
27
+ export type PrimitiveType = FlattenSyncProtocolIOFor<
28
+ ConfigSyncProtocolType.SOLANA_RPC_PARALLEL
29
+ >;
30
+
31
+ export type Input = PageSyncRange<Page>;
32
+
33
+ export type SolanaTransactionMeta = {
34
+ err: unknown | null;
35
+ logMessages: string[] | null;
36
+ preBalances: number[];
37
+ postBalances: number[];
38
+ };
39
+
40
+ export type Output = {
41
+ slot: number;
42
+ blockhash: string;
43
+ /**
44
+ * Unix seconds. Non-null by construction: the fetcher resolves the RPC's
45
+ * nullable `blockTime` before building an Output, because a block with no
46
+ * timestamp cannot be placed in the time-ordered merge.
47
+ */
48
+ blockTime: number;
49
+ blockHeight: number | null;
50
+ parentSlot: number;
51
+ transactions: SolanaTransactionMeta[];
52
+ primitives: PrimitiveType[];
53
+ };
54
+
55
+ /** Convert a Solana blockTime (unix seconds) to milliseconds. */
56
+ export function toMsTimestamp(blockTime: number): TimestampMs {
57
+ return blockTime * 1000 as TimestampMs;
58
+ }
@@ -10,6 +10,7 @@ import type { AvailSyncState } from "./avail/state.ts";
10
10
  import type { BitcoinSyncState } from "./bitcoin/state.ts";
11
11
  import type { CelestiaSyncState } from "./celestia/state.ts";
12
12
  import type { NearSyncState } from "./near/state.ts";
13
+ import type { SolanaSyncState } from "./solana/state.ts";
13
14
  import type { TestSyncState } from "./test/state.ts";
14
15
 
15
16
  // TODO: move folders
@@ -26,6 +27,7 @@ export type AllSyncProtocols =
26
27
  | BitcoinSyncState
27
28
  | CelestiaSyncState
28
29
  | NearSyncState
30
+ | SolanaSyncState
29
31
  | TestSyncState;
30
32
  export type ISyncProtocol = UnionToIntersection<AllSyncProtocols>;
31
33
 
@@ -31,6 +31,21 @@ export class WatchMultiplexer {
31
31
  private highestConfirmedHeight: number | undefined = undefined;
32
32
  private lowestIncompleteHeight: number = 0;
33
33
  private slotOriginTimestamp: bigint | undefined = undefined;
34
+ /**
35
+ * Incremented by every {@link start} call so events from a previous
36
+ * generation's streams can be identified and dropped.
37
+ *
38
+ * `start` can now be called more than once: `startSync` supervises the
39
+ * producer and restarts it when the stream dies. All multiplexer state lives
40
+ * on this instance, and the gRPC streams of the previous generation are not
41
+ * cancellable from here — a `Promise.all` rejection from ONE watcher aborts
42
+ * `start` while its siblings keep streaming. Without this guard a restart
43
+ * would run two live streams per predicate into the same `pendingBlocks`,
44
+ * pushing each matched tx twice. Duplicate primitives in a block mean
45
+ * duplicate scheduled STF inputs, i.e. non-deterministic state — the exact
46
+ * failure the whole pipeline is built to avoid.
47
+ */
48
+ private generation = 0;
34
49
 
35
50
  constructor(
36
51
  private readonly clientOptions: ClientBuilderOptions,
@@ -88,14 +103,39 @@ export class WatchMultiplexer {
88
103
  }
89
104
  }
90
105
 
106
+ const generation = ++this.generation;
107
+
108
+ // Drop half-assembled work from the previous generation. A block is only
109
+ // completed once EVERY watcher has passed it, so at the moment a stream
110
+ // died some heights had been seen by some watchers and not others. The new
111
+ // stream re-delivers those heights from the resume point, and merging the
112
+ // two views would double their txs. Heights below `lowestIncompleteHeight`
113
+ // are already completed and are never revisited, so this discards exactly
114
+ // the in-flight window.
115
+ if (generation > 1) {
116
+ for (const [, watcher] of this.watchers) {
117
+ for (const [height] of watcher.pendingBlocks) {
118
+ if (height >= this.lowestIncompleteHeight) {
119
+ watcher.pendingBlocks.delete(height);
120
+ }
121
+ }
122
+ watcher.highestSeenHeight = Math.max(
123
+ this.lowestIncompleteHeight - 1,
124
+ 0,
125
+ );
126
+ }
127
+ }
128
+
91
129
  const intersect = point ? [point] : [];
92
130
  const promises: Promise<void>[] = [];
93
131
  for (const [key, watcher] of this.watchers) {
94
132
  const watchClient = new CardanoWatchClient(this.clientOptions);
95
- promises.push(this.runWatcher(key, watcher, watchClient, intersect));
133
+ promises.push(this.runWatcher(key, watcher, watchClient, intersect, generation));
96
134
  }
97
135
 
98
- console.log(`[WatchMultiplexer] Started ${this.watchers.size} watcher(s)`);
136
+ console.log(
137
+ `[WatchMultiplexer] Started ${this.watchers.size} watcher(s) (generation ${generation})`,
138
+ );
99
139
  await Promise.all(promises);
100
140
  }
101
141
 
@@ -104,9 +144,19 @@ export class WatchMultiplexer {
104
144
  watcher: WatcherState,
105
145
  watchClient: CardanoWatchClient,
106
146
  intersect: ChainPoint[],
147
+ generation: number,
107
148
  ): Promise<void> {
108
149
  const stream = watchClient.watchTxByPredicate(watcher.sdkPredicate, intersect);
109
150
  for await (const event of stream) {
151
+ // A superseded generation must contribute nothing. Breaking here also
152
+ // closes the underlying stream via the async iterator's return path,
153
+ // which is the only way to stop it from this side.
154
+ if (generation !== this.generation) {
155
+ console.warn(
156
+ `[WatchMultiplexer] Watcher "${key}" from generation ${generation} superseded by ${this.generation}; closing`,
157
+ );
158
+ return;
159
+ }
110
160
  this.handleEvent(key, watcher, event);
111
161
  }
112
162
  console.warn(`[WatchMultiplexer] Watcher "${key}" stream ended unexpectedly`);
@@ -35,6 +35,9 @@ export class UtxoRpcSyncState extends SyncState<
35
35
  );
36
36
  }
37
37
 
38
+ /** All utxorpc data arrives via the watch stream in {@link startAsync}. */
39
+ public override readonly hasAsyncProducer = true;
40
+
38
41
  @bound
39
42
  override *startAsync(): Operation<void> {
40
43
  if (this.lastPage == null) {
@@ -31,8 +31,11 @@ import { CelestiaFetcher } from "./sync-protocols/celestia/fetcher.ts";
31
31
  import { CelestiaSyncState } from "./sync-protocols/celestia/state.ts";
32
32
  import { NearFetcher } from "./sync-protocols/near/fetcher.ts";
33
33
  import { NearSyncState } from "./sync-protocols/near/state.ts";
34
+ import { SolanaFetcher } from "./sync-protocols/solana/fetcher.ts";
35
+ import { SolanaSyncState } from "./sync-protocols/solana/state.ts";
34
36
  import { TestFetcher } from "./sync-protocols/test/fetcher.ts";
35
37
  import { TestSyncState } from "./sync-protocols/test/state.ts";
38
+ import { requestTimeoutOf } from "./sync-protocols/common/http.ts";
36
39
 
37
40
  export function* genSyncProtocols(
38
41
  dbConn: PoolClient,
@@ -56,6 +59,7 @@ export function* genSyncProtocols(
56
59
  entry,
57
60
  createViemPublicClient(viemNetwork, {
58
61
  cacheTime: entry.syncProtocol.pollingInterval,
62
+ timeout: requestTimeoutOf(entry.syncProtocol),
59
63
  }),
60
64
  );
61
65
  const state = yield* EvmSyncState.restoreState(
@@ -123,6 +127,7 @@ export function* genSyncProtocols(
123
127
  url: entry.network.rpcUrl,
124
128
  username: entry.network.rpcAuth?.username ?? null,
125
129
  password: entry.network.rpcAuth?.password ?? null,
130
+ requestTimeoutMs: requestTimeoutOf(entry.syncProtocol),
126
131
  });
127
132
  const fetcher = new BitcoinFetcher(entry, rpcClient);
128
133
  const state = yield* BitcoinSyncState.restoreState(
@@ -151,6 +156,16 @@ export function* genSyncProtocols(
151
156
  fetcher,
152
157
  );
153
158
  result.push(state);
159
+ } else if (
160
+ entry.networkType === ConfigNetworkType.SOLANA
161
+ ) {
162
+ const fetcher = new SolanaFetcher(entry);
163
+ const state = yield* SolanaSyncState.restoreState(
164
+ dbConn,
165
+ entry,
166
+ fetcher,
167
+ );
168
+ result.push(state);
154
169
  } else if (
155
170
  entry.networkType === ConfigNetworkType.TEST
156
171
  ) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Regression guard for sync/CLAUDE.md Finding #3 (unpaced fetch loop).
3
+ *
4
+ * `orchestration/sync.ts` used to guard all three of its `sleep`s with
5
+ * `if ("pollingInterval" in config.syncProtocol)`, so a protocol whose config
6
+ * lacked that key never slept — not on error, and not in the ordinary "caught
7
+ * up" pass where `stateToInput` returns `undefined`.
8
+ *
9
+ * The Cardano/utxorpc schema was exactly that case: the only sync protocol that
10
+ * did not merge `PollingSyncProtocol`. Every config in this repo happened to
11
+ * pass `pollingInterval` anyway as an undeclared extra field, which is the only
12
+ * reason Cardano sync worked at all; a config built strictly from the schema
13
+ * produced a node that froze.
14
+ *
15
+ * "Froze", not "span": the probe showed the loop starving the event loop
16
+ * outright — a `setTimeout` scheduled for 500ms had still not fired after 4s of
17
+ * looping. Nothing else in the process runs in that state: not the HTTP server,
18
+ * not the other chains' fetch loops, and not the stream callbacks that would
19
+ * advance utxorpc's own tip and let the loop escape.
20
+ *
21
+ * Fixed on both sides: the utxorpc schema now merges `PollingSyncProtocol`, and
22
+ * `startSync` sleeps unconditionally with a fallback interval so no protocol
23
+ * can hot-loop regardless of what its schema declares. Both halves are asserted
24
+ * below — the second is what makes the first non-load-bearing.
25
+ */
26
+ import { expect, test } from "bun:test";
27
+ import { join } from "node:path";
28
+ import {
29
+ ConfigSyncProtocolSchemaCardanoUtxoRpcParallel,
30
+ ConfigSyncProtocolSchemaEvmParallel,
31
+ } from "@effectstream/config";
32
+
33
+ const PROBE = join(import.meta.dir, "spin-probe.ts");
34
+
35
+ /** Every property a protocol config may carry, required + optional. */
36
+ function schemaKeys(schema: unknown): string[] {
37
+ const config = (schema as { config: { required: any; optional: any } }).config;
38
+ return [
39
+ ...Object.keys(config.required.properties ?? {}),
40
+ ...Object.keys(config.optional.properties ?? {}),
41
+ ];
42
+ }
43
+
44
+ type ProbeResult = {
45
+ reason: "limit" | "window";
46
+ iterations: number;
47
+ elapsedMs: number;
48
+ };
49
+
50
+ async function runProbe(spec: {
51
+ polling: number | null;
52
+ limit: number;
53
+ windowMs: number;
54
+ mode: "sync" | "call";
55
+ }): Promise<ProbeResult> {
56
+ const proc = Bun.spawn(["bun", PROBE, JSON.stringify(spec)], {
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ });
60
+ const stdout = await new Response(proc.stdout).text();
61
+ await proc.exited;
62
+ const line = stdout.trim().split("\n").filter(Boolean).at(-1);
63
+ if (!line) {
64
+ const stderr = await new Response(proc.stderr).text();
65
+ throw new Error(`probe produced no result\n${stderr}`);
66
+ }
67
+ return JSON.parse(line) as ProbeResult;
68
+ }
69
+
70
+ test("every polling protocol declares pollingInterval, utxorpc included", () => {
71
+ const utxorpc = schemaKeys(ConfigSyncProtocolSchemaCardanoUtxoRpcParallel);
72
+ const evm = schemaKeys(ConfigSyncProtocolSchemaEvmParallel);
73
+
74
+ // The runtime fetch loop keys its sleeps off this exact property name.
75
+ expect(utxorpc).toContain("pollingInterval");
76
+ expect(evm).toContain("pollingInterval");
77
+ // Merged in from PollingSyncProtocol along with it.
78
+ expect(utxorpc).toContain("requestTimeoutMs");
79
+ });
80
+
81
+ test("with a pollingInterval the fetch loop is paced", async () => {
82
+ const result = await runProbe({
83
+ polling: 20,
84
+ limit: 200_000,
85
+ windowMs: 500,
86
+ mode: "call",
87
+ });
88
+
89
+ // The window timer fired, so the event loop stayed responsive.
90
+ expect(result.reason).toBe("window");
91
+ // ~500ms / 20ms ≈ 25 passes. Generous bound; the point is it's tens, not tens
92
+ // of thousands.
93
+ expect(result.iterations).toBeLessThan(200);
94
+ }, 30_000);
95
+
96
+ test("without a pollingInterval the fetch loop still paces itself", async () => {
97
+ const WINDOW_MS = 1_500;
98
+ const result = await runProbe({
99
+ polling: null,
100
+ limit: 200_000,
101
+ // Comfortably longer than one FALLBACK_POLLING_INTERVAL_MS (1s) so the
102
+ // paced loop gets at least one pass in before the window closes.
103
+ windowMs: WINDOW_MS,
104
+ mode: "call",
105
+ });
106
+
107
+ // Before the fix this reported `reason: "limit"` with 200k iterations and a
108
+ // 500ms timer that never fired at all.
109
+ expect(result.reason).toBe("window");
110
+ // One pass per fallback interval — single digits over this window.
111
+ expect(result.iterations).toBeLessThan(10);
112
+ }, 30_000);
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Repro for sync/CLAUDE.md Finding #4 (missing RPC timeouts).
3
+ *
4
+ * Every per-chain client is pointed at a **blackhole**: a TCP server that
5
+ * completes the handshake and then never writes a byte. This is the realistic
6
+ * production failure (a load balancer that drops the backend, a half-open
7
+ * socket after a NAT rebind) — and the one plain `fetch` cannot see, because
8
+ * `fetch` has no default timeout in Bun/Node. The connection is *up*, so there
9
+ * is no ECONNREFUSED, no DNS error, nothing to catch.
10
+ *
11
+ * Consequence in production: `readData` never returns, so the fetch loop in
12
+ * `orchestration/sync.ts` never reaches its `catch` → `consecutiveErrors` stays
13
+ * 0, `lastSuccessfulFetchMs` freezes, and the merge blocks on that chain's page
14
+ * forever. The node stops producing blocks and reports nothing.
15
+ *
16
+ * Every client now bounds a single request with `AbortSignal.timeout` via
17
+ * `sync-protocols/common/http.ts:fetchWithTimeout`, configured per protocol by
18
+ * the optional `requestTimeoutMs` schema field. Retry is deliberately left to
19
+ * the fetch loop, which already re-runs the same page range and counts the
20
+ * failure in `consecutiveErrors` — retrying inside the client would hide the
21
+ * error from the health endpoint.
22
+ *
23
+ * These tests are the permanent regression guard: each client must REJECT
24
+ * against a blackhole, never hang.
25
+ */
26
+ import { afterAll, beforeAll, expect, test } from "bun:test";
27
+ import net from "node:net";
28
+ import { BitcoinRpcClient } from "../src/sync-protocols/bitcoin/fetcher.ts";
29
+ import { NearClient } from "../src/sync-protocols/near/NearClient.ts";
30
+ import { SolanaClient } from "../src/sync-protocols/solana/SolanaClient.ts";
31
+ import { AvailClient } from "../src/sync-protocols/avail/AvailClient.ts";
32
+ import { MidnightClient } from "../src/sync-protocols/midnight/MidnightClient.ts";
33
+
34
+ /** How long we watch a call before declaring it hung. */
35
+ const OBSERVE_MS = 1_500;
36
+ /** Timeout handed to clients that support one, kept well under OBSERVE_MS. */
37
+ const CLIENT_TIMEOUT_MS = 300;
38
+
39
+ let server: net.Server;
40
+ let port: number;
41
+ /** Held so the accepted sockets aren't garbage-collected mid-test. */
42
+ const openSockets: net.Socket[] = [];
43
+
44
+ beforeAll(async () => {
45
+ server = net.createServer((socket) => {
46
+ // Accept and go silent: the client sees a healthy connection and waits
47
+ // for a response that never comes.
48
+ openSockets.push(socket);
49
+ });
50
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
51
+ port = (server.address() as net.AddressInfo).port;
52
+ });
53
+
54
+ afterAll(async () => {
55
+ for (const socket of openSockets) socket.destroy();
56
+ await new Promise<void>((resolve) => server.close(() => resolve()));
57
+ });
58
+
59
+ type Settlement = "pending" | "resolved" | "rejected";
60
+
61
+ /**
62
+ * Watch `p` for `ms` and report whether it settled. The promise is left with a
63
+ * no-op catch attached so a late rejection (after the test ends) can't surface
64
+ * as an unhandled rejection and fail an unrelated test.
65
+ */
66
+ async function settlementWithin(
67
+ p: Promise<unknown>,
68
+ ms: number,
69
+ ): Promise<Settlement> {
70
+ const pending = Symbol("pending");
71
+ let outcome: Settlement = "pending";
72
+ const tracked = p.then(
73
+ () => {
74
+ outcome = "resolved";
75
+ },
76
+ () => {
77
+ outcome = "rejected";
78
+ },
79
+ );
80
+ const winner = await Promise.race([
81
+ tracked.then(() => "settled" as const),
82
+ new Promise<typeof pending>((resolve) => setTimeout(() => resolve(pending), ms)),
83
+ ]);
84
+ return winner === pending ? "pending" : outcome;
85
+ }
86
+
87
+ test("Bitcoin RPC rejects on a blackholed endpoint", async () => {
88
+ const client = new BitcoinRpcClient({
89
+ url: `http://127.0.0.1:${port}`,
90
+ username: null,
91
+ password: null,
92
+ requestTimeoutMs: CLIENT_TIMEOUT_MS,
93
+ });
94
+
95
+ const settlement = await settlementWithin(client.getBlockCount(), OBSERVE_MS);
96
+
97
+ expect(settlement).toBe("rejected");
98
+ }, OBSERVE_MS + 5_000);
99
+
100
+ test("NEAR RPC rejects on a blackholed endpoint", async () => {
101
+ const client = new NearClient(`http://127.0.0.1:${port}`, CLIENT_TIMEOUT_MS);
102
+
103
+ const settlement = await settlementWithin(
104
+ client.getBlock({ finality: "final" }),
105
+ OBSERVE_MS,
106
+ );
107
+
108
+ expect(settlement).toBe("rejected");
109
+ }, OBSERVE_MS + 5_000);
110
+
111
+ test("Solana RPC rejects on a blackholed endpoint", async () => {
112
+ const client = new SolanaClient(`http://127.0.0.1:${port}`, CLIENT_TIMEOUT_MS);
113
+
114
+ const settlement = await settlementWithin(client.getSlot(), OBSERVE_MS);
115
+
116
+ expect(settlement).toBe("rejected");
117
+ }, OBSERVE_MS + 5_000);
118
+
119
+ test("Avail light-client HTTP rejects on a blackholed endpoint", async () => {
120
+ // Built without the constructor on purpose: `new AvailClient(...)` eagerly
121
+ // calls `SDK.New(nodeUrl)`, whose websocket provider keeps reconnect timers
122
+ // alive and would outlive the test process. `getStatus()` only touches the
123
+ // light-client HTTP url, which is the code path under test.
124
+ const client: AvailClient = Object.create(AvailClient.prototype);
125
+ Object.assign(client, {
126
+ url: `http://127.0.0.1:${port}`,
127
+ requestTimeoutMs: CLIENT_TIMEOUT_MS,
128
+ });
129
+
130
+ const settlement = await settlementWithin(client.getStatus(), OBSERVE_MS);
131
+
132
+ expect(settlement).toBe("rejected");
133
+ }, OBSERVE_MS + 5_000);
134
+
135
+ test("CONTROL: Midnight rejects on a blackholed endpoint (has a request timeout)", async () => {
136
+ const client = new MidnightClient(
137
+ `http://127.0.0.1:${port}`,
138
+ undefined,
139
+ CLIENT_TIMEOUT_MS,
140
+ );
141
+
142
+ const settlement = await settlementWithin(
143
+ client.gqlQuery("query { __typename }"),
144
+ OBSERVE_MS,
145
+ );
146
+
147
+ // MidnightClient.gqlQuery() passes AbortSignal.timeout(requestTimeoutMs).
148
+ // This is the shape the three clients above are missing.
149
+ expect(settlement).toBe("rejected");
150
+ }, OBSERVE_MS + 5_000);