@kusamashield/mixnet-rpc
v0.1.0
Published
Nym and Tor privacy clients that wrap EVM JSON-RPC (ethers) and Polkadot/Substrate API calls for Kusama Shield.
Maintainers
Readme
@kusamashield/mixnet-rpc
Nym and Tor privacy clients that wrap EVM JSON-RPC (ethers v6) and Polkadot/Substrate API calls. Framework-agnostic, browser-first, extracted from the Kusama Shield interface.
- Nym — HTTP(S) through
mixFetchand WebSockets through the smolmix mixnet tunnel. Hides your IP from RPC providers and can tunnel Polkadot WSS. - Tor — HTTP(S) through the webtor-rs WASM client (Snowflake bridge). Tor cannot tunnel WebSockets.
- Wrappers — privacy-aware
fetch, an ethersJsonRpcProvider, and a globalWebSocketshim so@polkadot/apiconnects over Nym.
Browser only. Nym is WASM + a Web Worker and Tor is WASM; neither runs under Node. This package is ESM-only.
Install
npm install @kusamashield/mixnet-rpcThe Nym SDK (@nymproject/mix-fetch, @nymproject/mix-tunnel) is a regular
dependency. The Tor WASM (webtor-rs) is vendored, so no extra download.
Integration wrappers need optional peer dependencies:
npm install ethers # for @kusamashield/mixnet-rpc/ethers
npm install @polkadot/api @polkadot/rpc-provider # for .../polkadotQuick start
import {
enableNym,
enableTor,
disablePrivacy,
privacyFetch,
onPrivacyChange,
} from "@kusamashield/mixnet-rpc";
onPrivacyChange((s) => console.log(s.mode, s.status, s.message));
await enableNym(); // or enableTor()
const res = await privacyFetch("https://eth-rpc.polkadot.io", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }),
});
console.log(await res.json());
await disablePrivacy();privacyFetch behaves exactly like fetch: it routes JSON-RPC payloads (and
URLs matching the configured RPC host hints) through the active transport, and
falls back to the native fetch when no privacy mode is active or the request
is not proxyable.
Cookbook: read a balance and the latest block
The two recipes below are self-contained. They work with any transport
(off, tor, nym); just call the enable* function you want, or skip it to
go direct. The full, runnable versions live in
test/browser-tests.ts.
With an EVM / ETH JSON-RPC endpoint (ethers v6)
import { enableNym, disablePrivacy, rpcTimeoutMs } from "@kusamashield/mixnet-rpc";
import { createPrivacyProvider } from "@kusamashield/mixnet-rpc/ethers";
// Optional: route the EVM RPC through Tor or the Nym mixnet.
await enableNym(); // or enableTor(), or omit to go direct
const provider = createPrivacyProvider("https://eth-rpc.polkadot.io");
// pass a network id/object to skip auto-detection: createPrivacyProvider(url, 420420419)
// Latest block number.
const latestBlock = await provider.getBlockNumber();
// Native balance (wei) for any address.
const balanceWei = await provider.getBalance("0x0D694Da746e73D1e255c1894F90e38170db45809");
console.log("latest block:", latestBlock, "balance:", balanceWei.toString());
await provider.destroy();
await disablePrivacy();createPrivacyProvider(url, network?, options?) accepts the same arguments as
ethers' JsonRpcProvider. Because it installs a privacy-aware getUrlFunc on
the provider's FetchRequest, every JSON-RPC call (getBlockNumber,
getBalance, call, estimateGas, ...) is routed through the active
transport. Set createPrivacyProvider(url, undefined, { staticNetwork: true })
to avoid the initial network-detection round trips.
With Polkadot / Substrate (@polkadot/api)
Import the /polkadot entry before @polkadot/api so @polkadot/x-ws
captures the WebSocket shim (see the section below for why):
import "@kusamashield/mixnet-rpc/polkadot";
import { ApiPromise } from "@polkadot/api";
import { createPolkadotProvider } from "@kusamashield/mixnet-rpc/polkadot";
import { enableNym, disablePrivacy, rpcTimeoutMs } from "@kusamashield/mixnet-rpc";
// Polkadot WSS can only be tunnelled by Nym (Tor has no WebSocket support).
await enableNym();
const provider = await createPolkadotProvider(
"wss://polkadot-asset-hub-rpc.polkadot.io",
{ timeout: rpcTimeoutMs(30_000) }, // widened while Nym is active
);
const api = await ApiPromise.create({ provider, noInitWarn: true });
// Latest block.
const header = await api.rpc.chain.getHeader();
console.log("chain:", (await api.rpc.system.chain()).toString(), "block:", header.number.toString());
// Native balance (planck) via system.account.
const account = await api.query.system.account(
"5Dxr9EoL7ChvxPJAsQ8gZe1Dfbwh9AEeDmB3KpbCrihLU85u",
);
// At runtime `account.data.free` is a BN; cast for TypeScript.
const free = (account as any).data.free.toString();
console.log("free:", free);
await api.disconnect();
await disablePrivacy();Or use the one-shot helper that builds both the provider and the API:
import { createPolkadotApi } from "@kusamashield/mixnet-rpc/polkadot";
const api = await createPolkadotApi("wss://polkadot-asset-hub-rpc.polkadot.io");Running in a browser:
@polkadot/apiexpects a globalBuffer. Add a polyfill before importing it (the test page does this intest/polyfills.ts):import { Buffer } from "buffer"; (globalThis as any).Buffer ??= Buffer;
ethers v6
import { enableNym } from "@kusamashield/mixnet-rpc";
import { createPrivacyProvider } from "@kusamashield/mixnet-rpc/ethers";
await enableNym();
const provider = createPrivacyProvider("https://eth-rpc.polkadot.io", 420420419);
const block = await provider.getBlockNumber();The provider installs a custom getUrlFunc on its FetchRequest, so every
JSON-RPC round trip goes through the active transport. You can also get the raw
pieces:
import { createEthersFetchRequest, privacyGetUrl } from "@kusamashield/mixnet-rpc/ethers";Polkadot / Substrate
Import the /polkadot entry before @polkadot/api, so that
@polkadot/x-ws captures the shim (it reads globalThis.WebSocket at
module-eval time):
import "@kusamashield/mixnet-rpc/polkadot"; // installs the WebSocket shim
import { ApiPromise, WsProvider } from "@polkadot/api";
import { enableNym } from "@kusamashield/mixnet-rpc";
await enableNym();
// WSS to known Polkadot RPC hosts is now tunnelled through Nym.
const api = await ApiPromise.create({
provider: new WsProvider("wss://kusama-asset-hub-rpc.polkadot.io"),
});Or use the helpers, which dynamic-import the provider/API:
import {
createPolkadotProvider,
createPolkadotApi,
isPolkadotUsingShim,
} from "@kusamashield/mixnet-rpc/polkadot";
const provider = await createPolkadotProvider("wss://kusama-asset-hub-rpc.polkadot.io");
const api = await createPolkadotApi("wss://kusama-asset-hub-rpc.polkadot.io");
console.log(await isPolkadotUsingShim()); // true when import order is correctWhile Nym is active, use rpcTimeoutMs(base) to widen timeouts: Substrate
metadata is ~1.3 MB and can take 30-60s+ over the mixnet.
import { rpcTimeoutMs } from "@kusamashield/mixnet-rpc";
const timeout = rpcTimeoutMs(15_000); // 180_000 while Nym is activeGlobal fetch interceptor (optional)
To transparently proxy third-party libraries that call fetch directly:
import { enableNym, installFetchInterceptor } from "@kusamashield/mixnet-rpc";
installFetchInterceptor();
await enableNym();Call uninstallFetchInterceptor() to restore the original fetch.
Configuration
import { configureMixnet, getMixnetConfig, resetMixnetConfig } from "@kusamashield/mixnet-rpc";
configureMixnet({
rpcHostHints: ["my-rpc.example.com"], // replaces defaults
wsHostHints: ["my-ws.example.com"],
excludeUrlPatterns: ["/api/"], // never proxied
nymRpcTimeoutMs: 180_000,
});
// Append instead of replacing:
configureMixnet({ rpcHostHints: ["another.example.com"] }, { append: true });Tor options
import { enableTor } from "@kusamashield/mixnet-rpc";
await enableTor({
snowflakeUrl: "wss://snowflake.torproject.net/",
connectionTimeoutMs: 60_000,
circuitTimeoutMs: 120_000,
// Bundlers that mangle the vendored asset can point at their own copy:
// wasmUrl: "/webtor-proxy/webtor_wasm.js",
});The vendored WASM lives at
@kusamashield/mixnet-rpc/wasm/webtor-proxy/webtor_wasm.js (and
webtor_wasm_bg.wasm). It is loaded via
new URL("../wasm/webtor-proxy/webtor_wasm.js", import.meta.url) by default;
pass wasmUrl if your bundler does not preserve the package layout.
API
Mode control
| Export | Description |
|--------|-------------|
| enableNym(opts?) | Connect the Nym mixnet (disconnects Tor first). |
| enableTor(opts?) | Connect Tor via Snowflake (disconnects Nym first). |
| disablePrivacy(opts?) | Tear down transports and go direct. |
Clients
| Export | Description |
|--------|-------------|
| connectNym(opts?) / disconnectNym(opts?) / isNymConnected() | Nym lifecycle. |
| nymFetch(url, init?) | Fetch through Nym. |
| openNymWebSocket(url, protocols, handlers) | Raw Nym WebSocket handle. |
| connectTor(opts?) / disconnectTor() / isTorConnected() | Tor lifecycle. |
| torRequest(url, init?) | Fetch through Tor. |
State
| Export | Description |
|--------|-------------|
| getPrivacyState() | { mode, status, message } snapshot. |
| onPrivacyChange(cb) | Subscribe to state changes. |
| isPrivacyActive() / isNymActive() / isTorActive() | Status predicates. |
| rpcTimeoutMs(base) | Nym-aware timeout. |
Wrappers
| Export | Entry | Description |
|--------|-------|-------------|
| privacyFetch | . | Privacy-aware fetch. |
| installFetchInterceptor / uninstallFetchInterceptor | . | Global fetch override. |
| NymAwareWebSocket | . / ./polkadot | WebSocket-compatible class. |
| installWebSocketShim / uninstallWebSocketShim | . / ./polkadot | Global WebSocket override. |
| createEthersFetchRequest / createPrivacyProvider / privacyGetUrl | ./ethers | ethers v6 integration. |
| createPolkadotProvider / createPolkadotApi / isPolkadotUsingShim | ./polkadot | Polkadot integration. |
Testing
Two suites are included.
Unit tests (Node, fast)
Pure logic — config, state machine, request routing/helpers, WebSocket shim:
npm test # or: npm run test:unitBrowser integration tests (real RPCs)
The browser test queries both an EVM JSON-RPC endpoint (balance + latest
block via ethers) and a Polkadot endpoint (balance + latest block via
@polkadot/api), and can route either through a privacy transport. Nym and Tor
are WASM + Web Worker, so this runs in headless Chromium via a small CDP runner:
npm run test:browser # mode=off (direct; reliable, default)
npm run test:browser:nym # route through the Nym mixnet
npm run test:browser:tor # route through Tor (HTTP JSON-RPC only)Override endpoints/addresses with flags (or use --flag=value):
node scripts/run-browser-tests.mjs \
--mode=off \
--eth-rpc=https://eth-rpc-kusama.polkadot.io \
--eth-address=0x... \
--ws=wss://kusama-asset-hub-rpc.polkadot.io \
--ss58=5...| Flag | Default |
|------|---------|
| --mode=off\|nym\|tor | off |
| --eth-rpc | https://eth-rpc.polkadot.io |
| --eth-address | the Polkadot AssetHub pool |
| --ws | wss://polkadot-asset-hub-rpc.polkadot.io |
| --ss58 | a Kusama test account |
| --timeout | 360000 ms |
It needs the chromium binary (CHROMIUM_BIN to override) and network access.
The mode=off run is deterministic and is what you should use in CI; the
nym/tor runs depend on external services and may fail intermittently (see
the notes below). Example result:
{
"mode": "off",
"usingShim": true,
"eth": { "blockNumber": 21029611, "balanceWei": "27670000003000000000" },
"polkadot": { "chain": "Polkadot Asset Hub", "block": "21029613", "freePlanck": "53703807456" }
}Notes and caveats
- Nym tunnel is a singleton. After
disconnectMixTunnel()the WASM is unusable until a full page reload.disconnectNym()only marks the transport inactive so it can be re-enabled; pass{ teardown: true }to actually tear it down. - Import order matters for Polkadot.
@polkadot/x-wscapturesglobalThis.WebSocketwhen it is first evaluated. Import@kusamashield/mixnet-rpc/polkadotbefore any@polkadot/apiimport.isPolkadotUsingShim()verifies this. - Tor cannot tunnel WebSockets — only HTTP(S) JSON-RPC.
- Tor consensus (upstream): webtor-rs loads a cached Tor consensus from
https://privacy-ethereum.github.io/webtor-rs/consensus.txt.br. As of 2026-09-24 those files return 404, soconnectTor()fails withFailed to load cached consensus: HTTP error: 404. This is upstream in webtor-rs, not in this package; bump the vendoredwebtor-wasm(or self-host the consensus) when a fixed build is published. - Nym connectivity is flaky per run (gateway/IPR selection). HTTP
mixFetchmay time out or return an empty body, and Nym WSS may close with1006. Retries, or a fresh browser profile /clientId, usually help. - First Polkadot connect over Nym downloads ~1.3 MB of runtime metadata and
can take 30-60s+. Cache or prefetch it in your app and widen timeouts with
rpcTimeoutMs. - WASM needs the right MIME type and, for some setups, cross-origin
isolation. The interface disables COOP/COEP for wallet SDK compatibility; the
Nym worker does not require
SharedArrayBuffer.
License
MIT. Vendors webtor-rs (MIT) and depends on the Nym mixnet SDK (Apache-2.0).
