@hisoka-io/anon-rpc-worker 0.2.0 → 0.4.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
+ 0xd8bef626ec8511d7a468bca5137880680ca2da20d2ea4d9c981a2a3d0c86ba1e anon-rpc-worker.js
@@ -3,29 +3,40 @@
3
3
  "arch": "x64",
4
4
  "os": "linux"
5
5
  },
6
- "gitCommit": "ef019a716d7994820d7d63d6de3e60ae8f71e80d",
6
+ "gitCommit": "fee8d69861a112c65f0cbb0b848a7726ec12f01a",
7
7
  "gitTreeClean": true,
8
8
  "inputs": {
9
9
  "bootstrapKeccak": "0x6567e3ad5e1b1ac81529391c890d2652695d3ec139d56625170384794085fb74",
10
- "lockfileSha256": "0398a97b0a813b2a5305a745bac0233cea4bd9c8c4a3b084873d0e091383a825",
10
+ "lockfileSha256": "0a205a04cd3b6d861b8fedef8b8e6cd6356642e5f2cd9752ce0e2f403fbae426",
11
11
  "snapshotBlock": 316207920,
12
- "snapshotKeccak": "0xa351960e2c268b9665d1aabec87feaa132d30ade773aef64ef495ec25c94fbb2",
12
+ "snapshotKeccak": "0x1090f071896b6ef41ddfd40d4ebcd20fae85a66468f82497e19a635488014681",
13
+ "tlsWasmKeccak": "0x60b29cd4fca7ee2f31fbcff2dca25e64939e4b71a4629ffcfea1b89546f501ce",
14
+ "tlsWasmSha256": "f74e8cfbed7be33ef1a9158b7b66e454b4ca0849bcb8956487fd13da47e4f4d2",
13
15
  "wasmKeccak": "0x6ff418a78fc022d34dff00a867e173269413fb6887c9d6bf8e6505d4dcfffe2d",
14
16
  "wasmSha256": "5171416972ea4499fd4b4ea8bcadf002f4c10f10ed42cafb0d5a426121da9c4f"
15
17
  },
16
18
  "output": {
17
- "bytes": 924847,
18
- "keccak256": "0x0a58f9915f686950072a4786249d396ecbf2194a39ac835effe8ea1a7c76f324",
19
- "sha256": "479f10a34d113a551312f3adf2e6952f3ab8bc7a4ff98c470e13f9543a44131f"
19
+ "bytes": 1820668,
20
+ "keccak256": "0xd8bef626ec8511d7a468bca5137880680ca2da20d2ea4d9c981a2a3d0c86ba1e",
21
+ "sha256": "4b7b69cd6f45c364cd87ff75f5f8cacdebea2cd852d5384c47215bb88d56f35a"
20
22
  },
21
23
  "package": "@hisoka-io/anon-rpc-worker",
24
+ "tls": {
25
+ "extraRoots": 0,
26
+ "webpkiRoots": "1.0.9",
27
+ "webpkiRootsReleased": "2026-07-18"
28
+ },
22
29
  "toolchain": {
30
+ "clang": "19.1.7",
23
31
  "esbuild": "0.27.3",
24
- "node": "v25.5.0",
32
+ "node": "v22.23.3",
25
33
  "pins": {
26
34
  "BINARYEN_SHA256_AARCH64": "ad560204426015a815faa45693c83bef7d58677d38a39422c272a30ba4b6da2a",
27
35
  "BINARYEN_SHA256_X86_64": "3dc677006555b355ea2da5e82602065a161d5e83eaefd3f759afa00b96e83212",
28
36
  "BINARYEN_VERSION": "117",
37
+ "CLANG_PACKAGE_VERSION": "1:19.1.7-3~deb12u1",
38
+ "CLANG_VERSION": "19.1.7",
39
+ "DEBIAN_SNAPSHOT": "20261001T000000Z",
29
40
  "NODE_IMAGE": "docker.io/library/node:22.23.3-bookworm-slim@sha256:43ac6c60b8f89723f746e8a92ce91abd5017e627ce1ddfe4238355d3a30b772c",
30
41
  "NODE_VERSION": "22.23.3",
31
42
  "RUST_IMAGE": "docker.io/library/rust:1.95.0-slim-bookworm@sha256:d7482085ff5b415f84dba5647ae71606650bdef00db7aeb69f4b3d170c3e4082",
@@ -44,5 +55,5 @@
44
55
  "wasmPack": "0.13.1",
45
56
  "wasmPinsChecked": true
46
57
  },
47
- "version": "0.2.0"
58
+ "version": "0.4.0"
48
59
  }
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.4.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.8.0",
29
29
  "@hisoka-io/nox-wasm": "0.2.0"
