@hisoka-io/anon-rpc-worker 0.3.0 → 0.4.1

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 CHANGED
@@ -2,6 +2,67 @@
2
2
 
3
3
  All notable changes to `@hisoka-io/anon-rpc-worker`.
4
4
 
5
+ ## 0.4.1 (2026-10-25)
6
+
7
+ Built on `@hisoka-io/nox-client` 0.9.0 and `@hisoka-io/nox-wasm` 0.3.0. Every 0.4.0 config stays valid.
8
+
9
+ ### Changed
10
+
11
+ - TLS spares: defaults `tlsSpares` 2 (was 1) and `tlsSpareTtlMs` 12,000 (was 20,000). The upstream RPC hosts close
12
+ an idle TLS connection 15.0 s after the handshake, so a 20 s spare could be dead when used: one wasted exchange
13
+ and a `tls.retry` on a new session. Measured from India on 2026-10-15 at a 15 s call gap: 13 of 30 calls retried
14
+ with a 20 s TTL, none of 70 with 12 s.
15
+ - A spare is refreshed before its TTL while its host was called within the last minute: the replacement opens at
16
+ once 1.5-2 s before the TTL, and a host holds at most `tlsSpares` spares plus the one being replaced. Past that
17
+ window an expired spare is not replaced, as before. The one-minute window bounds how far apart the refresh chain
18
+ can link a wallet's calls to one host at low traffic (about 20 s in 0.4.0).
19
+ - A call that finds no spare while a spare's handshake for its host has run for half the handshake median waits for
20
+ that handshake instead of opening another session; if that handshake fails, the call opens its own.
21
+
22
+ - Embedded nox-wasm 0.3.0: nox-crypto at nox `dff8ebe`, which refuses a reply body shorter than 33 bytes with an
23
+ error instead of trapping the module.
24
+
25
+ ### Added
26
+
27
+ - Log event `tls.call` (info), one per tunnel hop: `path` (`spare`, `inline`, `waited`, `reused` or `retry`),
28
+ `openMs`, `spareAgeMs` (spares only), `exchangeMs`, `retries`, `ok`.
29
+
30
+ ## Stable specifier (2026-10-13)
31
+
32
+ Deployment record and tooling; the published package stays 0.4.0.
33
+
34
+ - Stable specifier `0x35133267A8F7EC35046a83DCA0f4Ca8A6e1b7A9A` deployed on Ethereum Sepolia on 2026-10-13,
35
+ pointing at 0.4.0: the reference `WorkerSpecifier` of ethereum/anon-rpc, which follows each worker release
36
+ through `setWorker`. The per-release immutable specifiers stay as pins (`specifier/README.md`).
37
+ - `specifier`: `pnpm set-worker`, a dry-run-first tool for the `setWorker` update.
38
+
39
+ ## 0.4.0 (2026-10-10)
40
+
41
+ Built on `@hisoka-io/nox-client` 0.8.0. Every 0.3.0 config stays valid; with the new defaults, https calls go
42
+ through TLS tunnels and need exits on nox rc.9 or later with `[tunnel]` enabled.
43
+
44
+ ### Added
45
+
46
+ - End-to-end TLS (README "End-to-end TLS"): the worker runs TLS 1.3 and 1.2 itself (rustls with ring in a second
47
+ embedded WebAssembly module, `packages/nox-tls`, compiled at boot) and exits relay TLS records over one TCP
48
+ connection per tunnel. Requests and responses stay end-to-end encrypted between the worker and the RPC provider,
49
+ and the mixnet keeps the sender anonymous; the exit sees the provider's host name, timing and sizes.
50
+ - Config `tls` (`"required"` default, `"preferred"`, `"off"`), `tlsSession` (`"per-call"` default, `"keep-alive"`),
51
+ `tlsSpares`, `tlsSpareTtlMs`, `tlsKeepAliveMs`, `tlsMaxCallsPerSession`, `tlsOpenTimeoutMs`, `tlsCopyAfterMs`,
52
+ `tlsGapMs`, `tlsMaxCopies`.
53
+ - Per-call sessions with handshaken spares per recently used host, opened in the background after the first wallet
54
+ call settled; keep-alive sessions as an opt-in. Resumption and 0-RTT stay off; ALPN is `http/1.1`.
55
+ - HTTP/1.1 in the worker: caller headers keep their order and duplicates; framing, hop-by-hop and `User-Agent`
56
+ headers are set by the encoder alone; CR, LF and NUL are refused; a whole JSON request is padded to a size bucket;
57
+ responses are complete only by HTTP framing or TLS close_notify.
58
+ - Call code `permission-denied` (the tunnel exit refuses the destination). Log events `boot.tls`, `tls.open`,
59
+ `tls.fallback`, `tls.reject`, `tls.exit.skipped`, `tls.retry`.
60
+ - Build: `nox-tls` built with the pinned toolchain and a pinned clang (`DEBIAN_SNAPSHOT`, `CLANG_PACKAGE_VERSION`,
61
+ `CLANG_VERSION` in `scripts/toolchain.env`); provenance records its digest, clang, the webpki-roots version and
62
+ release date, and `extraRoots`. `build.mjs --extra-root` and `build-test-worker.mjs --extra-root` for test beds;
63
+ `build-worker.sh --release` refuses a bundle with extra roots.
64
+ - Release gate: `verify-snapshot.mjs --release` requires an exit whose capability hints carry `tunnel_v1`.
65
+
5
66
  ## 0.3.0 (2026-10-06)
