@hisoka-io/anon-rpc-worker 0.2.0 → 0.3.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.
@@ -1 +1 @@
1
- 0x0a58f9915f686950072a4786249d396ecbf2194a39ac835effe8ea1a7c76f324 anon-rpc-worker.js
1
+ 0x24604525d220bcc7e39f2dbc22966a814600a63ada51baafad5dd0bcc1d28549 anon-rpc-worker.js
@@ -3,25 +3,25 @@
3
3
  "arch": "x64",
4
4
  "os": "linux"
5
5
  },
6
- "gitCommit": "ef019a716d7994820d7d63d6de3e60ae8f71e80d",
6
+ "gitCommit": "6bddd851c5b1f4e5508f4718e2ea9b4db626b17f",
7
7
  "gitTreeClean": true,
8
8
  "inputs": {
9
9
  "bootstrapKeccak": "0x6567e3ad5e1b1ac81529391c890d2652695d3ec139d56625170384794085fb74",
10
- "lockfileSha256": "0398a97b0a813b2a5305a745bac0233cea4bd9c8c4a3b084873d0e091383a825",
10
+ "lockfileSha256": "0a205a04cd3b6d861b8fedef8b8e6cd6356642e5f2cd9752ce0e2f403fbae426",
11
11
  "snapshotBlock": 316207920,
12
12
  "snapshotKeccak": "0xa351960e2c268b9665d1aabec87feaa132d30ade773aef64ef495ec25c94fbb2",
13
13
  "wasmKeccak": "0x6ff418a78fc022d34dff00a867e173269413fb6887c9d6bf8e6505d4dcfffe2d",
14
14
  "wasmSha256": "5171416972ea4499fd4b4ea8bcadf002f4c10f10ed42cafb0d5a426121da9c4f"
15
15
  },
16
16
  "output": {
17
- "bytes": 924847,
18
- "keccak256": "0x0a58f9915f686950072a4786249d396ecbf2194a39ac835effe8ea1a7c76f324",
19
- "sha256": "479f10a34d113a551312f3adf2e6952f3ab8bc7a4ff98c470e13f9543a44131f"
17
+ "bytes": 961799,
18
+ "keccak256": "0x24604525d220bcc7e39f2dbc22966a814600a63ada51baafad5dd0bcc1d28549",
19
+ "sha256": "0458b19258dffcfb9ce23d584096f3df06532080390d34ee71b201b6b78dbbac"
20
20
  },
21
21
  "package": "@hisoka-io/anon-rpc-worker",
22
22
  "toolchain": {
23
23
  "esbuild": "0.27.3",
24
- "node": "v25.5.0",
24
+ "node": "v22.23.3",
25
25
  "pins": {
26
26
  "BINARYEN_SHA256_AARCH64": "ad560204426015a815faa45693c83bef7d58677d38a39422c272a30ba4b6da2a",
27
27
  "BINARYEN_SHA256_X86_64": "3dc677006555b355ea2da5e82602065a161d5e83eaefd3f759afa00b96e83212",
@@ -44,5 +44,5 @@
44
44
  "wasmPack": "0.13.1",
45
45
  "wasmPinsChecked": true
46
46
  },
47
- "version": "0.2.0"
47
+ "version": "0.3.0"
48
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hisoka-io/anon-rpc-worker",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Nox mixnet worker for the anon-rpc standard, built as one reproducible classic-script bundle with a pinned NoxRegistry snapshot",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "ethers": "6.16.0",
26
26
  "typescript": "5.9.3",
27
27
  "vitest": "1.6.1",
28
- "@hisoka-io/nox-client": "0.6.0",
28
+ "@hisoka-io/nox-client": "0.7.0",
29
29
  "@hisoka-io/nox-wasm": "0.2.0"
30
30
  },