30
30
  },
31
31
  "publishConfig": {
@@ -3,21 +3,24 @@
3
3
  "members": {
4
4
  "0x03a42846c18b99c453a49d7fce69f336f865c48b": [
5
5
  "paid_v2",
6
- "surb_v2"
6
+ "surb_v2",
7
+ "tunnel_v1"
7
8
  ],
8
9
  "0x074a13b271b73eb5c0c44e2037d035e7eef4a462": [
9
10
  "surb_v2"
10
11
  ],
11
12
  "0x1efa385556a6e8643df0033048ba1dd67ec43f93": [
12
13
  "paid_v2",
13
- "surb_v2"
14
+ "surb_v2",
15
+ "tunnel_v1"
14
16
  ],
15
17
  "0x3e98ee4572741fd932e031ac60e79f7c012758be": [
16
18
  "surb_v2"
17
19
  ],
18
20
  "0x6774ca4baf6fff84f02898a3dee4299ed1f5ab4e": [
19
21
  "paid_v2",
20
- "surb_v2"
22
+ "surb_v2",
23
+ "tunnel_v1"
21
24
  ],
22
25
  "0x74486dc1ac551e5cd3f4eef80727cc9d50d3abe9": [
23
26
  "surb_v2"
@@ -35,5 +38,5 @@
35
38
  "surb_v2"
36
39
  ]
37
40
  },
38
- "source": "Capability hints reviewed for the release snapshot. Taken from the liveness section of the public seed topology (https://api.hisoka.io/seed/topology, fleet build 0.4.0-rc.4+b30e46d3ab9d2d707de37011a10639a4c15232ce), re-read at block 315566145 on 2026-10-04 with identical hints for all 10 members. Hints are untrusted by design and only choose between reply-block formats."
41
+ "source": "Capability hints reviewed for the release snapshot. Taken from the liveness section of the public seed topology (https://api.hisoka.io/seed/topology, fleet build 0.4.0-rc.9+3ef92f06f8265700b7b07e9e21e8f6dff0f47a09), read at block 317672566 on 2026-10-10 with all 10 members online; the three exits advertise tunnel_v1. Hints are untrusted by design: they choose between reply-block formats and pick tunnel exits (tunnel_v1), where a false hint costs one open timeout and plaintext stays out of reach."
39
42
  }
@@ -9,7 +9,8 @@
9
9
  "address": "0x03a42846c18b99c453a49d7fce69f336f865c48b",
10
10
  "capabilities": [
11
11
  "paid_v2",
12
- "surb_v2"
12
+ "surb_v2",
13
+ "tunnel_v1"
13
14
  ],
14
15
  "frozen": false,
15
16
  "ingressUrl": "https://nox-7.hisoka.io",
@@ -40,7 +41,8 @@
40
41
  "address": "0x1efa385556a6e8643df0033048ba1dd67ec43f93",
41
42
  "capabilities": [
42
43
  "paid_v2",
43
- "surb_v2"
44
+ "surb_v2",
45
+ "tunnel_v1"
44
46
  ],
45
47
  "frozen": false,
46
48
  "ingressUrl": "https://nox-10.hisoka.io",
@@ -71,7 +73,8 @@
71
73
  "address": "0x6774ca4baf6fff84f02898a3dee4299ed1f5ab4e",
72
74
  "capabilities": [
73
75
  "paid_v2",
74
- "surb_v2"
76
+ "surb_v2",
77
+ "tunnel_v1"
75
78
  ],
76
79
  "frozen": false,
77
80
  "ingressUrl": "https://nox-6.hisoka.io",