6
67
 
7
68
  Every 0.2.0 config stays valid. Works against entries on nox 0.4.0-rc.6 and on nodes with claim protocol v2.
package/README.md CHANGED
@@ -3,25 +3,27 @@
3
3
  The Nox mixnet worker for the [anon-rpc](https://github.com/ethereum/anon-rpc) standard: one classic-script
4
4
  bundle that a wallet's anon-rpc harness loads and pins by its keccak-256 hash.
5
5
 
6
- The worker dials Nox entry nodes over KPS (`anonRpcWorker.kps`), sends each `fetch` call as a Sphinx packet
7
- through the mixnet, and an exit node performs the HTTP request; the reply returns over single-use reply blocks
8
- (SURBs). Who the nodes are comes from NoxRegistry: a snapshot pinned inside the bundle is the floor, and after
6
+ The worker dials Nox entry nodes over KPS (`anonRpcWorker.kps`) and sends each `fetch` call as Sphinx packets
7
+ through the mixnet. It runs TLS with the RPC provider itself, and an exit relays the encrypted records (the exit
8
+ performs the HTTP request with `tls: "off"`, or as the `"preferred"` fallback); replies return over single-use
9
+ reply blocks (SURBs). Who the nodes are comes from NoxRegistry: a snapshot pinned inside the bundle is the floor, and after
9
10
  ready the worker reads the registry through the mixnet itself. Where the nodes are (IP, port, KPS certhash) is
10
11
  looked up at run time, so operators change IPs whenever they like and new nodes join without a new bundle. A cold
11
12
  boot needs no seed server and no public RPC; the bundle reaches the network only through the harness's KPS dialer.
12
13
 
13
- What each party sees: the entry node sees the wallet's IP address and encrypted packets; the exit node sees the
14
- HTTP request (RPC URL, headers it forwards, body) and never who sent it. End-to-end TLS inside the worker,
15
- which also hides the request from the exit, is the next milestone.
14
+ What each party sees: the entry node sees the wallet's IP address and encrypted packets. With end-to-end TLS (the
15
+ default, `tls: "required"`), requests and responses stay end-to-end encrypted between the worker and the RPC
16
+ provider, and the mixnet keeps the sender anonymous: the worker runs TLS itself and the exit relays TLS ciphertext
17
+ over one TCP connection, seeing the provider's host name, timing and sizes (see [End-to-end TLS](#end-to-end-tls)).
16
18
 
