@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.
- package/CLAUDE.md +82 -18
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +63 -4
- package/docs/ADDING-A-SYNC-PROTOCOL.md +16 -9
- package/package.json +8 -8
- package/src/sync-protocols/avail/AvailClient.ts +34 -6
- package/src/sync-protocols/avail/fetcher.ts +2 -0
- package/src/sync-protocols/avail/state.ts +2 -0
- package/src/sync-protocols/base/state.ts +51 -0
- package/src/sync-protocols/bitcoin/fetcher.ts +18 -10
- package/src/sync-protocols/celestia/CelestiaClient.ts +16 -2
- package/src/sync-protocols/celestia/fetcher.ts +5 -1
- package/src/sync-protocols/celestia/state.ts +5 -1
- package/src/sync-protocols/common/http.ts +77 -0
- package/src/sync-protocols/midnight/MidnightClient.ts +11 -3
- package/src/sync-protocols/midnight/fetcher.ts +18 -11
- package/src/sync-protocols/midnight/types.ts +1 -1
- package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
- package/src/sync-protocols/near/NearClient.ts +16 -6
- package/src/sync-protocols/near/fetcher.ts +5 -1
- package/src/sync-protocols/near/state.ts +5 -1
- package/src/sync-protocols/orchestration/sync.ts +101 -13
- package/src/sync-protocols/solana/SolanaClient.ts +188 -0
- package/src/sync-protocols/solana/fetcher.ts +456 -0
- package/src/sync-protocols/solana/program-logs.test.ts +113 -0
- package/src/sync-protocols/solana/program-logs.ts +84 -0
- package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
- package/src/sync-protocols/solana/read-primitives.test.ts +435 -0
- package/src/sync-protocols/solana/root-page.test.ts +66 -0
- package/src/sync-protocols/solana/state.ts +167 -0
- package/src/sync-protocols/solana/types.ts +58 -0
- package/src/sync-protocols/types.ts +2 -0
- package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
- package/src/sync-protocols/utxorpc/state.ts +3 -0
- package/src/syncProtocolFactory.ts +15 -0
- package/test/poll-loop-spin.test.ts +112 -0
- package/test/rpc-timeout.test.ts +150 -0
- package/test/spin-probe.ts +102 -0
- package/test/start-async-supervision.test.ts +139 -0
- package/test/zswap-decoder.test.ts +74 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, fetchWithTimeout } from "../common/http.ts";
|
|
2
|
+
|
|
1
3
|
// ============================
|
|
2
4
|
// NEAR JSON-RPC type responses
|
|
3
5
|
// ============================
|
|
@@ -107,17 +109,25 @@ export function parseNep297Log(log: string): Nep297Event | null {
|
|
|
107
109
|
|
|
108
110
|
export class NearClient {
|
|
109
111
|
private readonly rpcUrl: string;
|
|
112
|
+
/** Per-request deadline; see `sync-protocols/common/http.ts`. */
|
|
113
|
+
private readonly requestTimeoutMs: number;
|
|
110
114
|
|
|
111
|
-
constructor(rpcUrl: string) {
|
|
115
|
+
constructor(rpcUrl: string, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
112
116
|
this.rpcUrl = rpcUrl;
|
|
117
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
private async rpc<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
|
116
|
-
const res = await
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
const res = await fetchWithTimeout(
|
|
122
|
+
this.rpcUrl,
|
|
123
|
+
{
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
127
|
+
},
|
|
128
|
+
`NEAR ${method}`,
|
|
129
|
+
this.requestTimeoutMs,
|
|
130
|
+
);
|
|
121
131
|
|
|
122
132
|
const json = await res.json();
|
|
123
133
|
if (json.error) {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
} from "@effectstream/config";
|
|
5
5
|
import { BaseDataFetcher } from "../base/fetcher.ts";
|
|
6
6
|
import type { DataFetched } from "../base/fetcher.ts";
|
|
7
|
+
import { requestTimeoutOf } from "../common/http.ts";
|
|
7
8
|
import type {
|
|
8
9
|
LastPage,
|
|
9
10
|
OutputAndCleanup,
|
|
@@ -39,7 +40,10 @@ export class NearFetcher extends BaseDataFetcher<
|
|
|
39
40
|
readonly config: ConfigType,
|
|
40
41
|
) {
|
|
41
42
|
super(config.syncProtocol.name);
|
|
42
|
-
this.client = new NearClient(
|
|
43
|
+
this.client = new NearClient(
|
|
44
|
+
config.network.rpcUrl,
|
|
45
|
+
requestTimeoutOf(config.syncProtocol),
|
|
46
|
+
);
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
@bound
|
|
@@ -11,6 +11,7 @@ import { getPage } from "@effectstream/db";
|
|
|
11
11
|
import { NearClient } from "./NearClient.ts";
|
|
12
12
|
import { applyDelay } from "../common/utils.ts";
|
|
13
13
|
import { bufferAtCap } from "../common/page-helpers.ts";
|
|
14
|
+
import { requestTimeoutOf } from "../common/http.ts";
|
|
14
15
|
|
|
15
16
|
export class NearSyncState extends SyncState<
|
|
16
17
|
Input,
|
|
@@ -134,7 +135,10 @@ export class NearSyncState extends SyncState<
|
|
|
134
135
|
page,
|
|
135
136
|
config,
|
|
136
137
|
fetcher,
|
|
137
|
-
new NearClient(
|
|
138
|
+
new NearClient(
|
|
139
|
+
config.network.rpcUrl,
|
|
140
|
+
requestTimeoutOf(config.syncProtocol),
|
|
141
|
+
),
|
|
138
142
|
dbConn,
|
|
139
143
|
);
|
|
140
144
|
}
|
|
@@ -4,19 +4,113 @@ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
|
4
4
|
import type { AllSyncProtocols, ISyncProtocol } from "../types.ts";
|
|
5
5
|
import { tryYield } from "@effectstream/utils";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Used when a protocol config carries no `pollingInterval`.
|
|
9
|
+
*
|
|
10
|
+
* Every polling protocol declares one via `PollingSyncProtocol`, so this should
|
|
11
|
+
* be unreachable — it exists because the cost of getting it wrong is not a slow
|
|
12
|
+
* loop but a dead node. A pass through this loop that neither fetches nor
|
|
13
|
+
* sleeps never yields to the macrotask queue, which starves the entire process:
|
|
14
|
+
* no timers, no HTTP server, no other chain's fetch loop, and (for streaming
|
|
15
|
+
* chains) none of the stream callbacks that would let this loop make progress.
|
|
16
|
+
* See `sync/test/poll-loop-spin.test.ts`.
|
|
17
|
+
*/
|
|
18
|
+
const FALLBACK_POLLING_INTERVAL_MS = 1_000;
|
|
19
|
+
|
|
20
|
+
/** This protocol's polling interval, or the fallback above. */
|
|
21
|
+
function pollingIntervalOf(config: { syncProtocol: unknown }): number {
|
|
22
|
+
const syncProtocol = config.syncProtocol as { pollingInterval?: number };
|
|
23
|
+
return syncProtocol?.pollingInterval ?? FALLBACK_POLLING_INTERVAL_MS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Backoff bounds for restarting a dead streaming producer. */
|
|
27
|
+
const PRODUCER_MIN_BACKOFF_MS = 1_000;
|
|
28
|
+
const PRODUCER_MAX_BACKOFF_MS = 30_000;
|
|
29
|
+
|
|
7
30
|
export function* startSync(
|
|
8
31
|
state: AllSyncProtocols,
|
|
9
32
|
): Operation<void> {
|
|
10
33
|
const iState: ISyncProtocol = state as ISyncProtocol;
|
|
11
34
|
|
|
12
|
-
//
|
|
35
|
+
// Resolve once and publish it, so `/health` and anything else reads the same
|
|
36
|
+
// value this loop paces itself with instead of re-deriving it.
|
|
37
|
+
state.pollingIntervalMs = pollingIntervalOf(state.fetcher.config);
|
|
38
|
+
|
|
13
39
|
yield* spawn(function* () {
|
|
14
|
-
|
|
40
|
+
if (!iState.hasAsyncProducer) {
|
|
41
|
+
// Polled chain: startAsync is the base-class no-op. Run it once, as
|
|
42
|
+
// before — supervising it would mean restarting a no-op forever.
|
|
43
|
+
yield* iState.startAsync();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Streaming chain: all of this protocol's data arrives through the
|
|
48
|
+
// producer, so if it dies the chain goes silent and the merge blocks on its
|
|
49
|
+
// page forever. Both of its exit modes used to be unhandled — a throw tore
|
|
50
|
+
// down the enclosing scope (in production, `start()`, taking every other
|
|
51
|
+
// chain with it), and a clean return went entirely unnoticed. Supervise
|
|
52
|
+
// both. See `sync/test/start-async-supervision.test.ts`.
|
|
53
|
+
let attempt = 0;
|
|
54
|
+
while (true) {
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
const result = yield* tryYield(iState.startAsync());
|
|
57
|
+
const ranForMs = Date.now() - startedAt;
|
|
58
|
+
|
|
59
|
+
if (result.error != null) {
|
|
60
|
+
// Producer failures are tracked separately from `consecutiveErrors`.
|
|
61
|
+
// That counter belongs to the fetch loop and is only cleared by a
|
|
62
|
+
// successful `readData`, so bumping it here left an idle streaming chain
|
|
63
|
+
// reporting `erroring` until its next block arrived — and conversely, a
|
|
64
|
+
// successful poll would erase the record of a flapping producer.
|
|
65
|
+
state.producerErrors++;
|
|
66
|
+
state.lastProducerErrorMs = Date.now();
|
|
67
|
+
log.remote(
|
|
68
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
69
|
+
[...state.getNamespace(), "startAsync"],
|
|
70
|
+
SeverityNumber.ERROR,
|
|
71
|
+
(l) => l(result.error),
|
|
72
|
+
);
|
|
73
|
+
} else {
|
|
74
|
+
// Returning is a failure for a producer: its stream ended. Treated the
|
|
75
|
+
// same as a throw, because the observable effect is identical — no
|
|
76
|
+
// more data.
|
|
77
|
+
log.remote(
|
|
78
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
79
|
+
[...state.getNamespace(), "startAsync"],
|
|
80
|
+
SeverityNumber.WARN,
|
|
81
|
+
(l) => l("producer returned: stream ended, restarting"),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
state.producerRestarts++;
|
|
86
|
+
|
|
87
|
+
// De-escalate after a producer that clearly worked. Without this `attempt`
|
|
88
|
+
// only ever grows, so a stream that ran healthily for a day between
|
|
89
|
+
// incidents would still be waiting the 30s cap on its next restart —
|
|
90
|
+
// penalising a healthy chain for ancient history. (It also keeps
|
|
91
|
+
// `2 ** attempt` from reaching Infinity after ~1024 restarts, which
|
|
92
|
+
// `Math.min` currently absorbs silently.)
|
|
93
|
+
if (ranForMs >= PRODUCER_MAX_BACKOFF_MS) {
|
|
94
|
+
attempt = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const backoff = Math.min(
|
|
98
|
+
PRODUCER_MIN_BACKOFF_MS * 2 ** attempt,
|
|
99
|
+
PRODUCER_MAX_BACKOFF_MS,
|
|
100
|
+
);
|
|
101
|
+
attempt++;
|
|
102
|
+
yield* sleep(backoff);
|
|
103
|
+
}
|
|
15
104
|
});
|
|
16
105
|
|
|
17
106
|
// spawn polling task
|
|
18
107
|
yield* spawn(function* () {
|
|
19
108
|
while (true) {
|
|
109
|
+
// Liveness heartbeat: stamped every pass regardless of outcome, so a
|
|
110
|
+
// stale value means the loop is wedged inside a call that never returns
|
|
111
|
+
// rather than merely idle. See SyncState.lastPollAtMs.
|
|
112
|
+
state.lastPollAtMs = Date.now();
|
|
113
|
+
|
|
20
114
|
const inputResult = yield* tryYield(iState.stateToInput());
|
|
21
115
|
if (inputResult.error != null) {
|
|
22
116
|
state.consecutiveErrors++;
|
|
@@ -27,18 +121,14 @@ export function* startSync(
|
|
|
27
121
|
SeverityNumber.ERROR,
|
|
28
122
|
(log) => log(inputResult.error),
|
|
29
123
|
);
|
|
30
|
-
|
|
31
|
-
if ("pollingInterval" in config.syncProtocol) {
|
|
32
|
-
yield* sleep(config.syncProtocol.pollingInterval);
|
|
33
|
-
}
|
|
124
|
+
yield* sleep(state.pollingIntervalMs);
|
|
34
125
|
continue;
|
|
35
126
|
}
|
|
36
127
|
const input = inputResult.data;
|
|
37
|
-
const { config } = state.fetcher;
|
|
38
128
|
if (input == null) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
129
|
+
// Caught up. This is the ordinary steady state, and the pass that must
|
|
130
|
+
// never skip its sleep — see FALLBACK_POLLING_INTERVAL_MS.
|
|
131
|
+
yield* sleep(state.pollingIntervalMs);
|
|
42
132
|
continue;
|
|
43
133
|
}
|
|
44
134
|
|
|
@@ -52,9 +142,7 @@ export function* startSync(
|
|
|
52
142
|
SeverityNumber.ERROR,
|
|
53
143
|
(l) => l(result.error),
|
|
54
144
|
);
|
|
55
|
-
|
|
56
|
-
yield* sleep(config.syncProtocol.pollingInterval);
|
|
57
|
-
}
|
|
145
|
+
yield* sleep(state.pollingIntervalMs);
|
|
58
146
|
continue;
|
|
59
147
|
}
|
|
60
148
|
state.consecutiveErrors = 0;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
3
|
+
fetchWithTimeout,
|
|
4
|
+
} from "../common/http.ts";
|
|
5
|
+
|
|
6
|
+
// ==========================
|
|
7
|
+
// Solana JSON-RPC type defs
|
|
8
|
+
// ==========================
|
|
9
|
+
|
|
10
|
+
export type SolanaBlock = {
|
|
11
|
+
blockhash: string;
|
|
12
|
+
blockTime: number | null;
|
|
13
|
+
blockHeight: number | null;
|
|
14
|
+
parentSlot: number;
|
|
15
|
+
previousBlockhash: string;
|
|
16
|
+
transactions: SolanaTransaction[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SolanaTransaction = {
|
|
20
|
+
transaction: {
|
|
21
|
+
message: {
|
|
22
|
+
accountKeys: string[];
|
|
23
|
+
instructions: SolanaInstruction[];
|
|
24
|
+
};
|
|
25
|
+
signatures: string[];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Null when the RPC could not decode the transaction (e.g. a version newer
|
|
29
|
+
* than `maxSupportedTransactionVersion`). Callers must null-check.
|
|
30
|
+
*/
|
|
31
|
+
meta: {
|
|
32
|
+
err: unknown | null;
|
|
33
|
+
logMessages: string[] | null;
|
|
34
|
+
preBalances: number[];
|
|
35
|
+
postBalances: number[];
|
|
36
|
+
/**
|
|
37
|
+
* Addresses a versioned (v0) transaction pulled in through an address
|
|
38
|
+
* lookup table. These are NOT in `message.accountKeys`, which carries only
|
|
39
|
+
* static keys — but `pre`/`postBalances` are indexed over the full list.
|
|
40
|
+
* See {@link resolveAccountKeys} for the ordering.
|
|
41
|
+
*/
|
|
42
|
+
loadedAddresses?: {
|
|
43
|
+
writable: string[];
|
|
44
|
+
readonly: string[];
|
|
45
|
+
} | null;
|
|
46
|
+
/**
|
|
47
|
+
* SPL token balances after the transaction, one record per (token account,
|
|
48
|
+
* mint) pair it touched. Returned by `getBlock` with
|
|
49
|
+
* `transactionDetails: "full"`; absent on transactions that touched no token
|
|
50
|
+
* account, and on validators old enough not to report them.
|
|
51
|
+
*
|
|
52
|
+
* `accountIndex` indexes the SAME resolved list as `pre`/`postBalances` — see
|
|
53
|
+
* {@link resolveAccountKeys} — so a token account reached through a lookup
|
|
54
|
+
* table is only findable after resolution.
|
|
55
|
+
*/
|
|
56
|
+
postTokenBalances?: SolanaTokenBalance[] | null;
|
|
57
|
+
/** Pre-state counterpart of {@link postTokenBalances}. */
|
|
58
|
+
preTokenBalances?: SolanaTokenBalance[] | null;
|
|
59
|
+
} | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type SolanaTokenBalance = {
|
|
63
|
+
/** Index into the resolved account list, NOT into `message.accountKeys` alone. */
|
|
64
|
+
accountIndex: number;
|
|
65
|
+
mint: string;
|
|
66
|
+
/** Optional: older validators omit it. */
|
|
67
|
+
owner?: string;
|
|
68
|
+
/** The owning token program, i.e. SPL Token or Token-2022. Optional for the same reason. */
|
|
69
|
+
programId?: string;
|
|
70
|
+
uiTokenAmount: {
|
|
71
|
+
/** Raw u64 in base units, as a string. */
|
|
72
|
+
amount: string;
|
|
73
|
+
decimals: number;
|
|
74
|
+
uiAmount: number | null;
|
|
75
|
+
uiAmountString?: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The account list `pre`/`postBalances` are indexed against: static message
|
|
81
|
+
* keys first, then lookup-table writable addresses, then lookup-table readonly
|
|
82
|
+
* ones. Legacy transactions have no `loadedAddresses`, so this is just the
|
|
83
|
+
* static keys.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveAccountKeys(
|
|
86
|
+
accountKeys: string[],
|
|
87
|
+
loadedAddresses?: { writable: string[]; readonly: string[] } | null,
|
|
88
|
+
): string[] {
|
|
89
|
+
if (!loadedAddresses) return accountKeys;
|
|
90
|
+
return [
|
|
91
|
+
...accountKeys,
|
|
92
|
+
...(loadedAddresses.writable ?? []),
|
|
93
|
+
...(loadedAddresses.readonly ?? []),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type SolanaInstruction = {
|
|
98
|
+
programId: string;
|
|
99
|
+
accounts: string[];
|
|
100
|
+
data: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ===========
|
|
104
|
+
// RPC Client
|
|
105
|
+
// ===========
|
|
106
|
+
|
|
107
|
+
export class SolanaClient {
|
|
108
|
+
private readonly rpcUrl: string;
|
|
109
|
+
/** Per-request deadline; see `sync-protocols/common/http.ts`. */
|
|
110
|
+
private readonly requestTimeoutMs: number;
|
|
111
|
+
|
|
112
|
+
constructor(rpcUrl: string, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
113
|
+
this.rpcUrl = rpcUrl;
|
|
114
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async rpc<T>(
|
|
118
|
+
method: string,
|
|
119
|
+
params: unknown[] = [],
|
|
120
|
+
): Promise<T> {
|
|
121
|
+
// Bounded: a blackholed endpoint would otherwise hang readData forever,
|
|
122
|
+
// freezing block production with every health counter clean (sync
|
|
123
|
+
// CLAUDE.md finding #4).
|
|
124
|
+
const res = await fetchWithTimeout(
|
|
125
|
+
this.rpcUrl,
|
|
126
|
+
{
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
jsonrpc: "2.0",
|
|
131
|
+
id: 1,
|
|
132
|
+
method,
|
|
133
|
+
params,
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
`Solana ${method}`,
|
|
137
|
+
this.requestTimeoutMs,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const json = await res.json();
|
|
141
|
+
if (json.error) {
|
|
142
|
+
const err = new Error(
|
|
143
|
+
`[Solana] RPC error [${method}]: ${json.error.message ?? JSON.stringify(json.error)}`,
|
|
144
|
+
) as Error & { rpcCode?: number };
|
|
145
|
+
// Preserve the JSON-RPC code so callers can branch on it instead of
|
|
146
|
+
// pattern-matching human-readable messages.
|
|
147
|
+
err.rpcCode = typeof json.error.code === "number" ? json.error.code : undefined;
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
return json.result as T;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async getSlot(): Promise<number> {
|
|
154
|
+
return this.rpc<number>("getSlot", [
|
|
155
|
+
{ commitment: "confirmed" },
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getBlock(
|
|
160
|
+
slot: number,
|
|
161
|
+
): Promise<SolanaBlock | null> {
|
|
162
|
+
try {
|
|
163
|
+
return await this.rpc<SolanaBlock | null>("getBlock", [
|
|
164
|
+
slot,
|
|
165
|
+
{
|
|
166
|
+
encoding: "json",
|
|
167
|
+
transactionDetails: "full",
|
|
168
|
+
rewards: false,
|
|
169
|
+
maxSupportedTransactionVersion: 0,
|
|
170
|
+
commitment: "confirmed",
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
// A skipped slot is normal on Solana: no block was produced. Branch on the
|
|
175
|
+
// JSON-RPC code rather than the message text —
|
|
176
|
+
// -32007 SLOT_SKIPPED, -32009 LONG_TERM_STORAGE_SLOT_SKIPPED.
|
|
177
|
+
// Deliberately NOT -32004 (block not available yet): that is a transient
|
|
178
|
+
// "ask again" and must keep throwing so the fetcher retries rather than
|
|
179
|
+
// treating the slot as permanently empty.
|
|
180
|
+
const code = (e as { rpcCode?: number }).rpcCode;
|
|
181
|
+
if (code === -32007 || code === -32009) return null;
|
|
182
|
+
// Fall back to the message for RPCs that omit or remap the code.
|
|
183
|
+
if (e instanceof Error && e.message.includes("was skipped")) return null;
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
}
|