@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.
- package/CHANGELOG.md +24 -0
- package/README.md +17 -0
- package/dist/anon-rpc-worker.js +3460 -2615
- package/dist/anon-rpc-worker.js.keccak256 +1 -1
- package/dist/anon-rpc-worker.provenance.json +7 -7
- package/package.json +2 -2
- package/src/config.ts +8 -0
- package/src/core.ts +54 -1
- package/src/fetch-map.ts +91 -12
- package/src/local-answers.ts +238 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0x24604525d220bcc7e39f2dbc22966a814600a63ada51baafad5dd0bcc1d28549 anon-rpc-worker.js
|
|
@@ -3,25 +3,25 @@
|
|
|
3
3
|
"arch": "x64",
|
|
4
4
|
"os": "linux"
|
|
5
5
|
},
|
|
6
|
-
"gitCommit": "
|
|
6
|
+
"gitCommit": "6bddd851c5b1f4e5508f4718e2ea9b4db626b17f",
|
|
7
7
|
"gitTreeClean": true,
|
|
8
8
|
"inputs": {
|
|
9
9
|
"bootstrapKeccak": "0x6567e3ad5e1b1ac81529391c890d2652695d3ec139d56625170384794085fb74",
|
|
10
|
-
"lockfileSha256": "
|
|
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":
|
|
18
|
-
"keccak256": "
|
|
19
|
-
"sha256": "
|
|
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": "
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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 `
|
|
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
|
-
/**
|
|
97
|
-
const
|
|
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
|
|
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
|
|
211
|
+
if (location === undefined) return finishResponse(reply, current, settings);
|
|
194
212
|
const next = redirectedRequest(current, reply.status, location);
|
|
195
|
-
if (next === null) return
|
|
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
|
-
|
|
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
|
|
407
|
-
function prepareHeaders(list: HeaderList | undefined): [string, string][] {
|
|
408
|
-
if (list === undefined) return
|
|
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
|
-
|
|
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
|
+
}
|