17
19
  ## Layout
18
20
 
19
21
  | Path | Contents |
20
22
  |---|---|
21
- | `src/` | The worker: `worker.ts` (bundle entry), `core.ts` (boot, readiness, accept loop, deadlines), `config.ts`, `fetch-map.ts` (`fetch` to Nox `HttpRequest`), `jsonrpc.ts`, `errors.ts`, `log.ts`, `storage.ts`, `spec-types.ts` (anon-rpc SPEC 0.3.2 worker types) |
23
+ | `src/` | The worker: `worker.ts` (bundle entry), `core.ts` (boot, readiness, accept loop, deadlines), `config.ts`, `fetch-map.ts` (`fetch` to Nox `HttpRequest`, redirects, gzip), `tls/` (end-to-end TLS: `tunnel.ts` one exit tunnel, `channel.ts` TLS and HTTP/1.1 over it, `pool.ts` sessions, spares and exit choice, `transport.ts` the per-hop transport and error mapping, `module.ts` the TLS module bindings), `jsonrpc.ts`, `errors.ts`, `log.ts`, `storage.ts`, `spec-types.ts` (anon-rpc SPEC 0.3.2 worker types) |
22
24
  | `snapshot/` | The pinned registry snapshot, its keccak-256, JSON Schema and reviewed capability hints, and the discovery bootstrap (`nox-bootstrap.json`) |
23
25
  | `scripts/` | Snapshot generator and verifier, reproducible WASM and bundle build, hashing, provenance |
24
- | `specifier/` | Worker specifier contracts (Foundry) and a read-only deploy planner and inspector |
26
+ | `specifier/` | Worker specifier contracts (Foundry), a read-only deploy planner and inspector, and the `setWorker` update tool for the stable address |
25
27
  | `e2e/` | End-to-end test bed: a local Nox mesh with `nox-kps` sidecars, the reference harness in headless Chromium |
26
28
 
27
29
  ## Configuration
@@ -51,6 +53,16 @@ optional, and every config valid for 0.1.0 stays valid:
51
53
  | `bootRetryMaxMs` | integer | 60,000 | 5,000-300,000 |
52
54
  | `topologySources` | integer | 2 | 1-4 |
53
55
  | `warmup` | boolean | false | one echo through a full route before ready |
