From db72111ac0897573577faa118385ad32ae0158e0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 15 Jul 2026 17:19:50 -0700 Subject: [PATCH 1/2] Revert "Upgrade NYM mix-fetch to v2" This reverts commit 816be23626a6748fb5b96d41cfa08b3153938187, returning to @nymproject/mix-fetch 1.4.4 with the pinned gateway and network requester. A controlled A/B against the live mixnet (both stacks in one browser, driven concurrently) shows the v2 stack failing to complete small HTTPS JSON-RPC requests through most exit nodes (internal ~120s timeout on a tunnel that reports ready), while 1.4.4 completes the same requests in 2-3 seconds at the same instant. v2's exit-node auto-discovery also rarely converges, and the upgrade dropped the pins that made node selection deterministic, so wallets with NYM privacy enabled were left unable to sync or send (the 2026-07-15 staging QA report). --- CHANGELOG.md | 2 + package-lock.json | 18 ++------- package.json | 2 +- src/io/browser/browser-io.ts | 11 +++++- src/io/react-native/react-native-worker.ts | 12 +++++- src/util/nym.ts | 43 ++++++++++++---------- webpack.config.js | 24 ++++++++++++ 7 files changed, 73 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a3dc8ae..59630f22b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- fixed: Revert `@nymproject/mix-fetch` to v1 (1.4.4), restoring the pinned gateway and network requester. The v2 stack shipped in 2.47.0 fails to complete small HTTPS JSON-RPC requests through most exit nodes and its exit-node auto-discovery rarely converges, which left wallets with NYM privacy enabled unable to sync or send. + ## 2.47.0 (2026-07-11) - changed: Upgrade `@nymproject/mix-fetch` to v2, which routes NYM mixnet traffic through the new smolmix-wasm tunnel (`@nymproject/mix-tunnel`). The v2 wasm + worker are inlined into the bundle, so the build no longer copies sibling `.wasm`/`web-worker-*.js` assets. diff --git a/package-lock.json b/package-lock.json index ed8112bfe..4a2d85b8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.47.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@nymproject/mix-fetch": "^2.0.0", + "@nymproject/mix-fetch": "^1.4.4", "aes-js": "^3.1.0", "base-x": "^4.0.1", "biggystring": "^4.2.3", @@ -2153,19 +2153,9 @@ } }, "node_modules/@nymproject/mix-fetch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@nymproject/mix-fetch/-/mix-fetch-2.0.0.tgz", - "integrity": "sha512-dTHPMpd1Zhj4ZauN8vnxOq7gzO3N/ikaQoNvWwl1Xq6niQxqoLw/gKbsKxwrgvbRodhjkKrCqW++0zumTfi7pw==", - "license": "Apache-2.0", - "dependencies": { - "@nymproject/mix-tunnel": "0.1.0" - } - }, - "node_modules/@nymproject/mix-tunnel": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@nymproject/mix-tunnel/-/mix-tunnel-0.1.0.tgz", - "integrity": "sha512-olm+nue1rW7PyR9hMHbOdEZ/ytRXjEqHh1kGPfw8ZWKV/e+5Gh43mngyxFrZYqlQMVntlRXwkiJkB9bYWFTxxw==", - "license": "Apache-2.0" + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@nymproject/mix-fetch/-/mix-fetch-1.4.4.tgz", + "integrity": "sha1-8Q+94XJVwW+d6MPdboyPScTRLIg= sha512-sdyXXJG7sYv2OFOEf6FYm7HglKfMvJmJjrQC3Rbusy5rH5C2ajg2KyuBbZReLgIIbkkx3mK8sc5WRUOKTcKt2Q==" }, "node_modules/@pkgr/utils": { "version": "2.4.2", diff --git a/package.json b/package.json index c5f88b840..3c0aeecbe 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "*.{js,jsx,ts,tsx}": "eslint" }, "dependencies": { - "@nymproject/mix-fetch": "^2.0.0", + "@nymproject/mix-fetch": "^1.4.4", "aes-js": "^3.1.0", "base-x": "^4.0.1", "biggystring": "^4.2.3", diff --git a/src/io/browser/browser-io.ts b/src/io/browser/browser-io.ts index 08ddb102e..929582467 100644 --- a/src/io/browser/browser-io.ts +++ b/src/io/browser/browser-io.ts @@ -3,7 +3,7 @@ import { makeLocalStorageDisklet } from 'disklet' import { LogBackend, makeLog } from '../../core/log/log' import { EdgeFetchOptions, EdgeFetchResponse, EdgeIo } from '../../types/types' import { scrypt } from '../../util/crypto/scrypt' -import { initMixFetch } from '../../util/nym' +import { initMixFetch, mixFetchOptions } from '../../util/nym' import { fetchCorsProxy } from './fetch-cors-proxy' // Only try CORS proxy/bridge techniques up to 5 times @@ -50,7 +50,14 @@ export function makeBrowserIo(logBackend: LogBackend): EdgeIo { if (privacy === 'nym') { const nymFetch = await initMixFetch(log) - return await nymFetch(uri, opts) + return await nymFetch( + uri, + { + ...opts, + mode: 'unsafe-ignore-cors' as RequestMode + }, + mixFetchOptions + ) } if (corsBypass === 'always') { return await fetchCorsProxy(uri, opts) diff --git a/src/io/react-native/react-native-worker.ts b/src/io/react-native/react-native-worker.ts index c41755247..4eb777c89 100644 --- a/src/io/react-native/react-native-worker.ts +++ b/src/io/react-native/react-native-worker.ts @@ -17,7 +17,7 @@ import { EdgeFetchResponse, EdgeIo } from '../../types/types' -import { initMixFetch } from '../../util/nym' +import { initMixFetch, mixFetchOptions } from '../../util/nym' import { hideProperties } from '../hidden-properties' import { makeNativeBridge } from './native-bridge' import { WorkerApi, YAOB_THROTTLE_MS } from './react-native-types' @@ -177,7 +177,15 @@ async function makeIo(logBackend: LogBackend): Promise { if (privacy === 'nym') { const nymFetch = await initMixFetch(log) - return await nymFetch(uri, opts) + const response = await nymFetch( + uri, + { + ...opts, + mode: 'unsafe-ignore-cors' as RequestMode + }, + mixFetchOptions + ) + return response } if (corsBypass === 'always') { return await nativeFetch(uri, opts) diff --git a/src/util/nym.ts b/src/util/nym.ts index 43a0582cd..f2bf15eaf 100644 --- a/src/util/nym.ts +++ b/src/util/nym.ts @@ -1,53 +1,56 @@ import { createMixFetch, - disconnectMixTunnel, - SetupMixTunnelOpts + disconnectMixFetch, + IMixFetch, + IMixFetchFn, + SetupMixFetchOps } from '@nymproject/mix-fetch' import { EdgeLog } from '../types/types' -/** The fetch-bound function `createMixFetch` resolves to. */ -type MixFetchFn = (url: string, init?: RequestInit) => Promise - /** - * Configuration options for the NYM mixFetch tunnel. + * Configuration options for the NYM mixFetch client. */ -export const mixFetchOptions: SetupMixTunnelOpts = { +export const mixFetchOptions: SetupMixFetchOps = { clientId: 'edge-core-js-2026-03-10', + preferredGateway: '5rXcNe2a44vXisK3uqLHCzpzvEwcnsijDMU7hg4fcYk8', // with WSS + preferredNetworkRequester: + '5x6q9UfVHs5AohKMUqeivj7a556kVVy7QwoKige8xHxh.6CFoB3kJaDbYz6oafPJxNxNjzahpT2NtgtytcSyN9EvF@5rXcNe2a44vXisK3uqLHCzpzvEwcnsijDMU7hg4fcYk8', forceTls: true, // force WSS - // Mixnet round trips are slow, so give the tunnel handshake plenty of time. - // v1 tuned a 5 min `requestTimeoutMs`; v2 exposes no per-request timeout, but - // the tunnel setup is where mixnet latency bites, so restore that 5 min - // budget here to avoid premature failures during the handshake. - connectTimeoutMs: 300000 + mixFetchOverride: { + requestTimeoutMs: 300000 + } } // MixFetch initialization state -let mixFetchInitPromise: Promise | null = null +let mixFetchInitPromise: Promise | null = null /** * Initialize the NYM mixFetch client. Must be called before using mixFetch. * Safe to call multiple times - subsequent calls return the same promise. */ -export async function initMixFetch(log: EdgeLog): Promise { +export async function initMixFetch(log: EdgeLog): Promise { if (mixFetchInitPromise == null) { log('Initializing mixFetch...') mixFetchInitPromise = createMixFetch(mixFetchOptions) - .then(mixFetch => { + .then(mixFetchModule => { log('mixFetch initialized successfully') - return mixFetch + return mixFetchModule }) .catch(async error => { - // Tear down any partially-established tunnel left by the failed init - // so the next createMixFetch call starts fresh instead of reusing a + // Clean up stale global state left by the failed init so the + // next createMixFetch call starts fresh instead of reusing a // broken singleton. try { - await disconnectMixTunnel() + await disconnectMixFetch() } catch {} + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (window as any).__mixFetchGlobal mixFetchInitPromise = null log.error('mixFetch initialization failed:', error) throw error }) } - return await mixFetchInitPromise + const mixFetchModule = await mixFetchInitPromise + return mixFetchModule.mixFetch } diff --git a/webpack.config.js b/webpack.config.js index 76b53a441..6112f2718 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -50,6 +50,9 @@ module.exports = { static: bundlePath }, entry: './src/io/react-native/react-native-worker.ts', + experiments: { + asyncWebAssembly: true + }, mode: debug ? 'development' : 'production', module: { rules: [ @@ -72,6 +75,10 @@ module.exports = { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } + }, + { + test: /\.wasm$/, + type: 'webassembly/async' } ] }, @@ -85,12 +92,29 @@ module.exports = { plugins: [ new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), new webpack.ProvidePlugin({ process: ['process'] }), + // Copy static files and mix-fetch WASM/worker files new CopyPlugin({ patterns: [ // HTML entry point { from: path.resolve(__dirname, 'src/index.html'), to: 'index.html' + }, + // mix-fetch WASM files for NYM mixnet support + { + from: path.resolve( + __dirname, + 'node_modules/@nymproject/mix-fetch/*.wasm' + ), + to: '[name][ext]' + }, + // mix-fetch web worker files + { + from: path.resolve( + __dirname, + 'node_modules/@nymproject/mix-fetch/web-worker-*.js' + ), + to: '[name][ext]' } ] }) From a323e15f2ba62998d1ba85b1e7516318a5c96a09 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 15 Jul 2026 17:20:48 -0700 Subject: [PATCH 2/2] Bound the NYM mixFetch setup createMixFetch has no internal budget, so a dead or unreachable gateway kept the first mixnet request (and the wallet behind it) waiting indefinitely. Race it against a 60 second bound: a healthy setup with the pinned gateway completes in under 10 seconds measured, and on timeout the abandoned setup is torn down when it eventually settles so it cannot hold a gateway connection nobody will use. --- CHANGELOG.md | 1 + src/util/nym.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59630f22b..7094adea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - fixed: Revert `@nymproject/mix-fetch` to v1 (1.4.4), restoring the pinned gateway and network requester. The v2 stack shipped in 2.47.0 fails to complete small HTTPS JSON-RPC requests through most exit nodes and its exit-node auto-discovery rarely converges, which left wallets with NYM privacy enabled unable to sync or send. +- fixed: Bound the NYM mixFetch setup at 60 seconds, so a dead gateway surfaces an error instead of blocking the first mixnet request indefinitely. ## 2.47.0 (2026-07-11) diff --git a/src/util/nym.ts b/src/util/nym.ts index f2bf15eaf..04cb8ff28 100644 --- a/src/util/nym.ts +++ b/src/util/nym.ts @@ -22,6 +22,16 @@ export const mixFetchOptions: SetupMixFetchOps = { } } +/** + * Budget for `createMixFetch` itself (client start + gateway handshake). + * + * A healthy setup with the pinned gateway completes in under 10s measured. + * Without a bound here the whole app blocks on the first mixnet request for + * as long as a dead gateway keeps us waiting, which reads to the user as a + * freeze. + */ +const SETUP_TIMEOUT_MS = 60000 + // MixFetch initialization state let mixFetchInitPromise: Promise | null = null @@ -32,7 +42,23 @@ let mixFetchInitPromise: Promise | null = null export async function initMixFetch(log: EdgeLog): Promise { if (mixFetchInitPromise == null) { log('Initializing mixFetch...') - mixFetchInitPromise = createMixFetch(mixFetchOptions) + const pending = createMixFetch(mixFetchOptions) + // The timeout below can abandon this setup while it is still in flight. + // Deliberately do NOT tear it down on late completion: `createMixFetch` + // resolves to a healthy global singleton, and disconnecting it (a + // process-wide operation) would race a newer init that has taken over. + // A late completion just repopulates `__mixFetchGlobal`, which the next + // init reuses. Swallow a late rejection so it is not unhandled. + pending.catch(() => {}) + let timer: ReturnType | undefined + const timeout = new Promise((resolve, reject) => { + timer = setTimeout(() => { + reject( + new Error(`mixFetch setup timed out after ${SETUP_TIMEOUT_MS}ms`) + ) + }, SETUP_TIMEOUT_MS) + }) + mixFetchInitPromise = Promise.race([pending, timeout]) .then(mixFetchModule => { log('mixFetch initialized successfully') return mixFetchModule @@ -50,6 +76,9 @@ export async function initMixFetch(log: EdgeLog): Promise { log.error('mixFetch initialization failed:', error) throw error }) + .finally(() => { + clearTimeout(timer) + }) } const mixFetchModule = await mixFetchInitPromise return mixFetchModule.mixFetch