@@ -1 +1 @@
1
- 0xa351960e2c268b9665d1aabec87feaa132d30ade773aef64ef495ec25c94fbb2 nox-snapshot.json
1
+ 0x1090f071896b6ef41ddfd40d4ebcd20fae85a66468f82497e19a635488014681 nox-snapshot.json
package/src/config.ts CHANGED
@@ -15,6 +15,25 @@ export type SurbFormatSetting = "auto" | "v1" | "v2";
15
15
  /** `"chain"`: registry reads through the mixnet (S1). `"snapshot"`: the pinned snapshot is the only membership source. */
16
16
  export type DiscoverySetting = "chain" | "snapshot";
17
17
 
18
+ /**
19
+ * End-to-end TLS (README "End-to-end TLS"):
20
+ * - `"required"`: https calls to a host name on port 443 go through TLS
21
+ * tunnels, so exits relay ciphertext only; every other call is refused and
22
+ * nothing ever falls back to the exit's `HttpRequest` path.
23
+ * - `"preferred"`: tunnels where a tunnel exit is known and the URL allows
24
+ * one, `HttpRequest` otherwise. An operator who withholds `tunnel_v1` gets
25
+ * plaintext, so only `"required"` holds against the operator.
26
+ * - `"off"`: every call is an exit `HttpRequest` (worker 0.3 behaviour).
27
+ */
28
+ export type TlsSetting = "required" | "preferred" | "off";
29
+
30
+ /**
31
+ * `"per-call"`: one TLS session per call (with `tlsSpares` handshaken spares
32
+ * per recent host). `"keep-alive"`: calls to one host share a session on one
33
+ * exit, which links them at the exit and at the provider.
34
+ */
35
+ export type TlsSessionSetting = "per-call" | "keep-alive";
36
+
18
37
  export interface NoxWorkerConfig {
19
38
  readonly v: 1;
20
39
  /** KPS addresses (`<ip>:<port>:<certhash>`) tried first, in place of the bundle's default anchors. */
@@ -31,6 +50,12 @@ export interface NoxWorkerConfig {
31
50
  readonly logLevel: LogLevel;
32
51
  /** Per attempt for small and medium reads. */
33
52
  readonly attemptTimeoutMs: number;
53
+ /**
54
+ * Resendable reads: a second copy goes out on another route when no reply
55
+ * arrived after this long (raised to the observed p95 reply time); the
56
+ * first reply wins. 0 turns hedging off.
57
+ */
58
+ readonly hedgeAfterMs: number;
34
59
  /** Whole call, from acceptance: waiting for ready, sending, attempts. */
35
60
  readonly callDeadlineMs: number;
36
61
  readonly maxConcurrentCalls: number;
@@ -43,6 +68,24 @@ export interface NoxWorkerConfig {
43
68
  readonly topologySources: number;
44
69
  /** One echo through a full route before `signalReady`. */
45
70
  readonly warmup: boolean;
71
+ readonly tls: TlsSetting;
72
+ readonly tlsSession: TlsSessionSetting;
73
+ /** Per-call mode: handshaken, unused sessions kept per recently used host. */
74
+ readonly tlsSpares: number;
75
+ /** An unused spare is torn down after this long. */
76
+ readonly tlsSpareTtlMs: number;
77
+ /** Keep-alive mode: a session serves calls for this long after it opened. */
78
+ readonly tlsKeepAliveMs: number;
79
+ /** Keep-alive mode: calls per session. */
80
+ readonly tlsMaxCallsPerSession: number;
81
+ /** An exit that does not answer a tunnel open in this long is skipped for a while. */
82
+ readonly tlsOpenTimeoutMs: number;
83
+ /** Another copy of the current exchange when nothing arrived for this long (raised to the observed p95). */
84
+ readonly tlsCopyAfterMs: number;
85
+ /** Another copy when a gap in the reply stream stays open this long. */
86
+ readonly tlsGapMs: number;
87
+ /** Copies per exchange sent for silence or a gap. */
88
+ readonly tlsMaxCopies: number;
46
89
  }
47
90
 
48
91
  export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
@@ -55,6 +98,7 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
55
98
  trust: "auto",
56
99
  logLevel: "info",
57
100
  attemptTimeoutMs: 12_000,
101
+ hedgeAfterMs: 3_000,
58
102
  callDeadlineMs: 25_000,
59
103
  maxConcurrentCalls: 16,
60
104
  maxRequestBytes: 1_048_576,
@@ -64,11 +108,23 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
64
108
  bootRetryMaxMs: 60_000,
65
109
  topologySources: 2,
66
110
  warmup: false,
111
+ // Founder decisions D1/D2 (E2E TLS design §8): the most private settings until decided.
112
+ tls: "required",
113
+ tlsSession: "per-call",
114
+ tlsSpares: 1,
115
+ tlsSpareTtlMs: 20_000,
116
+ tlsKeepAliveMs: 30_000,
117
+ tlsMaxCallsPerSession: 100,
118
+ tlsOpenTimeoutMs: 5_000,
119
+ tlsCopyAfterMs: 2_500,
120
+ tlsGapMs: 800,
121
+ tlsMaxCopies: 3,
67
122
  });