56
+ | `tls` | `"required"`, `"preferred"`, `"off"` | `"required"` | end-to-end TLS, see [End-to-end TLS](#end-to-end-tls) |
57
+ | `tlsSession` | `"per-call"`, `"keep-alive"` | `"per-call"` | one TLS session per call, or sessions shared by the calls to one host |
58
+ | `tlsSpares` | integer | 2 | 0-4 handshaken spare sessions per recently used host (per-call mode; 1 in 0.4.0) |
59
+ | `tlsSpareTtlMs` | integer | 12,000 | 1,000-50,000; an unused spare is closed this long after its handshake (20,000 in 0.4.0) |
60
+ | `tlsKeepAliveMs` | integer | 30,000 | 1,000-50,000; how long a keep-alive session serves calls after it opened |
61
+ | `tlsMaxCallsPerSession` | integer | 100 | 1-1,000 calls per keep-alive session |
62
+ | `tlsOpenTimeoutMs` | integer | 5,000 | 1,000-30,000; an exit that does not answer a tunnel open in this long is skipped for 10 minutes |
63
+ | `tlsCopyAfterMs` | integer | 2,500 | 500-30,000; another copy of the current tunnel exchange when nothing arrived for this long (raised to the observed p95) |
64
+ | `tlsGapMs` | integer | 800 | 100-10,000; another copy when a gap in the reply stream stays open this long |
65
+ | `tlsMaxCopies` | integer | 3 | 1-5 copies per exchange for silence or a gap |
54
66
 
55
67
  An unknown key, a wrong type or an out-of-range value fails the boot with `bad-config`, naming the field.
56
68
 
@@ -64,6 +76,82 @@ Examples:
64
76
  { gateways: ["100.56.0.72:15005:uEiBVDwIs40bsslDkM-BYb2AOHw3PHe70_bj5U_09r7vdIQ"] }
65
77
  ```
66
78
 
79
+ ## End-to-end TLS
80
+
81
+ The worker carries a TLS client (rustls with ring, compiled to WebAssembly, `packages/nox-tls`) and runs TLS with
82
+ the RPC provider itself. A Nox exit that advertises `tunnel_v1` holds one TCP connection to the provider per tunnel
83
+ and relays TLS records both ways (`ServiceRequest::TunnelV1`, nox `docs/tunnel.md`). Requests and responses stay
84
+ end-to-end encrypted between the worker and the provider, and the mixnet keeps the sender anonymous; the exit relays
85
+ ciphertext.
86
+
87
+ What the exit learns: the provider's host name (from SNI, its own DNS lookup and the IP) and port 443; when a tunnel
88
+ opens and closes; the number, size and timing of TLS records in both directions (a whole JSON request is padded to
89
+ 512 B, 1 KiB, 4 KiB, 16 KiB, then 16 KiB steps, so calls of similar size share one record size; response sizes stay
90
+ visible, and larger buckets are a later milestone); the TLS client fingerprint, shared by every worker of one
91
+ version; in keep-alive mode, which calls share a session. The URL path and query (API keys included), headers,
92
+ JSON-RPC methods and parameters, signed transactions, response content and the sender stay hidden from it, and the
93
+ worker detects any change or truncation of a response.
94
+
95
+ `tls`:
96
+
97
+ | `tls` | https on port 443, host by name | http, another port, or an IP-literal host | no known exit advertises `tunnel_v1` |
98
+ |---|---|---|---|
99
+ | `"required"` (default) | tunnel | rejects `unsupported` | rejects `network-error` |
100
+ | `"preferred"` | tunnel | exit `HttpRequest` (logged `tls.fallback`) | exit `HttpRequest` (logged `tls.fallback`) |
101
+ | `"off"` | exit `HttpRequest` | exit `HttpRequest` | exit `HttpRequest` |
102
+
103
+ The transport is chosen before any byte of a call leaves the worker, from the URL and the capability data, and a
104
+ call that started on a tunnel never continues on `HttpRequest`, redirects included. The capabilities of pinned
105
+ members are the hints in the bundle's snapshot (`snapshot/capabilities.json`), so tunnel exits change, and an exit
106
+ stops counting as one, only with a new bundle; with discovery, other members' capabilities come from the seed's
107
+ liveness data. `"preferred"` uses plaintext whenever no known exit advertises `tunnel_v1`; `"required"` keeps every
108
+ call end-to-end encrypted. An exit that answers `Disabled`, or no tunnel open within `tlsOpenTimeoutMs`, is skipped
109
+ for 10 minutes and the open moves to another tunnel exit; skipped exits still count as tunnel exits, so with every one
110
+ skipped a call fails with `network-error` under both settings.
111
+
112
+ Session modes:
113
+
114
+ - `"per-call"` (default): every call has its own TLS session and `Connection: close`; no resumption, no 0-RTT, a
115
+ fresh key share each time. `tlsSpares` keeps handshaken, unused sessions for hosts the wallet called, opened in
116
+ the background (only after the first wallet call settled) on a random tunnel exit after an exponential delay
117
+ (mean 2 s), each serving one call. While a host was called in the last minute, a spare's replacement opens at
118
+ once 1.5-2 s before its TTL, so a spare is ready across idle gaps; after that an expired spare is not replaced. A
119
+ call that finds no spare while a spare's handshake is halfway done waits for it. Each call travels in its own
120
+ session; at low traffic the timing of a spare's open can still chain sequential calls of one wallet to one host,
121
+ which weakens as traffic grows. The refresh opens each replacement about 10-10.5 s after the spare it replaces,
122
+ so that chain can span calls up to 1 minute apart (about 20 s in 0.4.0); `tlsSpares: 0` turns spares and
123
+ refreshes off.
124
+ - `"keep-alive"`: calls to one host share a session on one exit for `tlsKeepAliveMs`, up to 4 sessions per host.
125
+ The exit links the calls of a session, and the provider sees them on one TLS connection.
126
+
127
+ Latency: a call on a ready session (a spare, or a warm keep-alive session) costs what the exit `HttpRequest` path
128
+ costs. The first call to a host, and calls beyond the ready spares, add one mixnet round trip for the handshake.
129
+
130
+ Measured on 2026-10-10 with the anon-rpc reference bench (ethereum/anon-rpc `impl/site/bench`; worker 0.4.0 with
131
+ its defaults, `eth_getBalance` against a public Ethereum RPC, 39 warm calls per vantage, 40 of 40 calls answered):
132
+ warm median 381 ms from the US (p90 795 ms) and 636 ms from India (p90 1,294 ms); cold boot, median of four,
133
+ 1.5 s from the US and 2.8 s from India. The testnet fleet runs a mix delay of 40 ms per hop, and the worker claims
134
+ replies with claim protocol v2.
135
+
136
+ Certificates are checked against the Mozilla root store compiled into the bundle (webpki-roots; the version and its
137
+ release date are in the provenance, refreshed with every worker release), with the host name and the device clock;
138
+ a wrong clock fails with a message that names it. Each worker release ships its root store, so wallets keep their
139
+ roots current by moving to new worker releases. Resends of a tunnel exchange go to the same exit through another
140
+ entry and mix and carry identical bytes, which the exit writes once; after a lost tunnel only reads that may be resent
141
+ run again on a new tunnel.
142
+
143
+ `tls` governs the wallet's calls. The chain check's registry reads (discovery) use the exit `HttpRequest` path: public
144
+ chain data, accepted only when a quorum of exit and provider pairs agrees byte for byte.
145
+
146
+ Next milestones: registry reads through tunnels as well, Encrypted Client Hello for providers that publish it, and
147
+ a hybrid post-quantum key exchange (X25519MLKEM768).
148
+
149
+ Errors: `permission-denied` (the exit refuses the destination), `network-error` (no tunnel exit, a certificate
150
+ failure, the exit cannot reach the provider), `protocol-error` (TLS or HTTP checks failed, a truncated response),
151
+ `too-large`, `timeout`. Log events: `boot.tls`, `tls.open` (debug: handshake time and CPU), `tls.fallback{reason}`,
152
+ `tls.reject{code}`, `tls.exit.skipped`, `tls.retry`, `tls.call{path, openMs, spareAgeMs, exchangeMs, retries, ok}`; none
153
+ carries bytes or a host name.
154
+
67
155
  ## Discovery (identity from chain, location at run time)
68
156
 
69
157
  The bundle pins `snapshot/nox-bootstrap.json` (`nox-anon-rpc-bootstrap/1`) next to the snapshot, both covered by
@@ -90,9 +178,9 @@ worker first saw it, and a route holds at most one such member. Checks repeat ev
90
178
  served fingerprint differs or every entry failed. Log events: `discovery.verified`, `discovery.disagreement`,
91
179
  `discovery.incomplete`, `discovery.rejected`, `discovery.probation`, `discovery.floor`, `discovery.failed`.
92
180
 
93
- What each party learns: RPC providers see exit IPs and public registry reads, never the wallet's address. Until
94
- TLS runs inside the worker, the exits terminate HTTPS, so the two-exit quorum is attested by the exits; probation
95
- and the snapshot floor bound what a forged answer can change. If every chain path fails, the worker keeps working
181
+ What each party learns: RPC providers see exit IPs and public registry reads, never the wallet's address. Registry
182
+ reads use the exit `HttpRequest` path, so the two-exit quorum is attested by the exits; registry reads through
183
+ tunnels are the next milestone. Probation and the snapshot floor bound what a forged answer can change. If every chain path fails, the worker keeps working
96
184
  on the snapshot floor.
97
185
 
98
186
  Operators move by sending `updateUrl` / `updateMetadataUrl` from the node key (self-service, about 0.00002 ETH);
@@ -125,19 +213,36 @@ were read at, and when each member outside the snapshot was first seen. At most
125
213
  days. A cache that fails any check (another registry, too many records, one malformed record) is ignored whole, and
126
214
  a learned address is used only after the node's `/metadata.json` names the same member.
127
215
 
128
- ## Release 0.2.0
216
+ ## Release 0.4.0
129
217
 
130
218
  | | |
131
219
  |---|---|
132
- | `workerHash` (keccak-256 of `dist/anon-rpc-worker.js`) | `0x0a58f9915f686950072a4786249d396ecbf2194a39ac835effe8ea1a7c76f324` |
133
- | Size | 924,847 bytes; 268,945 bytes with `gzip -9 -n` |
134
- | Specifier | Ethereum Sepolia `0x29b4a6A8Cc11769531854d87f9F33EC63Efe8fe6` (`ImmutableWorkerSpecifier`, resolvers in [specifier/README.md](./specifier/README.md#deployments)) |
135
- | Snapshot | NoxRegistry at Arbitrum Sepolia block 316,207,920, keccak-256 `0xa351960e…94fbb2` |
220
+ | `workerHash` (keccak-256 of `dist/anon-rpc-worker.js`) | `0xd8bef626ec8511d7a468bca5137880680ca2da20d2ea4d9c981a2a3d0c86ba1e` |
221
+ | Size | 1,820,668 bytes; 649,310 bytes with `gzip -9 -n` |
222
+ | sha256 | `4b7b69cd6f45c364cd87ff75f5f8cacdebea2cd852d5384c47215bb88d56f35a` |
223
+ | Specifier, stable address | Ethereum Sepolia `0x35133267A8F7EC35046a83DCA0f4Ca8A6e1b7A9A` (`WorkerSpecifier`, owner-updatable: it follows the current release; trust model in [specifier/README.md](./specifier/README.md#deployments)) |
224
+ | Specifier, 0.4.0 pin | Ethereum Sepolia `0xDf5Db854BA75B52a4bF1a250a93D8d25cB982b2d` (`ImmutableWorkerSpecifier`, resolvers in [specifier/README.md](./specifier/README.md#deployments)) |
225
+ | Snapshot | NoxRegistry at Arbitrum Sepolia block 316,207,920, keccak-256 `0x1090f071…014681` (the 0.3.0 registry state with the exits' `tunnel_v1` hints) |
136
226
  | nox-wasm module | 188,708 bytes, sha256 `5171416972ea4499fd4b4ea8bcadf002f4c10f10ed42cafb0d5a426121da9c4f` |
137
- | Built from | `nox-sdk` commit `ef019a716d7994820d7d63d6de3e60ae8f71e80d`, rustc 1.95.0, wasm-pack 0.13.1, wasm-bindgen 0.2.114, binaryen 117, esbuild 0.27.3 |
227
+ | nox-tls module | 593,738 bytes, sha256 `f74e8cfbed7be33ef1a9158b7b66e454b4ca0849bcb8956487fd13da47e4f4d2` |
228
+ | Root store | webpki-roots 1.0.9 (released 2026-07-18), 121 Mozilla root certificates |
229
+ | Built from | `nox-sdk` commit `fee8d69861a112c65f0cbb0b848a7726ec12f01a`, rustc 1.95.0, clang 19.1.7, wasm-pack 0.13.1, wasm-bindgen 0.2.114, binaryen 117, esbuild 0.27.3 |
230
+
231
+ The npm package carries the bundle and provenance file of the first pinned container build.
232
+
233
+ Each release publishes its own immutable specifier and then repoints the stable address with one `setWorker`
234
+ transaction from its owner ([specifier/README.md](./specifier/README.md#updating-the-stable-address-setworker)), so
235
+ wallets and listings that pin the stable address move to the new worker with nothing to change on their side.
236
+
237
+ Release 0.3.0: `workerHash` `0x24604525d220bcc7e39f2dbc22966a814600a63ada51baafad5dd0bcc1d28549` (961,799 bytes, commit
238
+ `6bddd85`), specifier `0x29B51ca9Ad80E9c0B0D111C8748E6a7908b82eDB`. Release 0.2.0: `workerHash`
239
+ `0x0a58f9915f686950072a4786249d396ecbf2194a39ac835effe8ea1a7c76f324` (924,847 bytes, commit `ef019a7`), specifier
240
+ `0x29b4a6A8Cc11769531854d87f9F33EC63Efe8fe6`.
138
241
 
139
- Two builds in fresh containers of the pinned images (at two different paths) and a host build give these exact
140
- bytes (`scripts/verify-reproducible.sh --native`). Run it from the repository root as
242
+ Two builds in fresh containers of the pinned images (at two different paths) give these exact bytes
243
+ (`scripts/verify-reproducible.sh --ref fee8d69`), on an x86_64 workstation and again on the x86_64 CI runner
244
+ (job "Worker bundle (two container builds)"). The aarch64 reproduction is the next check: the toolchain pins carry
245
+ aarch64 digests for it. Run the script from the repository root as
141
246
  `packages/anon-rpc-worker/scripts/verify-reproducible.sh` (or `pnpm verify:reproducible` inside the package); host builds (`--native`,
142
247
  `--local`) use rustc 1.95.0, so set `RUSTUP_TOOLCHAIN=1.95.0` when the host default differs.
143
248
 
@@ -145,15 +250,16 @@ bytes (`scripts/verify-reproducible.sh --native`). Run it from the repository ro
145
250
 
146
251
  - `dist/anon-rpc-worker.js`: a single IIFE built by esbuild (target es2022, not minified, so it can be read
147
252
  and audited). Every module is inlined: the worker source, the `@hisoka-io/nox-client` sources, the
148
- `nox-wasm` WebAssembly module (base64), the pinned registry snapshot and the discovery bootstrap, each embedded
149
- byte for byte as one string.
253
+ `nox-wasm` and `nox-tls` WebAssembly modules (base64), the pinned registry snapshot and the discovery bootstrap,
254
+ each embedded byte for byte as one string. The TLS module is compiled at boot, off the first call's path.
150
255
  The bundle has no imports and never loads code or WebAssembly by URL. The SDK's ambient `fetch` and
151
256
  `WebSocket` defaults are replaced at build time by a stand-in that fails closed, and the build checks the
152
257
  output for any other network API.
153
258
  - `dist/anon-rpc-worker.js.keccak256`: the Ethereum keccak-256 of those exact bytes, the value a worker
154
259
  specifier pins as `workerHash()`.
155
260
  - `dist/anon-rpc-worker.provenance.json`: source commit, toolchain versions, input digests (snapshot, bootstrap,
156
- WASM, lockfile) and output digests.
261
+ both WASM modules, lockfile), the compiled-in root store (webpki-roots version and release date, and
262
+ `extraRoots`, 0 in every release) and output digests.
157
263
 
158
264
  ## Pinned registry snapshot
159
265
 
@@ -172,13 +278,16 @@ The committed release snapshot is of block 316207920 (finalized, read and re-ver
172
278
  providers of different organisations). It carries the KPS addresses nox-1, nox-2 and nox-8 publish, so the worker
173
279
  finds its entries from the snapshot alone, and the default anchors and the chain check add any later moves.
174
280
  The release gate names the seven mix and exit nodes, which serve the mixnet without a KPS listener, with
175
- `--allow-missing-kps`.
281
+ `--allow-missing-kps`, and requires an exit whose capability hints carry `tunnel_v1`: the hints in
282
+ `snapshot/capabilities.json` come from the seed topology of 2026-10-10 (fleet on nox 0.4.0-rc.9), where the three
283
+ exits advertise `tunnel_v1`. The registry state at that block is the same as in 0.3.0 (fingerprint
284
+ `0x14ca3b69…7c75`); with the new hints the snapshot's keccak-256 is `0x1090f071…014681`.
176
285
 
177
286
  ```bash
178
287
  pnpm --filter @hisoka-io/nox-client build
179
288
  node scripts/make-snapshot.mjs --rpc <arbitrum-sepolia-rpc> --rpc <second-rpc> [--block <n>]
180
289
  node scripts/verify-snapshot.mjs --offline # schema, hashes, SDK-derived fields
181
- node scripts/verify-snapshot.mjs --rpc <rpc> --rpc <rpc> --release # chain re-read + every member publishes a KPS address
290
+ node scripts/verify-snapshot.mjs --rpc <rpc> --rpc <rpc> --release # chain re-read, KPS addresses, a tunnel exit
182
291
  ```
183
292
 
184
293
  ## Building and checking the hash
@@ -190,10 +299,11 @@ node packages/anon-rpc-worker/scripts/hash.mjs packages/anon-rpc-worker/dist/ano
190
299
  ```
191
300
 
192
301
  `scripts/toolchain.env` pins every tool that shapes the bytes: the Node and Rust container images by digest,
193
- rustc, wasm-pack, wasm-bindgen and binaryen (with sha256 digests of their release archives); pnpm comes from the
194
- repository's `packageManager` field and esbuild from the lockfile. `scripts/build-wasm.sh` follows the
195
- tor-js recipe: path remapping, a `RUSTC_WRAPPER` that makes cargo's metadata host-independent, `--locked`, and
196
- a pinned `wasm-opt` on `PATH`.
302
+ rustc, wasm-pack, wasm-bindgen and binaryen (with sha256 digests of their release archives), and clang with llvm-ar
303
+ for ring's C sources in `nox-tls` (Debian bookworm packages at a snapshot.debian.org timestamp, the same on x86_64
304
+ and aarch64); pnpm comes from the repository's `packageManager` field and esbuild from the lockfile.
305
+ `scripts/build-wasm.sh` follows the tor-js recipe: path remapping, a `RUSTC_WRAPPER` that makes cargo's metadata
306
+ host-independent, `--locked`, and a pinned `wasm-opt` on `PATH`.
197
307
 