31
31
  "publishConfig": {
package/src/config.ts CHANGED
@@ -31,6 +31,12 @@ export interface NoxWorkerConfig {
31
31
  readonly logLevel: LogLevel;
32
32
  /** Per attempt for small and medium reads. */
33
33
  readonly attemptTimeoutMs: number;
34
+ /**
35
+ * Resendable reads: a second copy goes out on another route when no reply
36
+ * arrived after this long (raised to the observed p95 reply time); the
37
+ * first reply wins. 0 turns hedging off.
38
+ */
39
+ readonly hedgeAfterMs: number;
34
40
  /** Whole call, from acceptance: waiting for ready, sending, attempts. */
35
41
  readonly callDeadlineMs: number;
36
42
  readonly maxConcurrentCalls: number;
@@ -55,6 +61,7 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
55
61
  trust: "auto",
56
62
  logLevel: "info",
57
63
  attemptTimeoutMs: 12_000,
64
+ hedgeAfterMs: 3_000,
58
65
  callDeadlineMs: 25_000,
59
66
  maxConcurrentCalls: 16,
60
67
  maxRequestBytes: 1_048_576,
@@ -69,6 +76,7 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
69
76
  /** Inclusive integer ranges of the numeric fields. */
70
77
  export const CONFIG_RANGES = Object.freeze({
71
78
  attemptTimeoutMs: [3_000, 60_000],
79
+ hedgeAfterMs: [0, 60_000],
72
80
  callDeadlineMs: [3_000, 120_000],
73
81
  maxConcurrentCalls: [1, 64],
74
82
  maxRequestBytes: [1_024, 4_194_304],
package/src/core.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * learned-anchor cache.
16
16
  */
17
17
  import {
18
+ DISCOVERY_FIRST_CHECK_MAX_DEFER_MS,
19
+ RESEND_FAST,
18
20
  eligiblePinnedMembers,
19
21
  pinnedKpsAddresses,
20
22
  verifyBootstrap,
@@ -40,7 +42,15 @@ import {
40
42
  type BootRetryCode,
41
43
  type FailedCode,
42
44
  } from "./errors.js";
43
- import { mapClientError, prepareRequest, sendPrepared, type CallBudget, type NoxHttpPort } from "./fetch-map.js";
45
+ import {
46
+ mapClientError,
47
+ prepareRequest,
48
+ sendPrepared,
49
+ type CallBudget,
50
+ type NoxHttpPort,
51
+ type PreparedRequest,
52
+ } from "./fetch-map.js";
53
+ import { LocalAnswers } from "./local-answers.js";
44
54
  import { createLogger, describeError, errorCode, type LogLevel, type WorkerLogger } from "./log.js";
45
55
  import {
46
56
  LearnedCacheWriter,
@@ -148,6 +158,7 @@ class NoxWorker {
148
158
  private cacheTimer: ReturnType<typeof setInterval> | undefined;
149
159
  private learnedWriter: LearnedCacheWriter | undefined;
150
160
  private callSeq = 0;
161
+ private readonly answers = new LocalAnswers();
151
162
  private readonly now: () => number;
152
163
  private readonly random: () => number;
153
164
  private readonly sleep: (ms: number) => Promise<void>;
@@ -291,11 +302,20 @@ class NoxWorker {
291
302
  log: this.sdkLogSink(),
292
303
  timeoutMs: cfg.attemptTimeoutMs,
293
304
  surbFormat: cfg.surbFormat,
305
+ // Interactive latency: hedge at about p95, resend at once on a lost
306
+ // reply, transport resends of their own, same-entry resends for a
307
+ // single bridge (ARCHITECTURE §4.7).
308
+ resend: { ...RESEND_FAST, hedgeAfterMs: cfg.hedgeAfterMs },
294
309
  kps: {
295
310
  dial: kpsDial,
296
311
  pinned,
297
312
  topologySources: cfg.topologySources,
298
313
  claimIntervalMs: cfg.claimIntervalMs,
314
+ // A warm standby entry. Claims share the primary connection: a second
315
+ // claim connection per entry measured mixed live and doubles per-IP
316
+ // connections at the relay.
317
+ claimLane: false,
318
+ standby: true,
299
319
  discovery: this.discoveryOptions(cfg, bootstrap, learned),
300
320
  ...(deprioritize.length === 0 ? {} : { deprioritize }),
301
321
  },
@@ -340,6 +360,8 @@ class NoxWorker {
340
360
  return {
341
361
  bootstrap,
342
362
  chain: cfg.discovery === "chain",
363
+ // Wallet calls first: the first registry check waits for the first call (bounded).
364
+ firstCheckDeferMs: DISCOVERY_FIRST_CHECK_MAX_DEFER_MS,
343
365
  onVerified: (state) => this.onVerified(state),
344
366
  ...(cfg.gateways === undefined ? {} : { gateways: cfg.gateways }),
345
367
  ...(cfg.bridges === undefined ? {} : { bridges: cfg.bridges }),
@@ -447,8 +469,20 @@ class NoxWorker {
447
469
  const budget = new CallControl(call.requestInit?.signal, cfg.callDeadlineMs, this.now);
448
470
  try {
449
471
  const prepared = await prepareRequest(call.url, call.requestInit, cfg, budget.signal);
472
+ const local = this.answers.lookup(prepared);
473
+ if (local !== undefined) {
474
+ this.log.debug("call.done", {
475
+ seq,
476
+ jsonrpcMethod: prepared.profile.method ?? null,
477
+ ms: this.now() - started,
478
+ outcome: "ok",
479
+ local: local.kind,
480
+ });
481
+ return local.response;
482
+ }
450
483
  const client = await this.waitReady(budget.signal);
451
484
  const response = await sendPrepared(prepared, client, cfg, budget);
485
+ if (this.answers.observe(prepared, response) === "verify") this.verifyAnswer(prepared, client, cfg);
452
486
  if (this.log.enabled("debug")) {
453
487
  this.log.debug("call.done", {
454
488
  seq,
@@ -470,6 +504,25 @@ class NoxWorker {
470
504
  }
471
505
  }
472
506
 
507
+ /**
508
+ * Send a memo call (`eth_chainId`, `net_version`) once more through the
509
+ * mixnet on a fresh route, in the background; the answer is kept only when
510
+ * both agree (see `LocalAnswers`).
511
+ */
512
+ private verifyAnswer(prepared: PreparedRequest, client: NoxClientPort, cfg: NoxWorkerConfig): void {
513
+ const budget = new CallControl(undefined, cfg.callDeadlineMs, this.now);
514
+ sendPrepared(prepared, client, cfg, budget)
515
+ .then(
516
+ (response) => {
517
+ this.answers.observe(prepared, response);
518
+ },
519
+ (error: unknown) => {
520
+ this.log.debug("local.verify.failed", { code: errorCode(mapClientError(error)) ?? "error" });
521
+ },
522
+ )
523
+ .finally(() => budget.dispose());
524
+ }
525
+
473
526
  private waitReady(signal: AbortSignal): Promise<NoxClientPort> {
474
527
  if (this.client !== undefined) return Promise.resolve(this.client);
475
528
  if (this.failed || this.stopped) {
package/src/fetch-map.ts CHANGED
@@ -32,6 +32,24 @@ export interface FetchSettings {
32
32
  readonly attemptTimeoutMs: number;
33
33
  readonly maxRequestBytes: number;
34
34
  readonly maxResponseBytes: number;
35
+ /**
36
+ * `accept-encoding` sent upstream. Default: `gzip` where the runtime has
37
+ * `DecompressionStream`, else `identity`.
38
+ */
39
+ readonly acceptEncoding?: ReplyEncoding;
40
+ }
41
+
42
+ /** Reply encodings the worker can ask upstreams for. */
43
+ export type ReplyEncoding = "gzip" | "identity";
44
+
45
+ /**
46
+ * `gzip` when this runtime can inflate replies itself. Exits pass bodies
47
+ * through as the upstream sent them (their HTTP client has no decompression),
48
+ * so a gzip reply crosses the mixnet compressed: a 414 KB `eth_getLogs` reply
49
+ * takes about 2 reply packets instead of 14.
50
+ */
51
+ export function defaultReplyEncoding(): ReplyEncoding {
52
+ return typeof DecompressionStream === "function" ? "gzip" : "identity";
35
53
  }
36
54
 
37
55
  /** A validated request, ready for the mixnet. */
@@ -74,7 +92,7 @@ const CROSS_ORIGIN_DROPPED_HEADERS = new Set(["authorization", "cookie"]);
74
92
  * Request headers never forwarded: the exit drops `host` and `user-agent`
75
93
  * itself, framing and hop-by-hop fields belong to the exit's own connection,
76
94
  * and cookies, origin and referer would tie calls together or to a page.
77
- * `accept-encoding` is replaced (see `FORCED_REQUEST_HEADERS`).
95
+ * `accept-encoding` is replaced (see `defaultReplyEncoding`).
78
96
  */
79
97
  const DROPPED_REQUEST_HEADERS = new Set([
80
98
  "host",
@@ -93,8 +111,8 @@ const DROPPED_REQUEST_HEADERS = new Set([
93
111
  "accept-encoding",
94
112
  ]);
95
113
 
96
- /** The exit's HTTP client has no decompression, so bodies must come back as sent. */
97
- const FORCED_REQUEST_HEADERS: readonly [string, string][] = [["accept-encoding", "identity"]];
114
+ /** Content codings the worker inflates. */
115
+ const GZIP_CODINGS: ReadonlySet<string> = new Set(["gzip", "x-gzip"]);
98
116
 
99
117
  /** Response fields that describe the exit's hop, or that a worker must not hand on. */
100
118
  const DROPPED_RESPONSE_HEADERS = new Set([
@@ -126,7 +144,7 @@ export async function prepareRequest(
126
144
  if (redirect !== "follow" && redirect !== "manual" && redirect !== "error") {
127
145
  throw callError(CALL_CODES.unsupported, 'redirect must be "follow", "manual" or "error"');
128
146
  }
129
- const headers = prepareHeaders(init?.headers);
147
+ const headers = prepareHeaders(init?.headers, settings.acceptEncoding ?? defaultReplyEncoding());
130
148
  const body = await readBody(init?.body, settings.maxRequestBytes, signal);
131
149
  if ((method === "GET" || method === "HEAD") && body.length > 0) {
132
150
  throw callError(CALL_CODES.unsupported, `A ${method} request cannot carry a body`);
@@ -180,7 +198,7 @@ export async function sendPrepared(
180
198
  const reply = await exchange(current, port, settings, budget);
181
199
  checkStatus(reply);
182
200
  if (!REDIRECT_STATUSES.has(reply.status) || current.redirect === "manual") {
183
- return toAnonResponse(reply, current, settings);
201
+ return finishResponse(reply, current, settings);
184
202
  }
185
203
  if (current.redirect === "error") {
186
204
  throw callError(
@@ -190,9 +208,9 @@ export async function sendPrepared(
190
208
  }
191
209
  const location = reply.headers.find(([name]) => name.toLowerCase() === "location")?.[1];
192
210
  // fetch returns a redirect status without a Location as an ordinary response.
193
- if (location === undefined) return toAnonResponse(reply, current, settings);
211
+ if (location === undefined) return finishResponse(reply, current, settings);
194
212
  const next = redirectedRequest(current, reply.status, location);
195
- if (next === null) return toAnonResponse(reply, current, settings);
213
+ if (next === null) return finishResponse(reply, current, settings);
196
214
  if (hop + 1 > MAX_REDIRECT_HOPS) {
197
215
  throw callError(CALL_CODES.networkError, `Too many redirects: more than ${MAX_REDIRECT_HOPS} hops`);
198
216
  }
@@ -290,6 +308,67 @@ function checkStatus(reply: DecodedHttpResponse): void {
290
308
  }
291
309
  }
292
310
 
311
+ /** Inflate a gzip reply (see `inflateReply`), then map it with `toAnonResponse`. */
312
+ export async function finishResponse(
313
+ reply: DecodedHttpResponse,
314
+ request: Pick<PreparedRequest, "url">,
315
+ settings: Pick<FetchSettings, "maxResponseBytes">,
316
+ ): Promise<AnonFetchResponse> {
317
+ checkStatus(reply);
318
+ if (reply.truncated) {
319
+ throw callError(CALL_CODES.tooLarge, "The exit cut the response body at its response size limit");
320
+ }
321
+ return toAnonResponse(await inflateReply(reply, settings.maxResponseBytes), request, settings);
322
+ }
323
+
324
+ /**
325
+ * The reply with a `gzip` body inflated and its `content-encoding` removed;
326
+ * any other reply unchanged. Inflation stops past `maxBytes` (`too-large`),
327
+ * so a small compressed reply cannot expand without bound; a corrupt body is
328
+ * a `protocol-error`.
329
+ */
330
+ export async function inflateReply(reply: DecodedHttpResponse, maxBytes: number): Promise<DecodedHttpResponse> {
331
+ const coding = reply.headers.find(([name]) => name.toLowerCase() === "content-encoding")?.[1]?.trim().toLowerCase();
332
+ if (coding === undefined || !GZIP_CODINGS.has(coding)) return reply;
333
+ const headers = reply.headers.filter(([name]) => name.toLowerCase() !== "content-encoding");
334
+ if (reply.body.length === 0) return { ...reply, headers };
335
+ if (typeof DecompressionStream !== "function") {
336
+ throw callError(CALL_CODES.protocolError, "The upstream sent a gzip body and this runtime cannot inflate it");
337
+ }
338
+ const source = reply.body;
339
+ const inflated = new ReadableStream<Uint8Array>({
340
+ start(controller) {
341
+ controller.enqueue(source);
342
+ controller.close();
343
+ },
344
+ }).pipeThrough(new DecompressionStream("gzip") as unknown as TransformStream<Uint8Array, Uint8Array>);
345
+ const reader = inflated.getReader();
346
+ const chunks: Uint8Array[] = [];
347
+ let total = 0;
348
+ for (;;) {
349
+ let result: Awaited<ReturnType<typeof reader.read>>;
350
+ try {
351
+ result = await reader.read();
352
+ } catch (error) {
353
+ throw callError(CALL_CODES.protocolError, "The upstream's gzip body is corrupt", error);
354
+ }
355
+ if (result.done) break;
356
+ total += result.value.length;
357
+ if (total > maxBytes) {
358
+ void reader.cancel().catch(() => undefined);
359
+ throw callError(CALL_CODES.tooLarge, `The response body inflates past ${maxBytes} bytes`);
360
+ }
361
+ chunks.push(result.value);
362
+ }
363
+ const body = new Uint8Array(total);
364
+ let offset = 0;
365
+ for (const chunk of chunks) {
366
+ body.set(chunk, offset);
367
+ offset += chunk.length;
368
+ }
369
+ return { ...reply, headers, body };
370
+ }
371
+
293
372
  /** Map the exit's decoded reply to an `AnonFetchResponse`; `request.url` becomes `url`. */
294
373
  export function toAnonResponse(
295
374
  reply: DecodedHttpResponse,
@@ -312,7 +391,7 @@ export function toAnonResponse(
312
391
  if (lower === "content-encoding" && value.trim().toLowerCase() !== "identity") {
313
392
  throw callError(
314
393
  CALL_CODES.protocolError,
315
- "The upstream sent an encoded body although the request asked for identity; exits cannot decode it",
394
+ `The upstream sent a ${value.trim()} body; this worker accepts identity and gzip`,
316
395
  );
317
396
  }
318
397
  if (DROPPED_RESPONSE_HEADERS.has(lower) || !TOKEN_RE.test(name) || !FIELD_VALUE_RE.test(value)) continue;
@@ -403,9 +482,9 @@ function normalizeMethod(method: unknown): string {
403
482
  return NORMALIZED_METHODS.has(upper) ? upper : method;
404
483
  }
405
484
 
406
- /** Keep order and duplicates (the wire type is a list), drop and force per policy. */
407
- function prepareHeaders(list: HeaderList | undefined): [string, string][] {
408
- if (list === undefined) return FORCED_REQUEST_HEADERS.map(([name, value]): [string, string] => [name, value]);
485
+ /** Keep order and duplicates (the wire type is a list), drop per policy, then set `accept-encoding`. */
486
+ function prepareHeaders(list: HeaderList | undefined, encoding: ReplyEncoding): [string, string][] {
487
+ if (list === undefined) return [["accept-encoding", encoding]];
409
488
  if (!Array.isArray(list)) throw callError(CALL_CODES.unsupported, "headers must be a list of [name, value] pairs");
410
489
  const out: [string, string][] = [];
411
490
  let bytes = 0;
@@ -427,7 +506,7 @@ function prepareHeaders(list: HeaderList | undefined): [string, string][] {
427
506
  }
428
507
  out.push([name, value]);
429
508
  }
430
- for (const [name, value] of FORCED_REQUEST_HEADERS) out.push([name, value]);
509
+ out.push(["accept-encoding", encoding]);
431
510
  return out;
432
511
  }
433
512
 
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Worker-local answers: JSON-RPC calls whose answer
3
+ * cannot change are answered from memory instead of crossing the mixnet
4
+ * again. Fewer queries leave the device, and a hit costs no round trip.
5
+ *
6
+ * What is answered locally, per upstream URL and exact params:
7
+ *
8
+ * - `eth_chainId` and `net_version`: memoised once a second, independently
9
+ * routed request through the mixnet returned the same result (the worker
10
+ * sends that check in the background after the first answer).
11
+ * - Only with `blockHashReads` (off by default, because an answer is not yet
12
+ * checked against the block hash): reads addressed by block hash, whose
13
+ * result is fixed by the hash:
14
+ * `eth_getBlockByHash`, `eth_getBlockReceipts`, `eth_getLogs` with a
15
+ * `blockHash` filter, the `…ByBlockHash…` lookups, and EIP-1898 state reads
16
+ * (`eth_call`, `eth_getBalance`, `eth_getCode`, `eth_getStorageAt`,
17
+ * `eth_getTransactionCount`, `eth_getProof`) whose block is
18
+ * `{"blockHash": …}` without `requireCanonical`. They are kept once two
19
+ * separate calls returned the same result. Each call takes its own route,
20
+ * so a wrong answer is kept only when both calls went through exits that
21
+ * returned the same wrong result.
22
+ *
23
+ * Only single calls (not batches) with a `result` that is not null are kept;
24
+ * errors never are, and neither is an empty list for a block-hash read: an
25
+ * upstream that has not seen the block yet may answer `[]` for its logs or
26
+ * receipts, and a later call returns the full list. A request with `Cache-Control: no-cache` or `no-store`
27
+ * always goes through the mixnet. The reply carries the caller's own
28
+ * JSON-RPC `id`. Memory is bounded by entry count and bytes (oldest out).
29
+ */
30
+ import type { AnonFetchResponse, HeaderList } from "./spec-types.js";
31
+ import type { PreparedRequest } from "./fetch-map.js";
32
+
33
+ /** Most answers kept. */
34
+ export const LOCAL_ANSWER_MAX_ENTRIES = 512;
35
+ /** Most result bytes kept in total. */
36
+ export const LOCAL_ANSWER_MAX_BYTES = 4 * 1024 * 1024;
37
+ /** Largest single result kept. */
38
+ export const LOCAL_ANSWER_MAX_ENTRY_BYTES = 512 * 1024;
39
+ /** Matching answers needed before a content-addressed result is served locally. */
40
+ export const LOCAL_ANSWER_CONFIRMATIONS = 2;
41
+
42
+ const MEMO_METHODS: ReadonlySet<string> = new Set(["eth_chainId", "net_version"]);
43
+
44
+ /** Methods whose first param is a block hash. */
45
+ const BY_BLOCK_HASH: ReadonlySet<string> = new Set([
46
+ "eth_getBlockByHash",
47
+ "eth_getBlockTransactionCountByHash",
48
+ "eth_getTransactionByBlockHashAndIndex",
49
+ "eth_getUncleByBlockHashAndIndex",
50
+ "eth_getUncleCountByBlockHash",
51
+ ]);
52
+
53
+ /** EIP-1898 state reads: the block selector is the last param. */
54
+ const STATE_READS: ReadonlySet<string> = new Set([
55
+ "eth_call",
56
+ "eth_getBalance",
57
+ "eth_getCode",
58
+ "eth_getStorageAt",
59
+ "eth_getTransactionCount",
60
+ "eth_getProof",
61
+ ]);
62
+
63
+ const HASH_RE = /^0x[0-9a-fA-F]{64}$/u;
64
+
65
+ /** Why a call was or was not answered locally (for debug logs). */
66
+ export type LocalKind = "memo" | "block-hash";
67
+
68
+ interface Entry {
69
+ readonly kind: LocalKind;
70
+ /** The JSON text of `result`. */
71
+ readonly result: string;
72
+ readonly headers: HeaderList;
73
+ confirmations: number;
74
+ }
75
+
76
+ /** A single JSON-RPC call read from a prepared request. */
77
+ interface Call {
78
+ readonly key: string;
79
+ readonly kind: LocalKind;
80
+ readonly id: unknown;
81
+ }
82
+
83
+ const decoder = new TextDecoder("utf-8", { fatal: true });
84
+ const encoder = new TextEncoder();
85
+
86
+ /** Options for {@link LocalAnswers}. */
87
+ export interface LocalAnswersOptions {
88
+ /** Also keep reads addressed by block hash (see above). Default false. */
89
+ readonly blockHashReads?: boolean;
90
+ }
91
+
92
+ export class LocalAnswers {
93
+ private readonly entries = new Map<string, Entry>();
94
+ private bytes = 0;
95
+ private readonly blockHashReads: boolean;
96
+
97
+ constructor(options: LocalAnswersOptions = {}) {
98
+ this.blockHashReads = options.blockHashReads ?? false;
99
+ }
100
+
101
+ /** The cacheable call in `request`, honouring the enabled kinds. */
102
+ private call(request: PreparedRequest): Call | undefined {
103
+ const call = cacheableCall(request);
104
+ if (call === undefined) return undefined;
105
+ return call.kind === "block-hash" && !this.blockHashReads ? undefined : call;
106
+ }
107
+
108
+ /** The local answer for `request`, if one is confirmed. */
109
+ lookup(request: PreparedRequest): { response: AnonFetchResponse; kind: LocalKind } | undefined {
110
+ const call = this.call(request);
111
+ if (call === undefined) return undefined;
112
+ const entry = this.entries.get(call.key);
113
+ if (entry === undefined || !this.served(entry)) return undefined;
114
+ this.entries.delete(call.key);
115
+ this.entries.set(call.key, entry);
116
+ const body = `{"jsonrpc":"2.0","id":${JSON.stringify(call.id ?? null)},"result":${entry.result}}`;
117
+ return {
118
+ kind: call.kind,
119
+ response: { status: 200, headers: entry.headers.map(([name, value]) => [name, value]), body: encoder.encode(body), url: request.url },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Record a mixnet answer. Returns `verify` when the call is a memo method
125
+ * seen for the first time: the caller sends it once more through the
126
+ * mixnet and passes that answer here too.
127
+ */
128
+ observe(request: PreparedRequest, response: AnonFetchResponse): "verify" | undefined {
129
+ const call = this.call(request);
130
+ if (call === undefined || response.status !== 200 || !(response.body instanceof Uint8Array)) return undefined;
131
+ const result = resultOf(response.body);
132
+ if (result === undefined || result.length > LOCAL_ANSWER_MAX_ENTRY_BYTES) return undefined;
133
+ if (call.kind === "block-hash" && result === "[]") return undefined;
134
+ const existing = this.entries.get(call.key);
135
+ if (existing !== undefined) {
136
+ if (existing.result === result) {
137
+ existing.confirmations += 1;
138
+ } else {
139
+ // Two answers disagree: keep neither.
140
+ this.remove(call.key, existing);
141
+ }
142
+ return undefined;
143
+ }
144
+ const headers = response.headers.filter(([name]) => name.toLowerCase() === "content-type");
145
+ this.entries.set(call.key, { kind: call.kind, result, headers, confirmations: 1 });
146
+ this.bytes += result.length;
147
+ this.evict();
148
+ return call.kind === "memo" ? "verify" : undefined;
149
+ }
150
+
151
+ /** Number of answers held (served or awaiting confirmation). */
152
+ get size(): number {
153
+ return this.entries.size;
154
+ }
155
+
156
+ private served(entry: Entry): boolean {
157
+ return entry.confirmations >= LOCAL_ANSWER_CONFIRMATIONS;
158
+ }
159
+
160
+ private remove(key: string, entry: Entry): void {
161
+ this.entries.delete(key);
162
+ this.bytes -= entry.result.length;
163
+ }
164
+
165
+ private evict(): void {
166
+ for (const [key, entry] of this.entries) {
167
+ if (this.entries.size <= LOCAL_ANSWER_MAX_ENTRIES && this.bytes <= LOCAL_ANSWER_MAX_BYTES) return;
168
+ this.remove(key, entry);
169
+ }
170
+ }
171
+ }
172
+
173
+ /** The call's cache key and kind, when `request` is one call whose answer is fixed. */
174
+ function cacheableCall(request: PreparedRequest): Call | undefined {
175
+ if (request.method !== "POST" || request.profile.method === undefined || request.profile.method === "batch") {
176
+ return undefined;
177
+ }
178
+ for (const [name, value] of request.headers) {
179
+ if (name.toLowerCase() === "cache-control" && /no-cache|no-store/iu.test(value)) return undefined;
180
+ }
181
+ let value: unknown;
182
+ try {
183
+ value = JSON.parse(decoder.decode(request.body));
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
188
+ const call = value as { jsonrpc?: unknown; method?: unknown; params?: unknown; id?: unknown };
189
+ if (call.jsonrpc !== "2.0" || typeof call.method !== "string" || !("id" in call)) return undefined;
190
+ const id = call.id;
191
+ if (id !== null && typeof id !== "string" && typeof id !== "number") return undefined;
192
+ const params = call.params ?? [];
193
+ const kind = classify(call.method, params);
194
+ if (kind === undefined) return undefined;
195
+ return { key: `${request.url}\n${call.method}\n${JSON.stringify(params)}`, kind, id };
196
+ }
197
+
198
+ function classify(method: string, params: unknown): LocalKind | undefined {
199
+ if (MEMO_METHODS.has(method)) return Array.isArray(params) && params.length === 0 ? "memo" : undefined;
200
+ if (!Array.isArray(params) || params.length === 0) return undefined;
201
+ const first: unknown = params[0];
202
+ if (BY_BLOCK_HASH.has(method)) return isHash(first) ? "block-hash" : undefined;
203
+ if (method === "eth_getBlockReceipts") return isHash(first) || isBlockHashSelector(first) ? "block-hash" : undefined;
204
+ if (method === "eth_getLogs") {
205
+ if (typeof first !== "object" || first === null) return undefined;
206
+ const filter = first as Record<string, unknown>;
207
+ return isHash(filter["blockHash"]) && filter["fromBlock"] === undefined && filter["toBlock"] === undefined
208
+ ? "block-hash"
209
+ : undefined;
210
+ }
211
+ if (STATE_READS.has(method)) return isBlockHashSelector(params[params.length - 1]) ? "block-hash" : undefined;
212
+ return undefined;
213
+ }
214
+
215
+ function isHash(value: unknown): boolean {
216
+ return typeof value === "string" && HASH_RE.test(value);
217
+ }
218
+
219
+ /** EIP-1898 `{"blockHash": …}` without `requireCanonical: true` (whose answer can turn into an error after a reorg). */
220
+ function isBlockHashSelector(value: unknown): boolean {
221
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
222
+ const selector = value as Record<string, unknown>;
223
+ return isHash(selector["blockHash"]) && selector["requireCanonical"] !== true && selector["blockNumber"] === undefined;
224
+ }
225
+
226
+ /** The JSON text of a successful reply's `result` (not null), else undefined. */
227
+ function resultOf(body: Uint8Array): string | undefined {
228
+ let value: unknown;
229
+ try {
230
+ value = JSON.parse(decoder.decode(body));
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
235
+ const reply = value as { result?: unknown; error?: unknown };
236
+ if (reply.error !== undefined || reply.result === undefined || reply.result === null) return undefined;
237
+ return JSON.stringify(reply.result);
238
+ }