68
123
 
69
124
  /** Inclusive integer ranges of the numeric fields. */
70
125
  export const CONFIG_RANGES = Object.freeze({
71
126
  attemptTimeoutMs: [3_000, 60_000],
127
+ hedgeAfterMs: [0, 60_000],
72
128
  callDeadlineMs: [3_000, 120_000],
73
129
  maxConcurrentCalls: [1, 64],
74
130
  maxRequestBytes: [1_024, 4_194_304],
@@ -76,6 +132,14 @@ export const CONFIG_RANGES = Object.freeze({
76
132
  claimIntervalMs: [50, 2_000],
77
133
  bootRetryMaxMs: [5_000, 300_000],
78
134
  topologySources: [1, 4],
135
+ tlsSpares: [0, 4],
136
+ tlsSpareTtlMs: [1_000, 50_000],
137
+ tlsKeepAliveMs: [1_000, 50_000],
138
+ tlsMaxCallsPerSession: [1, 1_000],
139
+ tlsOpenTimeoutMs: [1_000, 30_000],
140
+ tlsCopyAfterMs: [500, 30_000],
141
+ tlsGapMs: [100, 10_000],
142
+ tlsMaxCopies: [1, 5],
79
143
  } as const satisfies Record<string, readonly [number, number]>);
80
144
 
81
145
  /** `gateways` and `bridges` list length bounds. */
@@ -83,6 +147,8 @@ export const GATEWAYS_RANGE = Object.freeze([1, 16] as const);
83
147
 
84
148
  const SURB_FORMATS: readonly SurbFormatSetting[] = ["auto", "v1", "v2"];
85
149
  const DISCOVERY_SETTINGS: readonly DiscoverySetting[] = ["chain", "snapshot"];
150
+ const TLS_SETTINGS: readonly TlsSetting[] = ["required", "preferred", "off"];
151
+ const TLS_SESSION_SETTINGS: readonly TlsSessionSetting[] = ["per-call", "keep-alive"];
86
152
  /** Names reserved for later stages (PROPOSAL §2.5): refused so they are never reused differently. */
87
153
  const RESERVED_KEYS = new Set(["checkpoint"]);
88
154
  const KNOWN_KEYS = new Set<string>([...Object.keys(CONFIG_DEFAULTS), ...RESERVED_KEYS]);
@@ -158,6 +224,8 @@ export function parseConfig(raw: unknown): NoxWorkerConfig {
158
224
  }
159
225
  out.discovery = discovery as DiscoverySetting;
160
226
  }
227
+ out.tls = pickOne(raw, "tls", TLS_SETTINGS, out.tls);
228
+ out.tlsSession = pickOne(raw, "tlsSession", TLS_SESSION_SETTINGS, out.tlsSession);
161
229
  const fail = (detail: string): ConfigError => new ConfigError(`config.${detail}`);
162
230
  if (raw["gateways"] !== undefined && raw["bridges"] !== undefined) {
163
231
  throw new ConfigError("config.gateways and config.bridges exclude each other: with bridges the worker dials only bridges");
@@ -178,6 +246,15 @@ export function parseConfig(raw: unknown): NoxWorkerConfig {
178
246
  return Object.freeze(out);
179
247
  }
180
248
 
249
+ function pickOne<T extends string>(raw: Record<string, unknown>, field: string, allowed: readonly T[], fallback: T): T {
250
+ const value = raw[field];
251
+ if (value === undefined) return fallback;
252
+ if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
253
+ throw new ConfigError(`config.${field} must be one of ${allowed.join(", ")}`);
254
+ }
255
+ return value as T;
256
+ }
257
+
181
258
  function isPlainObject(value: unknown): value is Record<string, unknown> {
182
259
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
183
260
  const proto = Object.getPrototypeOf(value) as unknown;
package/src/core.ts CHANGED
@@ -12,9 +12,13 @@
12
12
  * call `signalFailed`; transient ones retry forever with back-off while
13
13
  * `ready` stays pending. After ready the SDK checks NoxRegistry through the
14
14
  * mixnet in the background (S1); each verified check refreshes the
15
- * learned-anchor cache.
15
+ * learned-anchor cache. With `tls` other than `"off"` the embedded TLS module
16
+ * is compiled at boot too, off the first call's path, and https calls go
17
+ * through TLS tunnels (src/tls/).
16
18
  */
17
19
  import {
20
+ DISCOVERY_FIRST_CHECK_MAX_DEFER_MS,
21
+ RESEND_FAST,
18
22
  eligiblePinnedMembers,
19
23
  pinnedKpsAddresses,
20
24
  verifyBootstrap,
@@ -40,7 +44,19 @@ import {
40
44
  type BootRetryCode,
41
45
  type FailedCode,
42
46
  } from "./errors.js";
43
- import { mapClientError, prepareRequest, sendPrepared, type CallBudget, type NoxHttpPort } from "./fetch-map.js";
47
+ import {
48
+ mapClientError,
49
+ prepareRequest,
50
+ sendPrepared,
51
+ type CallBudget,
52
+ type NoxHttpPort,
53
+ type PreparedRequest,
54
+ } from "./fetch-map.js";
55
+ import { LocalAnswers } from "./local-answers.js";
56
+ import type { NoxTlsBindings } from "./tls/module.js";
57
+ import { TlsPool } from "./tls/pool.js";
58
+ import { TlsTransport } from "./tls/transport.js";
59
+ import type { TunnelPort } from "./tls/tunnel.js";
44
60
  import { createLogger, describeError, errorCode, type LogLevel, type WorkerLogger } from "./log.js";
45
61
  import {
46
62
  LearnedCacheWriter,
@@ -53,7 +69,7 @@ import {
53
69
  } from "./storage.js";
54
70
 
55
71
  /** The slice of `NoxClient` the worker uses. */
56
- export interface NoxClientPort extends NoxHttpPort {
72
+ export interface NoxClientPort extends NoxHttpPort, TunnelPort {
57
73
  readonly nodes: readonly { readonly id: string }[];
58
74
  sendEcho(data: Uint8Array): Promise<Uint8Array>;
59
75
  disconnect(): void;
@@ -67,6 +83,8 @@ export interface WorkerDeps {
67
83
  readonly bootstrap: unknown;
68
84
  /** Initialise the embedded WASM and return its bindings; throws when WebAssembly is blocked. */
69
85
  loadWasm(): Promise<NoxWasmBindings>;
86
+ /** Initialise the embedded TLS module (only when `tls` is not `"off"`). */
87
+ loadTls(): Promise<NoxTlsBindings>;
70
88
  /** `NoxClient.connect`. */
71
89
  connect(config: NoxClientConfig): Promise<NoxClientPort>;
72
90
  /** Milliseconds since the epoch. Default `Date.now`. */
@@ -148,6 +166,9 @@ class NoxWorker {
148
166
  private cacheTimer: ReturnType<typeof setInterval> | undefined;
149
167
  private learnedWriter: LearnedCacheWriter | undefined;
150
168
  private callSeq = 0;
169
+ private tlsPool: TlsPool | undefined;
170
+ private tunnels: TlsTransport | undefined;
171
+ private readonly answers = new LocalAnswers();
151
172
  private readonly now: () => number;
152
173
  private readonly random: () => number;
153
174
  private readonly sleep: (ms: number) => Promise<void>;
@@ -220,6 +241,21 @@ class NoxWorker {
220
241
  }
221
242
  this.log.info("boot.wasm");
222
243
 
244
+ let tls: NoxTlsBindings | undefined;
245
+ if (cfg.tls !== "off") {
246
+ try {
247
+ tls = await this.deps.loadTls();
248
+ } catch (error) {
249
+ this.fail(
250
+ FAILED_CODES.wasmBlocked,
251
+ `The TLS module could not be compiled or instantiated (${describeError(error)}); ` +
252
+ "the embedder's Content-Security-Policy must allow 'wasm-unsafe-eval'",
253
+ );
254
+ return;
255
+ }
256
+ this.log.info("boot.tls", { mode: cfg.tls, session: cfg.tlsSession, roots: tls.info.webpkiRoots });
257
+ }
258
+
223
259
  void this.acceptLoop(cfg);
224
260
 
225
261
  const snapshotId = snapshotIdentity(pinned);
@@ -239,6 +275,15 @@ class NoxWorker {
239
275
  return;
240
276
  }
241
277
  this.client = client;
278
+ if (tls !== undefined) {
279
+ this.tlsPool = new TlsPool(client, tls, cfg, {
280
+ now: this.now,
281
+ random: this.random,
282
+ randomBytes: (length) => crypto.getRandomValues(new Uint8Array(length)),
283
+ log: this.log,
284
+ });
285
+ this.tunnels = new TlsTransport(this.tlsPool, tls, cfg, this.log, this.now);
286
+ }
242
287
  this.startRemovalCache(pinned, snapshotId, stored);
243
288
  this.api.signalReady();
244
289
  this.log.info("ready");
@@ -262,6 +307,9 @@ class NoxWorker {
262
307
  private shutdown(reason: NoxWorkerError): void {
263
308
  if (this.cacheTimer !== undefined) clearInterval(this.cacheTimer);
264
309
  this.cacheTimer = undefined;
310
+ this.tlsPool?.close();
311
+ this.tlsPool = undefined;
312
+ this.tunnels = undefined;
265
313
  this.client?.disconnect();
266
314
  this.client = undefined;
267
315
  for (const waiter of this.readyWaiters) waiter.reject(reason);
@@ -291,11 +339,20 @@ class NoxWorker {
291
339
  log: this.sdkLogSink(),
292
340
  timeoutMs: cfg.attemptTimeoutMs,
293
341
  surbFormat: cfg.surbFormat,
342
+ // Interactive latency: hedge at about p95, resend at once on a lost
343
+ // reply, transport resends of their own, same-entry resends for a
344
+ // single bridge (ARCHITECTURE §4.7).
345
+ resend: { ...RESEND_FAST, hedgeAfterMs: cfg.hedgeAfterMs },
294
346
  kps: {
295
347
  dial: kpsDial,
296
348
  pinned,
297
349
  topologySources: cfg.topologySources,
298
350
  claimIntervalMs: cfg.claimIntervalMs,
351
+ // A warm standby entry. Claims share the primary connection: a second
352
+ // claim connection per entry measured mixed live and doubles per-IP
353
+ // connections at the relay.
354
+ claimLane: false,
355
+ standby: true,
299
356
  discovery: this.discoveryOptions(cfg, bootstrap, learned),
300
357
  ...(deprioritize.length === 0 ? {} : { deprioritize }),
301
358
  },
@@ -340,6 +397,8 @@ class NoxWorker {
340
397
  return {
341
398
  bootstrap,
342
399
  chain: cfg.discovery === "chain",
400
+ // Wallet calls first: the first registry check waits for the first call (bounded).
401
+ firstCheckDeferMs: DISCOVERY_FIRST_CHECK_MAX_DEFER_MS,
343
402
  onVerified: (state) => this.onVerified(state),
344
403
  ...(cfg.gateways === undefined ? {} : { gateways: cfg.gateways }),
345
404
  ...(cfg.bridges === undefined ? {} : { bridges: cfg.bridges }),
@@ -447,8 +506,20 @@ class NoxWorker {
447
506
  const budget = new CallControl(call.requestInit?.signal, cfg.callDeadlineMs, this.now);
448
507
  try {
449
508
  const prepared = await prepareRequest(call.url, call.requestInit, cfg, budget.signal);
509
+ const local = this.answers.lookup(prepared);
510
+ if (local !== undefined) {
511
+ this.log.debug("call.done", {
512
+ seq,
513
+ jsonrpcMethod: prepared.profile.method ?? null,
514
+ ms: this.now() - started,
515
+ outcome: "ok",
516
+ local: local.kind,
517
+ });
518
+ return local.response;
519
+ }
450
520
  const client = await this.waitReady(budget.signal);
451
- const response = await sendPrepared(prepared, client, cfg, budget);
521
+ const response = await sendPrepared(prepared, client, cfg, budget, this.tunnels);
522
+ if (this.answers.observe(prepared, response) === "verify") this.verifyAnswer(prepared, client, cfg);
452
523
  if (this.log.enabled("debug")) {
453
524
  this.log.debug("call.done", {
454
525
  seq,
@@ -467,9 +538,30 @@ class NoxWorker {
467
538
  throw final;
468
539
  } finally {
469
540
  budget.dispose();
541
+ // Spares open only once a wallet call settled (background work never delays the first call).
542
+ this.tlsPool?.enableBackground();
470
543
  }
471
544
  }
472
545
 
546
+ /**
547
+ * Send a memo call (`eth_chainId`, `net_version`) once more through the
548
+ * mixnet on a fresh route, in the background; the answer is kept only when
549
+ * both agree (see `LocalAnswers`).
550
+ */
551
+ private verifyAnswer(prepared: PreparedRequest, client: NoxClientPort, cfg: NoxWorkerConfig): void {
552
+ const budget = new CallControl(undefined, cfg.callDeadlineMs, this.now);
553
+ sendPrepared(prepared, client, cfg, budget, this.tunnels)
554
+ .then(
555
+ (response) => {
556
+ this.answers.observe(prepared, response);
557
+ },
558
+ (error: unknown) => {
559
+ this.log.debug("local.verify.failed", { code: errorCode(mapClientError(error)) ?? "error" });
560
+ },
561
+ )
562
+ .finally(() => budget.dispose());
563
+ }
564
+
473
565
  private waitReady(signal: AbortSignal): Promise<NoxClientPort> {
474
566
  if (this.client !== undefined) return Promise.resolve(this.client);
475
567
  if (this.failed || this.stopped) {
@@ -13,3 +13,19 @@ declare module "nox-embed:bootstrap" {
13
13
  const bootstrap: unknown;
14
14
  export default bootstrap;
15
15
  }
16
+
17
+ declare module "nox-embed:tls" {
18
+ /** Initialise the embedded nox-tls module (no fetch; the bytes are in the bundle). */
19
+ export default function init(): Promise<void>;
20
+ export const TlsClientConfig: import("./tls/module.js").NoxTlsExports["TlsClientConfig"];
21
+ export const TlsClientSession: import("./tls/module.js").NoxTlsExports["TlsClientSession"];
22
+ export const HttpResponseParser: import("./tls/module.js").NoxTlsExports["HttpResponseParser"];
23
+ export const encodeHttpRequest: import("./tls/module.js").NoxTlsExports["encodeHttpRequest"];
24
+ export const buildInfo: import("./tls/module.js").NoxTlsExports["buildInfo"];
25
+ }
26
+
27
+ declare module "nox-embed:tls-root" {
28
+ /** A test bed's CA certificate (DER), trusted next to the compiled-in roots; `null` in every release build. */
29
+ const extraRootDer: Uint8Array | null;
30
+ export default extraRootDer;
31
+ }
package/src/errors.ts CHANGED
@@ -54,8 +54,14 @@ export const CALL_CODES = Object.freeze({
54
54
  tooLarge: "too-large",
55
55
  /** Not an absolute http(s) URL, or an invalid method, header or redirect mode. */
56
56
  unsupported: "unsupported",
57
- /** The exit's reply could not be decoded or is not a valid HTTP response. */
57
+ /**
58
+ * The exit's reply could not be decoded or is not a valid HTTP response; or
59
+ * TLS through a tunnel failed its own checks (a refused record, an alert, a
60
+ * failed record authentication, a body cut short).
61
+ */
58
62
  protocolError: "protocol-error",
63
+ /** The tunnel exit's policy refuses the destination (port, host name or a blocked address). */
64
+ permissionDenied: "permission-denied",
59
65
  /** An unexpected exception (a bug). */
60
66
  internalError: "internal-error",
61
67
  } as const);