198
308
  `scripts/verify-reproducible.sh` builds a commit twice from clean `git archive` exports in fresh containers
199
309
  of the pinned images, at two different paths, and compares the WASM, bundle and provenance digests
@@ -202,12 +312,14 @@ container builds and requires all three to match; `--local --bwrap` runs the two
202
312
  namespaces at the container paths, for hosts without a container runtime.
203
313
 
204
314
  For a test bed, `node scripts/build.mjs --snapshot <file>` embeds another canonical snapshot (for example a
205
- local mesh); the provenance records which snapshot a bundle carries.
315
+ local mesh) and `--extra-root <ca.der>` a test CA next to the Mozilla roots; the provenance records which snapshot a
316
+ bundle carries and how many extra roots. The release build (`build-worker.sh --release`) refuses a bundle with an
317
+ extra root (`provenance.mjs --release`).
206
318
 
207
319
  ## Tests
208
320
 
209
321
  ```bash
210
- pnpm --filter @hisoka-io/anon-rpc-worker test # worker, SDK integration over an in-memory KPS network, build
322
+ pnpm --filter @hisoka-io/anon-rpc-worker test # worker, SDK integration over an in-memory KPS network, build, e2e bed unit tests
211
323
  pnpm --filter @hisoka-io/anon-rpc-specifier test # Foundry and harness-path tests (forge, anvil)
212
324
  ```
213
325