Compare commits

...

12 Commits

Author SHA1 Message Date
mfahampshire 520b880b1b sigh wss wildcard CSP for IPR playground 2026-06-10 23:39:53 +02:00
mfahampshire b96dc11558 Coderabbit 2026-06-10 10:19:43 +01:00
mfahampshire 7b076ba212 Merge branch 'max/wasm-demos-docs-components' of github.com:nymtech/nym into max/wasm-demos-docs-components 2026-06-09 19:26:18 +01:00
mfahampshire 39504b1d5f tweaks3 2026-06-09 19:26:02 +01:00
mfahampshire d30bbb3f2a Update documentation/docs/pages/developers/demos/railgun.mdx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-09 18:13:43 +00:00
mfahampshire 9d4690d5ad Merge branch 'develop' into max/wasm-demos-docs-components 2026-06-09 18:12:39 +00:00
mfahampshire 6cfa88c0b9 tweaks2 2026-06-09 19:11:10 +01:00
mfahampshire 181e1f7526 tweaks 2026-06-09 19:04:43 +01:00
mfahampshire 69c54674cf fix build CI 2026-06-09 17:26:43 +01:00
mfahampshire d15c47dbde demos fix linting 2026-06-09 17:17:16 +01:00
mfahampshire 7529cde148 demos second pass 2026-06-09 17:07:49 +01:00
mfahampshire 9ca0f32c47 demos added to docs 2026-06-09 16:35:52 +01:00
23 changed files with 7698 additions and 242 deletions
@@ -0,0 +1,244 @@
// ENS-over-the-mixnet demo, ported from wasm/ens-demo. Resolve <name>.eth to an
// address + contenthash, then fetch the IPFS site, every byte through mixFetch.
// The tunnel lifecycle + options live in <MixTunnelSetup>; this component owns
// the ENS flow and receives a `mixFetch` when the tunnel is ready.
import React, { useRef, useState } from 'react';
import type { JsonRpcProvider } from 'ethers';
import { MixTunnelSetup, type MixFetchFn } from '../shared/mixTunnel';
import { Button, LogPanel, useLogs, box, row, input, sub, legend } from '../shared/ui';
import { buildProvider, callMixFetch, decompressBody, expandGatewayUrl, formatSize, htmlFingerprint, renderFingerprint } from './lib';
const NAME_PRESETS = ['vitalik.eth', 'ens.eth', 'gregskril.eth', 'raffy.eth', 'luc.eth'];
const RPC_PRESETS = ['https://ethereum-rpc.publicnode.com', 'https://rpc.ankr.com/eth', 'https://eth.public-rpc.com'];
const GATEWAY_PRESETS = ['https://{cid}.ipfs.dweb.link/', 'https://dweb.link/ipfs/{cid}/'];
const IP_ECHO_URL = 'https://ipinfo.io/ip';
const IP_SHAPE_RE = /^[\d.:a-f]{3,45}$/i;
const preStyle: React.CSSProperties = {
maxHeight: 240,
overflowY: 'auto',
fontSize: 12.5,
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
background: 'rgba(127,127,127,0.06)',
border: '1px solid rgba(127,127,127,0.2)',
borderRadius: 6,
padding: '0.5rem',
margin: '0.5rem 0 0',
};
export function EnsDemo() {
const { log, lines } = useLogs();
const [mixFetch, setMixFetch] = useState<MixFetchFn | null>(null);
const providerRef = useRef<JsonRpcProvider | null>(null);
const [ensName, setEnsName] = useState('vitalik.eth');
const [ensRpc, setEnsRpc] = useState(RPC_PRESETS[0]);
const [gateway, setGateway] = useState(GATEWAY_PRESETS[0]);
const [customCid, setCustomCid] = useState('');
const [lastCid, setLastCid] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [verifyLink, setVerifyLink] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const connected = mixFetch != null;
const ensLog = (msg: string, colour?: 'green' | 'red' | 'orange' | 'gray') => log('ens', msg, colour);
function ensureProvider(): JsonRpcProvider {
if (providerRef.current) return providerRef.current;
const rpc = ensRpc.trim();
if (!rpc) throw new Error('RPC URL is required');
ensLog(`building JsonRpcProvider({ rpc: ${rpc}, transport: mixFetch })`);
providerRef.current = buildProvider(rpc, mixFetch!, ensLog);
return providerRef.current;
}
function onReady(fn: MixFetchFn) {
setMixFetch(() => fn);
}
function onDisconnect() {
setMixFetch(null);
providerRef.current = null;
setLastCid(null);
}
async function resolveAddress() {
const name = ensName.trim();
if (!name) return ensLog('Name is required', 'red');
let provider: JsonRpcProvider;
try {
provider = ensureProvider();
} catch (e) {
return ensLog(`${e}`, 'red');
}
ensLog(`step 1/3: resolving ${name} via ENS Registry + Resolver`);
const t0 = performance.now();
try {
const addr = await provider.resolveName(name);
const ms = (performance.now() - t0).toFixed(0);
if (addr) ensLog(`${name} -> ${addr} (${ms} ms total)`, 'green');
else ensLog(`${name} has no addr record (${ms} ms)`, 'orange');
} catch (e: any) {
ensLog(`resolveName failed: ${e.shortMessage || e.message || e}`, 'red');
}
}
async function getContenthash() {
const name = ensName.trim();
if (!name) return ensLog('Name is required', 'red');
let provider: JsonRpcProvider;
try {
provider = ensureProvider();
} catch (e) {
return ensLog(`${e}`, 'red');
}
ensLog(`step 2/3: reading contenthash record from ${name}'s resolver`);
const t0 = performance.now();
try {
const resolver = await provider.getResolver(name);
if (!resolver) return ensLog(`${name} has no resolver`, 'orange');
const content = await resolver.getContentHash();
const ms = (performance.now() - t0).toFixed(0);
if (!content) return ensLog(`${name} has no contenthash (${ms} ms)`, 'orange');
ensLog(`contenthash: ${content} (${ms} ms total)`, 'green');
const ipfsMatch = content.match(/^ipfs:\/\/(.+)$/);
if (ipfsMatch) {
setLastCid(ipfsMatch[1]);
ensLog(`decoded CID: ${ipfsMatch[1]}`);
} else {
ensLog('non-IPFS scheme in contenthash; nothing to fetch', 'orange');
}
} catch (e: any) {
ensLog(`contenthash lookup failed: ${e.shortMessage || e.message || e}`, 'red');
}
}
async function fetchIpfsCid(cid: string, label: string) {
if (!cid) return ensLog(`${label}: CID is required`, 'red');
const gw = gateway.trim();
if (!gw) return ensLog('IPFS gateway is required', 'red');
const url = expandGatewayUrl(gw, cid);
ensLog(`${label} GET ${url}`);
const t0 = performance.now();
try {
const raw = await callMixFetch(mixFetch!, url, {});
const buf = await decompressBody(raw.body, raw.headers);
const ms = (performance.now() - t0).toFixed(0);
const ctype = raw.headers['content-type'] || '';
const wireSize = raw.body ? raw.body.byteLength : 0;
const wireNote = wireSize !== buf.byteLength ? ` (${formatSize(wireSize)} wire, decompressed)` : '';
ensLog(`${raw.status} ${raw.statusText}: ${formatSize(buf.byteLength)} ${ctype}${wireNote} in ${ms} ms`, 'green');
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
const looksLikeHtml = ctype.includes('html') || /<html\b|<!doctype html/i.test(text.slice(0, 1000));
if (looksLikeHtml) {
const fp = htmlFingerprint(text);
if (fp.title) ensLog(`page title: "${fp.title}"`, 'green');
setPreview(renderFingerprint(fp, buf.byteLength));
} else if (ctype.includes('json')) {
try {
setPreview(JSON.stringify(JSON.parse(text), null, 2));
} catch {
setPreview(text);
}
} else if (ctype.includes('text/')) {
setPreview(text);
} else {
setPreview(`[binary content, ${formatSize(buf.byteLength)}, ${ctype || 'unknown type'}]`);
}
setVerifyLink(`https://ipfs.io/ipfs/${cid}/`);
ensLog('Visual content check (open the link below in another tab), not CID-hash verification.', 'gray');
} catch (e: any) {
ensLog(`${label} fetch failed: ${e.message || e}`, 'red');
ensLog("If this is a 403/429 or connection error, the exit IP may be rate-limited. Tick 'Use random IPR' and reload.", 'orange');
}
}
async function verifyIp() {
if (!mixFetch) return;
setBusy(true);
ensLog('comparing direct-clearnet IP vs Nym-exit IP...');
let directIp: string;
try {
const text = (await (await fetch(IP_ECHO_URL)).text()).trim();
directIp = IP_SHAPE_RE.test(text) ? text : `(unexpected: ${text})`;
} catch (e: any) {
directIp = `error: ${e.message || e}`;
}
ensLog(` your real IP (direct fetch, no Nym): ${directIp}`, 'orange');
let nymIp: string;
try {
const raw = await callMixFetch(mixFetch, IP_ECHO_URL, {});
const text = new TextDecoder().decode(raw.body).trim();
nymIp = IP_SHAPE_RE.test(text) ? text : `(unexpected: ${text})`;
} catch (e: any) {
nymIp = `error: ${e.message || e}`;
}
ensLog(` what the upstream sees via mixFetch -> Nym: ${nymIp}`, 'green');
if (!nymIp.startsWith('error') && !directIp.startsWith('error') && nymIp !== directIp) {
ensLog('IPs differ. The RPC and gateway see the Nym exit, not you. Every ENS step uses the same path.', 'green');
} else if (nymIp.startsWith('error') || directIp.startsWith('error')) {
ensLog('Could not complete the comparison. Try again, or reconnect with a different IPR.', 'red');
} else {
ensLog('IPs match. The mixnet route may not be active, or the IP service is behind a shared CDN. Try again.', 'red');
}
setBusy(false);
}
return (
<div style={{ margin: '1.5rem 0' }}>
<MixTunnelSetup onReady={onReady} onDisconnect={onDisconnect} clientIdPrefix="ens-demo" />
<div style={box}>
<div style={legend}>ENS lookup</div>
<div style={row}>
<Button onClick={verifyIp} disabled={!connected || busy}>Verify IP routing</Button>
<span style={sub}>Confirms traffic exits through Nym. The comparison makes one direct (clearnet) call to ipinfo.io, so you will see a single ipinfo.io row in the Network tab.</span>
</div>
<div style={row}>
<label style={sub}>Name</label>
<select style={input} value={ensName} onChange={(e) => setEnsName(e.target.value)} disabled={!connected}>
{NAME_PRESETS.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<input style={input} value={ensName} onChange={(e) => setEnsName(e.target.value)} disabled={!connected} />
</div>
<div style={row}>
<label style={sub}>RPC</label>
<select style={input} value={RPC_PRESETS.includes(ensRpc) ? ensRpc : ''} onChange={(e) => { setEnsRpc(e.target.value); providerRef.current = null; }} disabled={!connected}>
{RPC_PRESETS.map((u) => <option key={u} value={u}>{u}</option>)}
</select>
<input style={input} value={ensRpc} onChange={(e) => { setEnsRpc(e.target.value); providerRef.current = null; }} disabled={!connected} />
</div>
<div style={row}>
<label style={sub}>IPFS gateway</label>
<select style={input} value={GATEWAY_PRESETS.includes(gateway) ? gateway : ''} onChange={(e) => setGateway(e.target.value)} disabled={!connected}>
{GATEWAY_PRESETS.map((g) => <option key={g} value={g}>{g}</option>)}
</select>
<input style={input} value={gateway} onChange={(e) => setGateway(e.target.value)} disabled={!connected} />
</div>
<div style={row}>
<Button onClick={resolveAddress} disabled={!connected}>1. Resolve address</Button>
<Button onClick={getContenthash} disabled={!connected}>2. Get contenthash</Button>
<Button onClick={() => lastCid && fetchIpfsCid(lastCid, 'step 3/3:')} disabled={!connected || !lastCid}>3. Fetch from IPFS</Button>
</div>
<div style={row}>
<label style={sub}>Or fetch any CID</label>
<input style={input} value={customCid} onChange={(e) => setCustomCid(e.target.value)} placeholder="bafybe... or Qm..." disabled={!connected} />
<Button onClick={() => fetchIpfsCid(customCid.trim(), 'custom')} disabled={!connected || !customCid.trim()}>Fetch</Button>
</div>
<LogPanel lines={lines('ens')} placeholder="Connect the tunnel, then run a lookup." />
{verifyLink && (
<div style={{ ...sub, marginTop: '0.4rem' }}>
verify visually in another tab: <a href={verifyLink} target="_blank" rel="noopener noreferrer">{verifyLink}</a>
</div>
)}
{preview != null && <pre style={preStyle}>{preview}</pre>}
</div>
</div>
);
}
@@ -0,0 +1,254 @@
// ENS demo logic, ported from wasm/ens-demo/index.js and decoupled from the DOM.
// The interesting bit is buildProvider(): ethers v6 lets us swap its HTTP
// transport by assigning FetchRequest.getUrlFunc, so every JSON-RPC call routes
// through mixFetch. To ethers, mixFetch looks like any fetch; to the mixnet,
// ethers looks like any caller.
import { JsonRpcProvider, FetchRequest } from 'ethers';
import type { MixFetchFn } from '../shared/mixTunnel';
import type { Colour } from '../shared/ui';
export type LogFn = (msg: string, colour?: Colour) => void;
export type LogLinkFn = (prefix: string, url: string) => void;
export function formatSize(bytes: number): string {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
}
// base58btc decoder (Bitcoin/IPFS variant; alphabet excludes 0/O/I/l), used to
// take CIDv0 strings apart into their raw multihash bytes.
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const BASE58_MAP: Record<string, number> = (() => {
const m: Record<string, number> = Object.create(null);
for (let i = 0; i < BASE58_ALPHABET.length; i++) m[BASE58_ALPHABET[i]] = i;
return m;
})();
function base58Decode(str: string): Uint8Array {
let zeros = 0;
while (zeros < str.length && str[zeros] === '1') zeros++;
let value = 0n;
for (const c of str) {
if (!(c in BASE58_MAP)) throw new Error(`invalid base58 char: ${c}`);
value = value * 58n + BigInt(BASE58_MAP[c]);
}
const bytes: number[] = [];
while (value > 0n) {
bytes.unshift(Number(value & 0xffn));
value >>= 8n;
}
for (let i = 0; i < zeros; i++) bytes.unshift(0);
return new Uint8Array(bytes);
}
// RFC 4648 base32, lowercase, no padding (the multibase 'b' alphabet CIDv1 uses).
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
function base32Encode(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = '';
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
return output;
}
// Convert CIDv0 ("Qm...") to CIDv1 ("bafybe..."). Pure re-encoding: same content,
// same sha2-256 digest, different envelope. Needed for IPFS subdomain gateways,
// whose DNS labels are case-insensitive (base58btc is case-sensitive; base32
// lowercase survives the round trip). Pass through unchanged if not a canonical
// CIDv0.
export function cidV0ToV1(cid: string): string {
if (!cid.startsWith('Qm') || cid.length !== 46) return cid;
let decoded: Uint8Array;
try {
decoded = base58Decode(cid);
} catch {
return cid;
}
if (decoded.length !== 34 || decoded[0] !== 0x12 || decoded[1] !== 0x20) return cid;
const v1 = new Uint8Array(36);
v1[0] = 0x01; // CID version 1
v1[1] = 0x70; // codec: dag-pb (preserves CIDv0's implicit codec)
v1.set(decoded, 2); // copy multihash verbatim
return 'b' + base32Encode(v1);
}
// Expand a gateway template against a CID. {cid} placeholder supports path form
// (https://gw/ipfs/{cid}/) and subdomain form (https://{cid}.ipfs.gw/); the
// latter forces CIDv0 -> CIDv1. Legacy no-placeholder form is path-only.
export function expandGatewayUrl(gateway: string, cid: string): string {
if (gateway.includes('{cid}')) {
const isSubdomain = /\{cid\}\.ipfs\./.test(gateway) || /\{cid\}\.ipns\./.test(gateway);
const finalCid = isSubdomain ? cidV0ToV1(cid) : cid;
return gateway.replace('{cid}', finalCid);
}
return `${gateway}${cid}/`;
}
export interface FlatResponse {
status: number;
statusText: string;
headers: Record<string, string>;
body: Uint8Array;
}
// Normalise headers to a lowercase-keyed plain object so downstream code only
// ever sees one shape (mix-fetch v2 returns a real Response; smolmix's older
// shape returned [k,v] tuples).
export function headersToObj(headers: unknown): Record<string, string> {
const out: Record<string, string> = {};
if (!headers) return out;
if (headers instanceof Headers || headers instanceof Map) {
for (const [k, v] of (headers as Headers).entries()) out[k.toLowerCase()] = v;
return out;
}
if (Array.isArray(headers)) {
for (const [k, v] of headers) out[String(k).toLowerCase()] = v;
return out;
}
if (typeof headers === 'object') {
for (const [k, v] of Object.entries(headers as Record<string, string>)) out[k.toLowerCase()] = v;
}
return out;
}
// Single boundary into the mixnet: flatten the Response to {status, headers
// (lowercased), body (bytes)} once, because downstream does its own
// decompression / TextDecoder work on the raw wire payload.
export async function callMixFetch(mixFetch: MixFetchFn, url: string, init?: RequestInit): Promise<FlatResponse> {
const res = await mixFetch(url, init || {});
const body = new Uint8Array(await res.arrayBuffer());
return { status: res.status, statusText: res.statusText, headers: headersToObj(res.headers), body };
}
// Decompress a body if the server set Content-Encoding. Native fetch does this
// transparently; smolmix returns raw wire bytes. Brotli (br) is not in
// DecompressionStream; pass it through.
export async function decompressBody(body: Uint8Array, headers: Record<string, string>): Promise<Uint8Array> {
if (!body || body.byteLength === 0) return body;
const enc = (headers['content-encoding'] || '').toLowerCase().trim();
if (!enc || enc === 'identity') return body;
let format: 'gzip' | 'deflate' | 'deflate-raw' | null = null;
if (enc === 'gzip' || enc === 'x-gzip') format = 'gzip';
else if (enc === 'deflate') format = 'deflate';
else if (enc === 'deflate-raw') format = 'deflate-raw';
if (!format) return body;
// body is always a plain ArrayBuffer-backed Uint8Array at runtime; the cast
// sidesteps the TS 5.7 generic-typed-array vs BlobPart (ArrayBuffer) mismatch.
const stream = new Blob([body as BlobPart]).stream().pipeThrough(new DecompressionStream(format));
const buf = await new Response(stream).arrayBuffer();
return new Uint8Array(buf);
}
export function stripContentEncoding(headers: Record<string, string>): Record<string, string> {
const out = { ...headers };
delete out['content-encoding'];
return out;
}
export interface Fingerprint {
title: string | null;
h1: string | null;
description: string | null;
ogTitle: string | null;
counts: { links: number; images: number; scripts: number; stylesheets: number; headings: number };
bodyTextLen: number;
}
// Extract a human-meaningful fingerprint from HTML for visual comparison against
// the same page in another tab. DOMParser runs no scripts and fetches nothing.
export function htmlFingerprint(text: string): Fingerprint {
const doc = new DOMParser().parseFromString(text, 'text/html');
const get = (sel: string, attr?: string): string | null => {
const el = doc.querySelector(sel);
if (!el) return null;
return attr ? el.getAttribute(attr) : el.textContent?.trim() ?? null;
};
return {
title: get('title'),
h1: get('h1'),
description: get('meta[name="description"]', 'content') || get('meta[property="og:description"]', 'content'),
ogTitle: get('meta[property="og:title"]', 'content'),
counts: {
links: doc.querySelectorAll('a').length,
images: doc.querySelectorAll('img').length,
scripts: doc.querySelectorAll('script').length,
stylesheets: doc.querySelectorAll('link[rel="stylesheet"], style').length,
headings: doc.querySelectorAll('h1, h2, h3, h4, h5, h6').length,
},
bodyTextLen: doc.body ? doc.body.textContent!.replace(/\s+/g, ' ').trim().length : 0,
};
}
export function renderFingerprint(fp: Fingerprint, totalBytes: number): string {
const orNone = (s: string | null) => s || '(none)';
return [
'page fingerprint:',
'',
` title: ${orNone(fp.title)}`,
` H1: ${orNone(fp.h1)}`,
` description: ${orNone(fp.description)}`,
` og:title: ${orNone(fp.ogTitle)}`,
'',
` ${fp.counts.links} links, ${fp.counts.images} images, ${fp.counts.scripts} scripts, ${fp.counts.stylesheets} stylesheets, ${fp.counts.headings} headings`,
` body text: ${fp.bodyTextLen.toLocaleString()} chars after stripping tags`,
` HTML size: ${formatSize(totalBytes)} raw response body`,
].join('\n');
}
// Bridge ethers' FetchRequest to mixFetch. Every JSON-RPC call routes through
// the mixnet; we decompress (smolmix doesn't) and log per-call timing/selector.
export function buildProvider(rpcUrl: string, mixFetch: MixFetchFn, ensLog: LogFn): JsonRpcProvider {
const base = new FetchRequest(rpcUrl);
base.getUrlFunc = async (req: FetchRequest) => {
const t0 = performance.now();
const raw = await callMixFetch(mixFetch, req.url, {
method: req.method,
headers: req.headers,
body: (req.body ?? undefined) as BodyInit | undefined,
});
const ms = (performance.now() - t0).toFixed(0);
const body = await decompressBody(raw.body, raw.headers);
const headers = stripContentEncoding(raw.headers);
ensLog(` RPC ${req.method} ${req.url} -> ${raw.status} ${raw.statusText}`);
ensLog(` ${raw.body.byteLength}B wire, ${body.byteLength}B decoded, ${ms} ms`);
if (req.body) {
try {
const parsed = JSON.parse(new TextDecoder().decode(req.body));
const data: string = parsed.params?.[0]?.data || '';
const selector = data.slice(0, 10);
ensLog(` -> method=${parsed.method} selector=${selector} args=0x${data.slice(10)}`);
} catch {
/* not all RPC bodies are eth_call with calldata */
}
}
if (body.byteLength) {
try {
ensLog(` <- ${new TextDecoder().decode(body)}`);
} catch {
/* binary body */
}
}
return { statusCode: raw.status, statusMessage: raw.statusText, headers, body };
};
// staticNetwork skips the eth_chainId discovery probe: one round trip saved.
return new JsonRpcProvider(base, 'mainnet', { staticNetwork: true });
}
@@ -0,0 +1,323 @@
// Railgun-over-the-mixnet demo, ported from wasm/railgun-demo. Two privacy
// layers: Nym hides the network (RPC via mixFetch), Railgun hides the
// application layer (shielded notes). Sepolia testnet only.
import React, { useEffect, useRef, useState } from 'react';
import { HDNodeWallet, JsonRpcProvider, formatEther } from 'ethers';
import { MixTunnelSetup, type MixFetchFn } from '../shared/mixTunnel';
import { Button, LogPanel, useLogs, box, row, input, sub, legend } from '../shared/ui';
import { buildProvider, callMixFetch, installGlobalMixFetchRouting, withRetry } from '../shared/mixfetch';
import {
DEFAULT_MNEMONIC,
SEPOLIA_CHAIN_ID,
STORAGE_KEY,
createRailgunWalletFromMnemonic,
derivePublicAddress,
ensureRailgunEngine,
shieldEth,
type RailgunWalletInfo,
} from './lib';
const RPC_PRESETS = ['https://ethereum-sepolia-rpc.publicnode.com', 'https://rpc.sepolia.org'];
// Fixed shield amount: a single small value so the shared, faucet-funded testnet
// wallet can't be drained by an arbitrary amount.
const SHIELD_AMOUNT = '0.01';
const IP_ECHO_URL = 'https://ipinfo.io/ip';
const IP_SHAPE_RE = /^[\d.:a-f]{3,45}$/i;
export function RailgunDemo() {
const { log, lines } = useLogs();
const dlog = (msg: string, colour?: 'green' | 'red' | 'orange' | 'gray') => log('railgun', msg, colour);
const [mixFetch, setMixFetch] = useState<MixFetchFn | null>(null);
const [mnemonic, setMnemonic] = useState('');
const [publicAddr, setPublicAddr] = useState('(not generated)');
const [railgunWallet, setRailgunWallet] = useState<RailgunWalletInfo | null>(null);
const [rpc, setRpc] = useState(RPC_PRESETS[0]);
const [balance, setBalance] = useState('');
const [txHash, setTxHash] = useState<string | null>(null);
const [storageStatus, setStorageStatus] = useState('');
const [busy, setBusy] = useState(false);
const [importPhrase, setImportPhrase] = useState('');
const publicWalletRef = useRef<HDNodeWallet | null>(null);
const providerRef = useRef<JsonRpcProvider | null>(null);
const connected = mixFetch != null;
const hasWallet = publicAddr !== '(not generated)';
function updateStorageStatus() {
let stored: string | null = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch {
/* localStorage disabled */
}
setStorageStatus(stored ? 'wallet saved in browser storage (auto-loaded on reload)' : 'no wallet saved; generate or import to persist one');
}
function ensureProvider(): JsonRpcProvider {
if (providerRef.current) return providerRef.current;
if (!mixFetch) throw new Error('connect the mixnet tunnel first');
const url = rpc.trim();
if (!url) throw new Error('Sepolia RPC URL is required');
dlog(`building JsonRpcProvider({ rpc: ${url}, transport: mixFetch })`);
providerRef.current = buildProvider(url, mixFetch, SEPOLIA_CHAIN_ID);
return providerRef.current;
}
async function deriveRailgun(phrase: string) {
dlog('initialising Railgun engine + deriving shielded address...');
try {
await ensureRailgunEngine(rpc.trim(), dlog);
const result = await createRailgunWalletFromMnemonic(phrase.trim());
setRailgunWallet(result);
dlog(`Railgun address derived: ${result.railgunAddress}`, 'green');
} catch (e: any) {
dlog(`Railgun derivation failed: ${e.message || e}`, 'red');
}
}
function loadWallet(phrase: string) {
let wallet: HDNodeWallet;
try {
wallet = derivePublicAddress(phrase);
} catch (e: any) {
dlog(`invalid mnemonic: ${e.message || e}`, 'red');
return;
}
publicWalletRef.current = wallet;
setPublicAddr(wallet.address);
setRailgunWallet(null);
setMnemonic(phrase.trim());
try {
localStorage.setItem(STORAGE_KEY, phrase.trim());
} catch {
/* localStorage disabled */
}
updateStorageStatus();
dlog(`public address derived: ${wallet.address}`, 'green');
if (mixFetch) void deriveRailgun(phrase);
else dlog('connect the mixnet tunnel to derive the Railgun address', 'orange');
}
// Auto-load on mount: stored mnemonic, else the funded testnet fallback.
useEffect(() => {
let stored: string | null = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch {
/* localStorage disabled */
}
const phrase = stored || DEFAULT_MNEMONIC;
setMnemonic(phrase);
updateStorageStatus();
try {
const wallet = derivePublicAddress(phrase);
publicWalletRef.current = wallet;
setPublicAddr(wallet.address);
dlog(`auto-loaded wallet: ${wallet.address}`, 'green');
dlog('public side ready. The Railgun address derives once the tunnel is up.');
} catch (e: any) {
dlog(`auto-load failed: ${e.message || e}`, 'red');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onReady(fn: MixFetchFn) {
setMixFetch(() => fn);
// Route every ethers HTTP call (incl. Railgun's internal providers) through Nym.
installGlobalMixFetchRouting(fn);
if (publicWalletRef.current && !railgunWallet) void deriveRailgun(mnemonic);
}
function onDisconnect() {
setMixFetch(null);
providerRef.current = null;
}
function generateWallet() {
dlog('generating fresh BIP-39 mnemonic...');
const wallet = HDNodeWallet.createRandom();
loadWallet(wallet.mnemonic!.phrase);
}
function importWallet() {
loadWallet(importPhrase);
setImportPhrase('');
}
function clearWallet() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage disabled */
}
publicWalletRef.current = null;
setPublicAddr('(not generated)');
setRailgunWallet(null);
setMnemonic('');
updateStorageStatus();
dlog('cleared stored wallet; reload to load the funded fallback');
}
async function checkBalance() {
if (!publicWalletRef.current) return dlog('generate or import a wallet first', 'red');
let provider: JsonRpcProvider;
try {
provider = ensureProvider();
} catch (e: any) {
return dlog(`${e.message || e}`, 'red');
}
setBusy(true);
dlog(`eth_getBalance(${publicWalletRef.current.address}) via mixFetch...`);
try {
const wei = await withRetry(() => provider.getBalance(publicWalletRef.current!.address), 'eth_getBalance', { log: dlog });
const eth = formatEther(wei);
setBalance(`${eth} ETH (Sepolia)`);
dlog(`balance: ${eth} ETH`, 'green');
} catch (e: any) {
dlog(`balance lookup failed: ${e.shortMessage || e.message || e}`, 'red');
} finally {
setBusy(false);
}
}
async function shield() {
if (!railgunWallet) return dlog('Railgun wallet not derived; connect tunnel + generate wallet first', 'red');
if (!publicWalletRef.current) return dlog('public wallet missing', 'red');
let provider: JsonRpcProvider;
try {
provider = ensureProvider();
} catch (e: any) {
return dlog(`${e.message || e}`, 'red');
}
setBusy(true);
setTxHash(null);
try {
await shieldEth({
publicWallet: publicWalletRef.current,
railgunWallet,
provider,
amountStr: SHIELD_AMOUNT,
log: dlog,
onTxHash: setTxHash,
});
} catch (e: any) {
dlog(`shield failed: ${e.shortMessage || e.message || e}`, 'red');
// Tear down the provider so its background pollers stop after a failure.
try {
providerRef.current?.destroy();
} catch {
/* ignore */
}
providerRef.current = null;
} finally {
setBusy(false);
}
}
async function verifyIp() {
if (!mixFetch) return dlog('connect the mixnet tunnel first', 'red');
setBusy(true);
dlog('comparing direct-clearnet IP vs Nym-exit IP...');
let directIp: string;
try {
directIp = (await (await fetch(IP_ECHO_URL)).text()).trim();
if (!IP_SHAPE_RE.test(directIp)) directIp = `(unexpected: ${directIp})`;
} catch (e: any) {
directIp = `error: ${e.message || e}`;
}
dlog(` your real IP (direct fetch, no Nym): ${directIp}`, 'orange');
let nymIp: string;
try {
const raw = await callMixFetch(mixFetch, IP_ECHO_URL, {});
nymIp = new TextDecoder().decode(raw.body).trim();
if (!IP_SHAPE_RE.test(nymIp)) nymIp = `(unexpected: ${nymIp})`;
} catch (e: any) {
nymIp = `error: ${e.message || e}`;
}
dlog(` what the upstream sees via mixFetch -> Nym: ${nymIp}`, 'green');
if (!nymIp.startsWith('error') && !directIp.startsWith('error') && nymIp !== directIp) {
dlog('IPs differ. Every Shield broadcast uses this same Nym-exit path.', 'green');
} else {
dlog('Could not confirm a different exit IP. Try again, or reconnect with a different IPR.', 'red');
}
setBusy(false);
}
return (
<div style={{ margin: '1.5rem 0' }}>
<div style={{ ...box, borderColor: 'var(--colorWarn, #d97706)' }}>
<strong>Sepolia testnet only.</strong>{' '}
<span style={sub}>
The wallet holds only test ETH from public faucets and the mnemonic is stored in plain
browser storage. Never paste a mainnet mnemonic into this demo.
</span>
</div>
<MixTunnelSetup onReady={onReady} onDisconnect={onDisconnect} clientIdPrefix="railgun-demo" />
<div style={box}>
<div style={legend}>Wallet</div>
<div style={sub}>
A testnet wallet is auto-loaded from browser storage. Its mnemonic is not shown here:
it holds only Sepolia test ETH, so please don't be cheeky and try to pull the funded
testnet key out. Import your own below if you'd rather.
</div>
<div style={row}>
<input
type="password"
autoComplete="off"
style={input}
value={importPhrase}
onChange={(e) => setImportPhrase(e.target.value)}
placeholder="import your own 12-word mnemonic (optional)"
/>
<Button onClick={importWallet} disabled={!importPhrase.trim()}>Import</Button>
<Button onClick={generateWallet}>Generate</Button>
<Button onClick={clearWallet}>Clear</Button>
</div>
<div style={sub}>public address: <code>{publicAddr}</code></div>
<div style={sub}>Railgun address: <code>{railgunWallet ? railgunWallet.railgunAddress : connected ? '(deriving...)' : '(connect tunnel to derive)'}</code></div>
<div style={sub}>{storageStatus}</div>
</div>
<div style={box}>
<div style={legend}>Public Sepolia state</div>
<div style={row}>
<label style={sub}>RPC</label>
<select style={input} value={RPC_PRESETS.includes(rpc) ? rpc : ''} disabled={connected || busy} onChange={(e) => { setRpc(e.target.value); providerRef.current = null; }}>
{RPC_PRESETS.map((u) => <option key={u} value={u}>{u}</option>)}
</select>
<input style={input} value={rpc} disabled={connected || busy} onChange={(e) => { setRpc(e.target.value); providerRef.current = null; }} />
</div>
<div style={row}>
<Button onClick={checkBalance} disabled={!connected || !hasWallet || busy}>Check balance</Button>
<Button onClick={verifyIp} disabled={!connected || busy}>Verify IP routing</Button>
<span style={sub}>{balance}</span>
</div>
<div style={sub}>Verify IP makes one direct (clearnet) call to ipinfo.io for the comparison, so you will see a single ipinfo.io row in the Network tab.</div>
</div>
<div style={box}>
<div style={legend}>Shield ETH into a private note</div>
<div style={row}>
<Button onClick={shield} disabled={!connected || !railgunWallet || busy}>Shield {SHIELD_AMOUNT} ETH</Button>
<span style={sub}>Fixed at {SHIELD_AMOUNT} ETH so the shared testnet wallet isn't drained.</span>
</div>
{txHash && (
<div style={{ marginTop: '0.5rem' }}>
<a
href={`https://sepolia.etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#3b82f6', textDecoration: 'underline', fontWeight: 600 }}
>
View transaction on Etherscan
</a>{' '}
<code style={{ ...sub, opacity: 0.7 }}>{txHash.slice(0, 10)}...{txHash.slice(-8)}</code>
</div>
)}
<LogPanel lines={lines('railgun')} placeholder="Connect the tunnel, then derive the wallet and shield." />
</div>
</div>
);
}
@@ -0,0 +1,217 @@
// Railgun-over-the-mixnet logic, ported from wasm/railgun-demo/index.js.
// Two privacy layers: Nym hides the network (every RPC via mixFetch), Railgun
// hides the application layer (shielded notes break the on-chain graph).
//
// The @railgun-community SDK is imported dynamically (no bundled types here, so
// it is typed `any`) and the engine is a process-global singleton, so the
// started/loaded flags live at module scope, which is the faithful model.
import { HDNodeWallet, Mnemonic, JsonRpcProvider, keccak256, parseEther } from 'ethers';
import { withRetry } from '../shared/mixfetch';
export const SEPOLIA_CHAIN_ID = 11155111;
export const SEPOLIA_NETWORK_NAME = 'Ethereum_Sepolia';
export const SEPOLIA_WETH = '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14';
export const TXID_VERSION_V2 = 'V2_PoseidonMerkle';
export const STORAGE_KEY = 'railgun-demo-mnemonic';
export const DEFAULT_MNEMONIC = 'inherit joy bubble reveal fit skin repair involve spoil cube robot angry';
const ENCRYPTION_KEY = '0101010101010101010101010101010101010101010101010101010101010101';
export type RailgunLog = (msg: string, colour?: 'green' | 'red' | 'orange' | 'gray') => void;
export interface RailgunWalletInfo { id: string; railgunAddress: string; }
let engineStarted = false;
let providerLoaded = false;
// Pure-client public address derivation. No network, no engine; works before
// the tunnel is up so the page can show the funding target immediately.
export function derivePublicAddress(phrase: string): HDNodeWallet {
const mnemonic = Mnemonic.fromPhrase(phrase.trim());
return HDNodeWallet.fromMnemonic(mnemonic, "m/44'/60'/0'/0/0");
}
async function ensureEngineStarted(): Promise<void> {
if (engineStarted) return;
const railgun: any = await import('@railgun-community/wallet');
const { MemoryLevel }: any = await import('memory-level');
const db = new MemoryLevel();
// Read-only artifact store: Shield does not need proving artifacts.
const artifactStore = new railgun.ArtifactStore(
async () => null,
async () => {},
async () => false,
);
await railgun.startRailgunEngine(
'railgundemo', // wallet source id; alphanumeric only
db,
false, // shouldDebug
artifactStore,
false, // useNativeArtifacts (Node-only)
false, // skipMerkletreeScans
undefined, // poiNodeURLs (undefined keeps POI uninstantiated)
undefined, // customPOILists
false, // verboseScanLogging
);
// Sepolia is POI-gated but the public aggregator is dead and POI is not what
// this demo proves: clear the network's poi field so the engine treats it as
// a pre-POI deployment. Production would point at a real POI URL instead.
const { NETWORK_CONFIG }: any = await import('@railgun-community/shared-models');
NETWORK_CONFIG[SEPOLIA_NETWORK_NAME].poi = undefined;
// Disable GraphQL quick-sync (its subgraph map lacks Sepolia, so it would
// spam-XHR /undefined). Falls back to direct eth_getLogs scanning.
const engine = railgun.getEngine();
engine.quickSyncEvents = async () => ({ commitmentEvents: [], unshieldEvents: [], nullifierEvents: [] });
engine.quickSyncRailgunTransactionsV2 = async () => [];
engineStarted = true;
}
async function loadProviderOnce(rpc: string): Promise<void> {
if (providerLoaded) return;
const railgun: any = await import('@railgun-community/wallet');
// One provider, weight 2 (the validator requires totalWeight >= 2; a single
// HTTPS endpoint avoids competing TCP handshakes during cold start).
const fallbackConfig = { chainId: SEPOLIA_CHAIN_ID, providers: [{ provider: rpc, priority: 1, weight: 2 }] };
await railgun.loadProvider(fallbackConfig, SEPOLIA_NETWORK_NAME, 10000);
providerLoaded = true;
}
export async function ensureRailgunEngine(rpc: string, log: RailgunLog): Promise<void> {
if (engineStarted && providerLoaded) return;
log('initialising Railgun engine (one-time)...');
await ensureEngineStarted();
// loadProvider hits the network via mixFetch; cold-start can exceed Railgun's
// 60s timeout, so retry (the second attempt finds the pool warm).
await withRetry(() => loadProviderOnce(rpc), 'loadProvider', { log });
log('Railgun engine ready', 'green');
}
export async function createRailgunWalletFromMnemonic(phrase: string): Promise<RailgunWalletInfo> {
const railgun: any = await import('@railgun-community/wallet');
const creationBlockNumbers = { [SEPOLIA_NETWORK_NAME]: 10_900_000 };
return await railgun.createRailgunWallet(ENCRYPTION_KEY, phrase, creationBlockNumbers);
}
// Shield ETH into a shielded note. The headline action: a 4-step flow that
// signs a shield key, estimates gas, populates the tx, then signs + broadcasts
// (idempotently) through the mixFetch-routed provider.
export async function shieldEth(opts: {
publicWallet: HDNodeWallet;
railgunWallet: RailgunWalletInfo;
provider: JsonRpcProvider;
amountStr: string;
log: RailgunLog;
onTxHash: (hash: string) => void;
}): Promise<void> {
const { publicWallet, railgunWallet, provider, amountStr, log, onTxHash } = opts;
const railgun: any = await import('@railgun-community/wallet');
let amountWei: bigint;
try {
amountWei = parseEther(amountStr);
} catch {
log(`invalid amount: "${amountStr}"`, 'red');
return;
}
if (amountWei <= 0n) {
log('amount must be > 0', 'red');
return;
}
log(`shielding ${amountStr} ETH -> ${railgunWallet.railgunAddress}`);
// Step 1: shieldPrivateKey = keccak256 of a signature over a deterministic
// message. Signing proves consent and binds the key to the public wallet.
log('step 1/4: signing shield-key derivation message...');
const msg = railgun.getShieldPrivateKeySignatureMessage();
const sigHex = await publicWallet.signMessage(msg);
const shieldPrivateKey = keccak256(sigHex);
const wrappedERC20Amount = { tokenAddress: SEPOLIA_WETH, amount: amountWei };
// Step 2: gas estimate (needs the funder's address to simulate the call).
log('step 2/4: estimating gas via mixFetch...');
// withRetry can't infer T from the `any`-typed SDK call, so it falls back to
// unknown; annotate the result to read its fields.
const gasEstResp: any = await withRetry(
() =>
railgun.gasEstimateForShieldBaseToken(
TXID_VERSION_V2,
SEPOLIA_NETWORK_NAME,
railgunWallet.railgunAddress,
shieldPrivateKey,
wrappedERC20Amount,
publicWallet.address,
),
'gasEstimateForShieldBaseToken',
{ log },
);
log(` gas estimate: ${gasEstResp.gasEstimate.toString()} units`);
// EIP-1559 gas details, padded 50% so a transient spike during the mixnet
// round trip does not strand the tx.
const feeData = await provider.getFeeData();
const maxFeePerGas = ((feeData.maxFeePerGas ?? feeData.gasPrice ?? 30_000_000_000n) * 3n) / 2n;
const maxPriorityFeePerGas = ((feeData.maxPriorityFeePerGas ?? 2_000_000_000n) * 3n) / 2n;
const gasDetails = { evmGasType: 2, gasEstimate: gasEstResp.gasEstimate, maxFeePerGas, maxPriorityFeePerGas };
// Step 3: populate the actual transaction.
log('step 3/4: populating shield transaction...');
const populateResp = await railgun.populateShieldBaseToken(
TXID_VERSION_V2,
SEPOLIA_NETWORK_NAME,
railgunWallet.railgunAddress,
shieldPrivateKey,
wrappedERC20Amount,
gasDetails,
);
const tx = populateResp.transaction;
// Step 4: sign then broadcast separately, so the tx hash is fixed before any
// broadcast attempt and a dropped response can be retried idempotently.
log('step 4/4: signing + broadcasting via mixFetch -> Nym...');
const signer = publicWallet.connect(provider);
const populated = await signer.populateTransaction(tx);
const signedHex = await signer.signTransaction(populated);
const txHash = keccak256(signedHex);
log(` signed tx hash: ${txHash}`);
log(` -> To: ${populated.to} (Railgun Sepolia proxy contract)`);
log(` -> calldata selector: ${(populated.data || '').slice(0, 10)} (Railgun shield function; Etherscan decodes this)`);
log(` -> full calldata: ${populated.data || ''}`);
onTxHash(txHash);
let sentTx: any;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
sentTx = await provider.broadcastTransaction(signedHex);
log(` broadcast OK (attempt ${attempt})`, 'green');
break;
} catch (e: any) {
const m = e.shortMessage || e.message || String(e);
try {
const existing = await provider.getTransaction(txHash);
if (existing) {
log(' broadcast response failed but tx is on chain, partial success', 'green');
sentTx = existing;
break;
}
} catch {
/* getTransaction failed too; treat as not on chain, retry */
}
if (attempt < 3) {
log(` broadcast attempt ${attempt}/3 failed (${m}), retrying in 10s...`, 'orange');
await new Promise((r) => setTimeout(r, 10_000));
} else {
log(' broadcast failed after 3 attempts. The mixnet route to the RPC is degraded. Disconnect, tick "Use random IPR", reconnect, then Shield again.', 'red');
throw e;
}
}
}
log('waiting for receipt (1 confirmation)...');
const receipt = await sentTx.wait(1);
if (receipt && receipt.status === 1) {
log(`shielded. Block ${receipt.blockNumber}, gas used ${receipt.gasUsed}`, 'green');
log("verify on Etherscan: the To field is Railgun's Sepolia proxy, the method decodes to a shield, and the logs hold an encrypted Shield commitment.", 'green');
} else {
log('tx mined but reverted', 'red');
}
}
@@ -0,0 +1,50 @@
// Shared mixnet glossary for the demo pages, with links to the relevant docs.
// Raw <a> inside a React component does not pick up Nextra's MDX link styling,
// so the links are styled explicitly via the L helper.
import React from 'react';
function L({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a href={href} style={{ color: '#3b82f6', textDecoration: 'underline' }}>
{children}
</a>
);
}
export function MixnetGlossary() {
return (
<ul>
<li>
<strong>Mixnet.</strong> An overlay network that routes your traffic through several relays,
mixed in with everyone else's, so no single point can link sender to receiver. See{' '}
<L href="/network/mixnet-mode">mixnet mode</L>.
</li>
<li>
<strong>Entry gateway.</strong> Your first hop into the mixnet. The browser holds one
WebSocket to it, and all tunnelled traffic travels over that single connection as opaque
frames. See <L href="/network/infrastructure/nym-nodes">Nym nodes</L>.
</li>
<li>
<strong>IPR (IP Packet Router), the exit.</strong> Where traffic leaves the mixnet for the
public internet. The destination sees the IPR's IP, not yours. See{' '}
<L href="/network/infrastructure/exit-services#ip-packet-router">exit services</L>.
</li>
<li>
<strong>SURB (single-use reply block).</strong> A prepaid, single-use return envelope. The
exit replies through it without ever learning your address. See{' '}
<L href="/network/mixnet-mode/anonymous-replies">anonymous replies</L>.
</li>
<li>
<strong>Cover traffic / Poisson timing.</strong> Decoy packets sent on randomised timing, so
your real traffic blends into a steady stream. See{' '}
<L href="/network/mixnet-mode/cover-traffic">cover traffic</L>.
</li>
<li>
<strong>mixFetch.</strong> A <code>fetch()</code>-shaped function from{' '}
<L href="/developers/mix-fetch"><code>@nymproject/mix-fetch</code></L>. It runs the mixnet
client (smolmix) in a Web Worker, so each request goes through the mixnet rather than the
browser's network stack.
</li>
</ul>
);
}
@@ -0,0 +1,19 @@
// Shared "Watch the Network tab" callout, used on the playground and the demo
// pages. Generic wording so it reads correctly wherever a single mixnet tunnel
// carries the page's traffic.
import React from 'react';
import { Callout } from 'nextra/components';
export function NetworkTabCallout() {
return (
<Callout type="info">
<strong>Watch the Network tab.</strong> Open DevTools Network before you connect. Once the
tunnel reports ready, every operation you run here adds <strong>no new request</strong> to that
tab: it is multiplexed inside the single WebSocket to the entry gateway. Only the clearnet
comparison buttons add rows. (Setup also fetches the network topology over HTTPS and refreshes
it periodically, so those nym-api calls and the gateway WebSocket are the only clearnet requests
you will see.) Your real traffic never leaves the browser as an identifiable, per-destination
request.
</Callout>
);
}
@@ -0,0 +1,243 @@
// Shared mixnet-tunnel setup panel for the in-docs demos.
//
// Owns the connection lifecycle (setup / disconnect / state) and the options
// surface (IPR pin, SURBs, DNS, timeouts, ...), and hands the parent demo a
// `mixFetch` function once the tunnel is `ready`. Modelled on the playground's
// inline setup section; the demos differ only in what they do with `mixFetch`.
//
// The package import is dynamic so the multi-MB wasm chunk loads only when the
// visitor clicks Connect, not on page render. Everything here is client-only;
// render the demo page with `next/dynamic` + `ssr: false`.
import React, { useEffect, useState } from 'react';
import { Button, LogPanel, StatusText, useLogs, box, row, input, num, sub, legend, type Status } from './ui';
export type MixFetchFn = (url: string, init?: RequestInit) => Promise<Response>;
interface MixFetchModule {
setupMixTunnel: (opts?: Record<string, unknown>) => Promise<void>;
disconnectMixTunnel: () => Promise<void>;
getTunnelState: () => Promise<{ state: string; reason?: string }>;
mixFetch: MixFetchFn;
}
// Lazy-load the published mix-fetch facade. The literal specifier keeps webpack
// code-splitting the wasm into an async chunk.
async function loadMixFetch(): Promise<MixFetchModule> {
// @ts-ignore -- @nymproject/mix-fetch resolves at runtime; lazy wasm chunk
const m = await import('@nymproject/mix-fetch');
return m as unknown as MixFetchModule;
}
const clampSurbs = (n: number, min: number) => Math.min(50, Math.max(min, n));
// Default IPR exit for the docs demos. Pinned so a demo connects to a known
// exit by default; users can switch to auto-discovery with 'Use random IPR'.
const DEFAULT_IPR =
'6B6iuWX4bQP4GVA4Yq7XmZencaaGw6BaPY6xJWYSwsbF.6g6LRx1fgU2Q2A4ZPKonYHtfBARh1GPMe1LtXk6vpRR8@q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1';
export function MixTunnelSetup({
onReady,
onDisconnect,
clientIdPrefix = 'docs-demo',
}: {
onReady: (mixFetch: MixFetchFn) => void;
onDisconnect?: () => void;
clientIdPrefix?: string;
}) {
const { log, lines } = useLogs();
const [mods, setMods] = useState<MixFetchModule | null>(null);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
// The tunnel is one-shot per page (smolmix OnceLock + single worker), so once
// it has been torn down, Connect stays disabled until a reload.
const [terminated, setTerminated] = useState(false);
const [status, setStatus] = useState<Status>({ text: 'Not started', colour: 'gray' });
const [showAdvanced, setShowAdvanced] = useState(false);
// Connection options.
const [useRandomIpr, setUseRandomIpr] = useState(false);
const [iprAddress, setIprAddress] = useState(DEFAULT_IPR);
const [clientId, setClientId] = useState('');
const [forceTls, setForceTls] = useState(true);
const [disablePoisson, setDisablePoisson] = useState(false);
const [disableCover, setDisableCover] = useState(false);
const [debug, setDebug] = useState(true);
const [openSurbs, setOpenSurbs] = useState(10);
const [dataSurbs, setDataSurbs] = useState(2);
const [primaryDns, setPrimaryDns] = useState('');
const [fallbackDns, setFallbackDns] = useState('');
const [dnsTimeout, setDnsTimeout] = useState('');
const [connectTimeout, setConnectTimeout] = useState('');
const [maxRedirects, setMaxRedirects] = useState('');
const [storagePassphrase, setStoragePassphrase] = useState('');
// Generate the client id after mount (not at render) so SSG and client
// hydration agree: Math.random at render would differ between the two.
useEffect(() => {
setClientId((c) => c || `${clientIdPrefix}-${Math.random().toString(36).slice(2, 8)}`);
}, [clientIdPrefix]);
const optInt = (v: string): number | undefined => {
const n = parseInt(v, 10);
return Number.isNaN(n) ? undefined : n;
};
const optStr = (v: string): string | undefined => v.trim() || undefined;
async function connect() {
if (!useRandomIpr && !iprAddress.trim()) {
setStatus({ text: "IPR address required (or tick 'Use random IPR')", colour: 'red' });
return;
}
setBusy(true);
setStatus({ text: 'Connecting (building the client, connecting to the IPR exit)...', colour: 'orange' });
log('tunnel', `Connecting (clientId=${clientId}, IPR: ${useRandomIpr ? 'auto-discover' : iprAddress.trim().slice(0, 28) + '...'}, SURBs open=${openSurbs} data=${dataSurbs})`, 'orange');
try {
const m = mods ?? (await loadMixFetch());
if (!mods) setMods(m);
// One WASM instance per browser tab, shared across demo pages by the
// bundler. If another page already brought the tunnel up, reuse it rather
// than calling setupMixTunnel again (which throws "already initialised").
const existing = await m.getTunnelState().catch(() => null);
if (existing && existing.state === 'ready') {
log('tunnel', 'Tunnel already up from another page; reusing it (its original options apply).', 'green');
} else {
await m.setupMixTunnel({
...(useRandomIpr ? {} : { preferredIpr: iprAddress.trim() }),
clientId,
forceTls,
disablePoissonTraffic: disablePoisson,
disableCoverTraffic: disableCover,
openReplySurbs: clampSurbs(openSurbs, 1),
dataReplySurbs: clampSurbs(dataSurbs, 0),
primaryDns: optStr(primaryDns),
fallbackDns: optStr(fallbackDns),
dnsTimeoutMs: optInt(dnsTimeout),
connectTimeoutMs: optInt(connectTimeout),
maxRedirects: optInt(maxRedirects),
storagePassphrase: storagePassphrase || undefined,
debug,
});
log('tunnel', 'Tunnel ready', 'green');
}
setConnected(true);
setStatus({ text: 'Connected', colour: 'green' });
onReady(m.mixFetch);
} catch (e) {
const msg = String((e as any)?.message ?? e);
if (/already initialised/i.test(msg)) {
log('tunnel', 'Tunnel already initialised in this tab; reload the page if it does not connect.', 'orange');
setStatus({ text: 'Failed (already initialised, reload)', colour: 'red' });
} else {
setStatus({ text: 'Failed', colour: 'red' });
log('tunnel', `Connection failed: ${msg}`, 'red');
log('tunnel', "Timeouts and IPR rate-limits are common. Try again, or tick 'Use random IPR' and reload.", 'orange');
}
} finally {
setBusy(false);
}
}
async function disconnect() {
if (!mods) return;
setBusy(true);
log('tunnel', 'Disconnecting...');
try {
await mods.disconnectMixTunnel();
log('tunnel', 'Disconnected. Reload the page to reconnect.', 'green');
setStatus({ text: 'Disconnected (reload to reconnect)', colour: 'gray' });
} catch (e) {
log('tunnel', `Disconnect failed: ${e}`, 'red');
setStatus({ text: 'Disconnected after error (reload to reconnect)', colour: 'red' });
} finally {
// The tunnel is one-shot per page: smolmix uses a OnceLock and the package
// owns one worker, so there is no fresh-client path without a reload. Keep
// Connect disabled and say so rather than failing on a second connect.
setConnected(false);
setTerminated(true);
setBusy(false);
onDisconnect?.();
}
}
return (
<div style={box}>
<div style={legend}>Mixnet tunnel</div>
<div style={row}>
<label style={{ ...sub, display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={useRandomIpr} onChange={(e) => setUseRandomIpr(e.target.checked)} disabled={connected || busy} />
Use random IPR
</label>
<input
style={input}
value={iprAddress}
onChange={(e) => setIprAddress(e.target.value)}
placeholder="<nym-address of IPR exit node>"
disabled={useRandomIpr || connected || busy}
/>
</div>
<div style={row}>
<Button onClick={connect} disabled={connected || busy || terminated}>{busy && !connected ? 'Connecting...' : 'Connect to mixnet'}</Button>
<Button onClick={disconnect} disabled={!connected || busy}>Disconnect</Button>
<StatusText status={status} />
<button
type="button"
aria-expanded={showAdvanced}
style={{ ...sub, marginLeft: 'auto', cursor: 'pointer', background: 'none', border: 'none', padding: 0, fontFamily: 'inherit', fontWeight: 'inherit', color: 'inherit' }}
onClick={() => setShowAdvanced((v) => !v)}
>
{showAdvanced ? '▾ advanced' : '▸ advanced'}
</button>
</div>
{showAdvanced && (
<div style={{ ...row, flexDirection: 'column', alignItems: 'stretch', gap: 6 }}>
<div style={row}>
<label style={sub}>client id</label>
<input style={input} value={clientId} onChange={(e) => setClientId(e.target.value)} disabled={connected || busy} />
</div>
<div style={row}>
<label style={{ ...sub, display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={forceTls} onChange={(e) => setForceTls(e.target.checked)} disabled={connected || busy} /> forceTls (WSS to gateway)
</label>
<label style={{ ...sub, display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={disablePoisson} onChange={(e) => setDisablePoisson(e.target.checked)} disabled={connected || busy} /> disable Poisson
</label>
<label style={{ ...sub, display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={disableCover} onChange={(e) => setDisableCover(e.target.checked)} disabled={connected || busy} /> disable cover traffic
</label>
<label style={{ ...sub, display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={debug} onChange={(e) => setDebug(e.target.checked)} disabled={connected || busy} /> verbose console logs
</label>
</div>
<div style={row}>
<label style={sub}>open SURBs</label>
<input style={num} type="number" min={1} value={openSurbs} onChange={(e) => setOpenSurbs(+e.target.value)} disabled={connected || busy} />
<label style={sub}>data SURBs</label>
<input style={num} type="number" min={0} value={dataSurbs} onChange={(e) => setDataSurbs(+e.target.value)} disabled={connected || busy} />
</div>
<div style={row}>
<label style={sub}>primary DNS</label>
<input style={input} value={primaryDns} onChange={(e) => setPrimaryDns(e.target.value)} placeholder="8.8.8.8:53" disabled={connected || busy} />
<label style={sub}>fallback DNS</label>
<input style={input} value={fallbackDns} onChange={(e) => setFallbackDns(e.target.value)} placeholder="1.1.1.1:53" disabled={connected || busy} />
</div>
<div style={row}>
<label style={sub}>dns timeout ms</label>
<input style={num} value={dnsTimeout} onChange={(e) => setDnsTimeout(e.target.value)} placeholder="30000" disabled={connected || busy} />
<label style={sub}>connect timeout ms</label>
<input style={num} value={connectTimeout} onChange={(e) => setConnectTimeout(e.target.value)} placeholder="60000" disabled={connected || busy} />
<label style={sub}>max redirects</label>
<input style={num} value={maxRedirects} onChange={(e) => setMaxRedirects(e.target.value)} placeholder="5" disabled={connected || busy} />
</div>
<div style={row}>
<label style={sub}>storage passphrase</label>
<input style={input} type="password" value={storagePassphrase} onChange={(e) => setStoragePassphrase(e.target.value)} placeholder="(plaintext if empty)" disabled={connected || busy} />
</div>
</div>
)}
<LogPanel lines={lines('tunnel')} placeholder="Press Connect to bring up the tunnel." />
</div>
);
}
@@ -0,0 +1,121 @@
// Shared mixnet + ethers helpers for demos that route HTTP through mixFetch.
// ens has its own copies of the small helpers (it predates this file); railgun
// uses these. Consolidate ens onto this later.
import { FetchRequest, JsonRpcProvider, type Networkish } from 'ethers';
import type { MixFetchFn } from './mixTunnel';
export interface FlatResponse {
status: number;
statusText: string;
headers: Record<string, string>;
body: Uint8Array;
}
export function headersToObj(headers: unknown): Record<string, string> {
const out: Record<string, string> = {};
if (!headers) return out;
if (headers instanceof Headers || headers instanceof Map) {
for (const [k, v] of (headers as Headers).entries()) out[k.toLowerCase()] = v;
return out;
}
if (Array.isArray(headers)) {
for (const [k, v] of headers) out[String(k).toLowerCase()] = v;
return out;
}
if (typeof headers === 'object') {
for (const [k, v] of Object.entries(headers as Record<string, string>)) out[k.toLowerCase()] = v;
}
return out;
}
export async function callMixFetch(mixFetch: MixFetchFn, url: string, init?: RequestInit): Promise<FlatResponse> {
const res = await mixFetch(url, init || {});
const body = new Uint8Array(await res.arrayBuffer());
return { status: res.status, statusText: res.statusText, headers: headersToObj(res.headers), body };
}
export async function decompressBody(body: Uint8Array, headers: Record<string, string>): Promise<Uint8Array> {
if (!body || body.byteLength === 0) return body;
const enc = (headers['content-encoding'] || '').toLowerCase().trim();
if (!enc || enc === 'identity') return body;
let format: 'gzip' | 'deflate' | 'deflate-raw' | null = null;
if (enc === 'gzip' || enc === 'x-gzip') format = 'gzip';
else if (enc === 'deflate') format = 'deflate';
else if (enc === 'deflate-raw') format = 'deflate-raw';
if (!format) return body;
// body is always a plain ArrayBuffer-backed Uint8Array at runtime; the cast
// sidesteps the TS 5.7 generic-typed-array vs BlobPart (ArrayBuffer) mismatch.
const stream = new Blob([body as BlobPart]).stream().pipeThrough(new DecompressionStream(format));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
export function stripContentEncoding(headers: Record<string, string>): Record<string, string> {
const out = { ...headers };
delete out['content-encoding'];
return out;
}
// The getUrl adapter ethers uses: route the request through mixFetch, decompress,
// rename the response fields ethers expects.
function makeGetUrl(mixFetch: MixFetchFn) {
return async (req: FetchRequest) => {
const raw = await callMixFetch(mixFetch, req.url, {
method: req.method,
headers: req.headers,
body: (req.body ?? undefined) as BodyInit | undefined,
});
const body = await decompressBody(raw.body, raw.headers);
return {
statusCode: raw.status,
statusMessage: raw.statusText,
headers: stripContentEncoding(raw.headers),
body,
};
};
}
// A JsonRpcProvider whose transport is mixFetch (per-instance override).
export function buildProvider(rpcUrl: string, mixFetch: MixFetchFn, network: Networkish): JsonRpcProvider {
const base = new FetchRequest(rpcUrl);
base.getUrlFunc = makeGetUrl(mixFetch);
return new JsonRpcProvider(base, network, { staticNetwork: true });
}
// Install a single global FetchRequest URL handler so EVERY ethers HTTP request
// (including providers a library constructs internally from URL strings) routes
// through mixFetch. Requires a single ethers instance across the bundle: the
// `ethers$` alias in next.config.js enforces that.
let globalRoutingInstalled = false;
export function installGlobalMixFetchRouting(mixFetch: MixFetchFn): void {
if (globalRoutingInstalled) return;
FetchRequest.registerGetUrl(makeGetUrl(mixFetch));
globalRoutingInstalled = true;
}
// Retry wrapper for mixnet-routed RPC calls: the first request on a cold mixnet
// path pays TCP-connect + TLS-handshake time, and Railgun's hardcoded timeouts
// can fire on it; the retry finds the pool warm.
export async function withRetry<T>(
fn: () => Promise<T>,
label: string,
opts: { attempts?: number; delayMs?: number; log?: (msg: string, colour?: string) => void } = {},
): Promise<T> {
const { attempts = 3, delayMs = 3000, log } = opts;
let lastErr: unknown;
for (let i = 1; i <= attempts; i++) {
try {
return await fn();
} catch (e: any) {
lastErr = e;
const msg = e.shortMessage || e.message || String(e);
if (i < attempts) {
log?.(`${label} attempt ${i}/${attempts} failed (${msg}), retrying in ${delayMs / 1000}s...`, 'orange');
await new Promise((r) => setTimeout(r, delayMs));
} else {
log?.(`${label} failed after ${attempts} attempts: ${msg}`, 'red');
}
}
}
throw lastErr;
}
@@ -0,0 +1,5 @@
// Shared UI primitives for the in-docs demos (ens, railgun). These live with
// the playground today; re-exported here so the demo components import from one
// stable place rather than reaching into ../playground. If the primitives ever
// move to a neutral home, only this file changes.
export * from '../../playground/ui';
@@ -1,6 +1,6 @@
{
"nodes": 683,
"nodes": 685,
"locations": 75,
"mixnodes": 240,
"exit_gateways": 435
"exit_gateways": 437
}
@@ -1 +1 @@
Tuesday, June 9th 2026, 13:20:08 UTC
Tuesday, June 9th 2026, 16:06:04 UTC
@@ -4,117 +4,95 @@ Start this nym-node
Usage: nym-node run [OPTIONS]
Options:
--id <ID>
Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node]
--config-file <CONFIG_FILE>
Path to a configuration file of this node [env: NYMNODE_CONFIG=]
--accept-operator-terms-and-conditions
Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at <https://nymtech.net/terms-and-conditions/operators/v1.0.0> [env:
NYMNODE_ACCEPT_OPERATOR_TERMS=]
--deny-init
Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env: NYMNODE_DENY_INIT=]
--init-only
If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses [env: NYMNODE_INIT_ONLY=]
--local
Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=]
--mode [<MODE>...]
Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only]
--modes <MODES>
Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only]
-w, --write-changes
If this node has been initialised before, specify whether to write any new changes to the config file [env: NYMNODE_WRITE_CONFIG_CHANGES=]
--bonding-information-output <BONDING_INFORMATION_OUTPUT>
Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a subject to change and this argument should be
treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=]
-o, --output <OUTPUT>
Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible values: text, json]
--public-ips <PUBLIC_IPS>
Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all circumstances, it's going to be identical to the
address you're going to use for bonding [env: NYMNODE_PUBLIC_IPS=]
--hostname <HOSTNAME>
Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env: NYMNODE_HOSTNAME=]
--location <LOCATION>
Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit
numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=]
--http-bind-address <HTTP_BIND_ADDRESS>
Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=]
--landing-page-assets-path <LANDING_PAGE_ASSETS_PATH>
Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=]
--http-access-token <HTTP_ACCESS_TOKEN>
An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env: NYMNODE_HTTP_ACCESS_TOKEN=]
--expose-system-info <EXPOSE_SYSTEM_INFO>
Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] [possible values: true, false]
--expose-system-hardware <EXPOSE_SYSTEM_HARDWARE>
Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false]
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE>
Specify whether detailed system crypto hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false]
--nyxd-urls <NYXD_URLS>
Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--nyxd-websocket-url <NYXD_WEBSOCKET_URL>
Url to the websocket endpoint of a nyx validator, for example `wss://rpc.nymtech.net/websocket`. It is used for subscribing to new block events [env: NYMNODE_NYXD_WEBSOCKET=]
--mixnet-bind-address <MIXNET_BIND_ADDRESS>
Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT>
If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=]
--nym-api-urls <NYM_API_URLS>
Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=]
--enable-console-logging <ENABLE_CONSOLE_LOGGING>
Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible values: true, false]
--wireguard-enabled <WIREGUARD_ENABLED>
Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS>
Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT>
Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX>
The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env:
NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--wireguard-userspace <WIREGUARD_USERSPACE>
Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=]
[possible values: true, false]
--verloc-bind-address <VERLOC_BIND_ADDRESS>
Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=]
--verloc-announce-port <VERLOC_ANNOUNCE_PORT>
If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=]
--entry-bind-address <ENTRY_BIND_ADDRESS>
Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=]
--announce-ws-port <ANNOUNCE_WS_PORT>
Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=]
--announce-wss-port <ANNOUNCE_WSS_PORT>
If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=]
--enforce-zk-nyms <ENFORCE_ZK_NYMS>
Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible
values: true, false]
--mnemonic <MNEMONIC>
Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be generated [env: NYMNODE_MNEMONIC=]
--upgrade-mode-attestation-url <UPGRADE_MODE_ATTESTATION_URL>
Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=]
--upgrade-mode-attester-public-key <UPGRADE_MODE_ATTESTER_PUBLIC_KEY>
Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local networks [env:
NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=]
--upstream-exit-policy-url <UPSTREAM_EXIT_POLICY_URL>
Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=]
--open-proxy <OPEN_PROXY>
Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true,
false]
--nr-allow-local-ips <NR_ALLOW_LOCAL_IPS>
Allow the network requester to forward traffic to non-globally-routable addresses. Intended for local development, private-network deployments, and testnet scenarios. Not
recommended on production exit gateway unless you know what you're doing [env: NYMNODE_NR_ALLOW_LOCAL_IPS=] [possible values: true, false]
--ipr-allow-local-ips <IPR_ALLOW_LOCAL_IPS>
Allow the IP packet router to forward traffic to non-globally-routable addresses. Intended for local development, private-network deployments, and testnet scenarios. Not recommended
on production exit gateway unless you know what you're doing [env: NYMNODE_IPR_ALLOW_LOCAL_IPS=] [possible values: true, false]
--lp-control-bind-address <LP_CONTROL_BIND_ADDRESS>
Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=]
--lp-control-announce-port <LP_CONTROL_ANNOUNCE_PORT>
Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will be used instead [env:
NYMNODE_LP_CONTROL_ANNOUNCE_PORT=]
--lp-data-bind-address <LP_DATA_BIND_ADDRESS>
Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=]
--lp-data-announce-port <LP_DATA_ANNOUNCE_PORT>
Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=]
--lp-use-mock-ecash <LP_USE_MOCK_ECASH>
Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP listener will accept any credential without
blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false]
-h, --help
Print help
--id <ID> Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node]
--config-file <CONFIG_FILE> Path to a configuration file of this node [env: NYMNODE_CONFIG=]
--accept-operator-terms-and-conditions Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at
<https://nymtech.net/terms-and-conditions/operators/v1.0.0> [env: NYMNODE_ACCEPT_OPERATOR_TERMS=]
--deny-init Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist
[env: NYMNODE_DENY_INIT=]
--init-only If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses
[env: NYMNODE_INIT_ONLY=]
--local Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=]
--mode [<MODE>...] Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway,
exit-gateway, exit-providers-only]
--modes <MODES> Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode,
entry-gateway, exit-gateway, exit-providers-only]
-w, --write-changes If this node has been initialised before, specify whether to write any new changes to the config file [env:
NYMNODE_WRITE_CONFIG_CHANGES=]
--bonding-information-output <BONDING_INFORMATION_OUTPUT> Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding
information is still a subject to change and this argument should be treated only as a preview of future features [env:
NYMNODE_BONDING_INFORMATION_OUTPUT=]
-o, --output <OUTPUT> Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible
values: text, json]
--public-ips <PUBLIC_IPS> Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In
nearly all circumstances, it's going to be identical to the address you're going to use for bonding [env:
NYMNODE_PUBLIC_IPS=]
--hostname <HOSTNAME> Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients
[env: NYMNODE_HOSTNAME=]
--location <LOCATION> Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g.
'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=]
--http-bind-address <HTTP_BIND_ADDRESS> Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=]
--landing-page-assets-path <LANDING_PAGE_ASSETS_PATH> Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=]
--http-access-token <HTTP_ACCESS_TOKEN> An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env:
NYMNODE_HTTP_ACCESS_TOKEN=]
--expose-system-info <EXPOSE_SYSTEM_INFO> Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=]
[possible values: true, false]
--expose-system-hardware <EXPOSE_SYSTEM_HARDWARE> Specify whether basic system hardware information should be exposed. default: true [env:
NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false]
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE> Specify whether detailed system crypto hardware information should be exposed. default: true [env:
NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false]
--nyxd-urls <NYXD_URLS> Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--nyxd-websocket-url <NYXD_WEBSOCKET_URL> Url to the websocket endpoint of a nyx validator, for example `wss://rpc.nymtech.net/websocket`. It is used for
subscribing to new block events [env: NYMNODE_NYXD_WEBSOCKET=]
--mixnet-bind-address <MIXNET_BIND_ADDRESS> Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the
node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=]
--nym-api-urls <NYM_API_URLS> Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=]
--enable-console-logging <ENABLE_CONSOLE_LOGGING> Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=]
[possible values: true, false]
--wireguard-enabled <WIREGUARD_ENABLED> Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true,
false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS> Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env:
NYMNODE_WG_BIND_ADDRESS=]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT> Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where
the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX> The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is
32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--wireguard-userspace <WIREGUARD_USERSPACE> Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments
without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false]
--verloc-bind-address <VERLOC_BIND_ADDRESS> Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=]
--verloc-announce-port <VERLOC_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the
node is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=]
--entry-bind-address <ENTRY_BIND_ADDRESS> Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env:
NYMNODE_ENTRY_BIND_ADDRESS=]
--announce-ws-port <ANNOUNCE_WS_PORT> Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will
be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=]
--announce-wss-port <ANNOUNCE_WSS_PORT> If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=]
--enforce-zk-nyms <ENFORCE_ZK_NYMS> Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts
non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false]
--mnemonic <MNEMONIC> Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be
generated [env: NYMNODE_MNEMONIC=]
--upgrade-mode-attestation-url <UPGRADE_MODE_ATTESTATION_URL> Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and
local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=]
--upgrade-mode-attester-public-key <UPGRADE_MODE_ATTESTER_PUBLIC_KEY> Expected public key of the entity signing the published attestation. This argument should never be set outside testnets
and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=]
--upstream-exit-policy-url <UPSTREAM_EXIT_POLICY_URL> Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=]
--open-proxy <OPEN_PROXY> Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it
receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false]
--nr-allow-local-ips <NR_ALLOW_LOCAL_IPS> Allow the network requester to forward traffic to non-globally-routable addresses. Intended for local development,
private-network deployments, and testnet scenarios. Not recommended on production exit gateway unless you know what
you're doing [env: NYMNODE_NR_ALLOW_LOCAL_IPS=] [possible values: true, false]
--ipr-allow-local-ips <IPR_ALLOW_LOCAL_IPS> Allow the IP packet router to forward traffic to non-globally-routable addresses. Intended for local development,
private-network deployments, and testnet scenarios. Not recommended on production exit gateway unless you know what
you're doing [env: NYMNODE_IPR_ALLOW_LOCAL_IPS=] [possible values: true, false]
--lp-control-bind-address <LP_CONTROL_BIND_ADDRESS> Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=]
--lp-control-announce-port <LP_CONTROL_ANNOUNCE_PORT> Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the
`lp_control_bind_address` will be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=]
--lp-data-bind-address <LP_DATA_BIND_ADDRESS> Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=]
--lp-data-announce-port <LP_DATA_ANNOUNCE_PORT> Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the
`lp_data_bind_address` will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=]
--lp-use-mock-ecash <LP_USE_MOCK_ECASH> Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When
enabled, the LP listener will accept any credential without blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=]
[possible values: true, false]
-h, --help Print help
```
+43 -1
View File
@@ -34,6 +34,48 @@ nextra.webpack = (config, options) => {
// }),
// );
// --- Railgun demo: browser polyfills for the @railgun-community SDK ---
// Railgun pulls libp2p / pouchdb / crypto transitively and expects Node-stdlib
// globals that webpack 5 no longer auto-polyfills. Client build only; the SSR
// build resolves these natively. Mirrors wasm/railgun-demo/webpack.config.js.
if (!options.isServer) {
newConfig.resolve.fallback = {
...newConfig.resolve.fallback,
buffer: require.resolve("buffer/"),
crypto: require.resolve("crypto-browserify"),
http: require.resolve("stream-http"),
https: require.resolve("https-browserify"),
stream: require.resolve("stream-browserify"),
url: require.resolve("url/"),
vm: require.resolve("vm-browserify"),
zlib: require.resolve("browserify-zlib"),
};
// Force single instances of ethers and shared-models. ethers: so Railgun's
// global FetchRequest.registerGetUrl shares static state with our import.
// shared-models: so our `NETWORK_CONFIG[...].poi = undefined` POI sidestep
// mutates the SAME object the engine's loadProvider reads (otherwise an
// ESM/CJS split gives two copies and the POI gate still fires).
newConfig.resolve.alias = {
...newConfig.resolve.alias,
ethers$: require.resolve("ethers"),
"@railgun-community/shared-models$": require.resolve("@railgun-community/shared-models"),
};
newConfig.plugins.push(
new options.webpack.ProvidePlugin({
Buffer: ["buffer", "Buffer"],
process: "process/browser",
}),
);
// Railgun ships its zk-SNARK circuits as async WASM.
newConfig.experiments = { ...newConfig.experiments, asyncWebAssembly: true };
}
// Silence "Critical dependency" warnings from Railgun's GraphQL subgraph plumbing.
newConfig.ignoreWarnings = [
...(newConfig.ignoreWarnings || []),
{ module: /@graphql-mesh/, message: /Critical dependency/ },
{ module: /@graphql-tools\/url-loader/, message: /Critical dependency/ },
];
return newConfig;
};
@@ -1484,7 +1526,7 @@ const config = {
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
connect-src 'self' wss://nym-node-cli.devrel.nymte.ch:9001 https://github.com *.vercel.app *.nymtech.net *.nymvpn.com *.nymte.ch *.nyx.network *.nym.com https://nym.com nymvpn.com https://nymvpn.com *.nymtech.cc;
connect-src 'self' wss://* wss://nym-node-cli.devrel.nymte.ch:9001 https://github.com *.vercel.app *.nymtech.net *.nymvpn.com *.nymte.ch *.nyx.network *.nym.com https://nym.com nymvpn.com https://nymvpn.com *.nymtech.cc https://ipinfo.io;
frame-src 'self' https://vercel.live *.vercel.app *.nym.com https://nym.com;
worker-src 'self' blob: https://vercel.live *.vercel.app *.nym.com https://nym.com;
`;
+13
View File
@@ -44,12 +44,16 @@
"@nymproject/mix-tunnel": "^0.1.0",
"@nymproject/mix-websocket": "^0.1.0",
"@nymproject/sdk-full-fat": ">=1.5.1-rc.0 || ^1.4.1",
"@railgun-community/shared-models": "7.5.0",
"@railgun-community/wallet": "10.4.0",
"@redocly/cli": "^1.25.15",
"@types/mdx": "^2.0.13",
"chain-registry": "^1.19.0",
"cosmjs-types": "^0.9.0",
"ethers": "^6.13.1",
"framer-motion": "^12.34.5",
"lucide-react": "^0.438.0",
"memory-level": "^1.0.0",
"next": "15.5.10",
"nextra": "2",
"nextra-theme-docs": "2",
@@ -65,6 +69,15 @@
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"copy-webpack-plugin": "^11.0.0",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"https-browserify": "^1.0.0",
"process": "^0.11.10",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"url": "^0.11.4",
"vm-browserify": "^1.1.2",
"eslint": "8.46.0",
"eslint-config-next": "13.4.13",
"next-sitemap": "4.2.3",
@@ -17,6 +17,7 @@
"title": "TypeScript"
},
"playground": "Playground (embedded clients)",
"demos": "Demos",
"mix-tunnel": "mix-tunnel (shared tunnel)",
"mix-fetch": "mix-fetch (HTTPS requests)",
"mix-dns": "mix-dns (DNS resolution)",
@@ -0,0 +1,4 @@
{
"ens": "ENS over the mixnet",
"railgun": "Shielding ETH with Railgun"
}
@@ -0,0 +1,114 @@
---
title: "Demo: ENS resolution over the Nym mixnet"
description: "Resolve an ENS name to its address and IPFS contenthash, then fetch the site, with every JSON-RPC and gateway request routed through the mixnet via mix-fetch. Shows the ethers-to-mixFetch adapter."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-09"
---
import dynamic from 'next/dynamic'
export const EnsDemo = dynamic(
() => import('../../../components/demos/ens/EnsDemo').then((m) => m.EnsDemo),
{ ssr: false },
)
import { NetworkTabCallout } from '../../../components/demos/shared/NetworkTabCallout'
import { MixnetGlossary } from '../../../components/demos/shared/MixnetGlossary'
# ENS over the mixnet
A normal ENS lookup (name to address to IPFS website) built with
[ethers.js](https://docs.ethers.org/v6/), except every network request goes
through the Nym mixnet instead of leaving over your normal connection. The
Ethereum RPC node and the IPFS gateway see the [IPR](/network/infrastructure/exit-services#ip-packet-router)
exit's IP, not yours, and your ISP cannot see which names or sites you reach.
The trade-off is latency: every packet takes a multi-relay path, so requests are
slower than a direct route.
## How it works
The whole integration is one adapter. ethers v6 exposes
`FetchRequest.getUrlFunc` as a settable property, so you replace its HTTP
transport with a function that calls [`mixFetch`](/developers/mix-fetch). To
ethers it looks like an ordinary fetch; to the mixnet, ethers looks like any
other caller.
```ts
import { JsonRpcProvider, FetchRequest } from 'ethers';
import { mixFetch } from '@nymproject/mix-fetch';
function buildProvider(rpcUrl: string): JsonRpcProvider {
const base = new FetchRequest(rpcUrl);
// Route every JSON-RPC call through mixFetch, renaming the response
// fields ethers expects (status -> statusCode, statusText -> statusMessage).
base.getUrlFunc = async (req) => {
const res = await mixFetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body ?? undefined,
});
return {
statusCode: res.status,
statusMessage: res.statusText,
headers: Object.fromEntries(res.headers),
body: new Uint8Array(await res.arrayBuffer()),
};
};
// staticNetwork skips the eth_chainId probe: one mixnet round trip saved.
return new JsonRpcProvider(base, 'mainnet', { staticNetwork: true });
}
```
One caveat: browser `fetch` decompresses gzip transparently and `mixFetch` does
not, so the demo adds a `DecompressionStream` step after each response (Cloudflare
gzips RPC replies). The full version with decompression and per-call logging is in
[`components/demos/ens/lib.ts`](https://github.com/nymtech/nym/tree/develop/documentation/docs/components/demos/ens).
On npm: [`@nymproject/mix-fetch`](https://www.npmjs.com/package/@nymproject/mix-fetch) and [`ethers`](https://www.npmjs.com/package/ethers).
The lookup itself is three steps, each an Ethereum call or HTTPS GET over the same
tunnel:
1. **Resolve address.** Two `eth_call`s: the ENS Registry's `resolver(node)`
returns the resolver contract for the name, then `resolver.addr(node)` returns
the Ethereum address. `node` is the namehash (recursive keccak256 over the
labels).
2. **Get contenthash.** One more `eth_call`: `resolver.contenthash(node)`. ethers
decodes the EIP-1577 multicodec bytes to a URI; this demo handles `ipfs://`.
3. **Fetch from IPFS.** A plain HTTPS GET to a gateway with the CID as a subdomain
or path label. CIDv0 (`Qm...`) is re-encoded as CIDv1 (`bafy...`) for subdomain
gateways, since DNS is case-insensitive.
## Try it
Connect to bring the tunnel up (a default IPR exit is pinned; tick **Use random
IPR** for auto-discovery), click **Verify IP routing** to confirm traffic exits
through Nym, then run the three steps.
<NetworkTabCallout />
<EnsDemo />
## What to expect
- **The first request is the slow one.** Connecting builds the mixnet client and
handshakes with the IPR; no TCP or TLS yet. The first request to a host then
runs a TCP and TLS handshake carried as IP packets over the mixnet (several
sequential round trips). smolmix keeps that connection warm and reuses it, so
later requests to the same host are much quicker. A long pause is handshakes in
flight, not a hang.
- **You will not see the tunnelled requests in DevTools.** The RPC and IPFS
requests never touch the browser's `fetch`. They leave the worker as encrypted
packets over a single WebSocket to the entry gateway, which is the one
connection the Network tab shows. The exception is **Verify IP routing**, which
deliberately makes one direct clearnet call to ipinfo.io for comparison.
- **Rate limiting.** Public IPFS gateways and Ethereum RPCs rate-limit shared IP
addresses. If requests start failing with 403, 429, or connection errors, the
exit IP is likely flagged: tick **Use random IPR** and reload for a fresh exit.
## Glossary
<MixnetGlossary />
@@ -0,0 +1,111 @@
---
title: "Demo: Shielding testnet ETH into Railgun over the Nym mixnet"
description: "Shield testnet ETH into a Railgun private note with every Ethereum RPC call routed through the Nym mixnet. Shows the global ethers-to-mixFetch routing that covers a whole SDK."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-09"
---
import dynamic from 'next/dynamic'
export const RailgunDemo = dynamic(
() => import('../../../components/demos/railgun/RailgunDemo').then((m) => m.RailgunDemo),
{ ssr: false },
)
import { NetworkTabCallout } from '../../../components/demos/shared/NetworkTabCallout'
import { MixnetGlossary } from '../../../components/demos/shared/MixnetGlossary'
# Shielding testnet ETH into Railgun over the mixnet
**Nym** hides the network layer: every Ethereum RPC
call goes through the mixnet via [`mixFetch`](/developers/mix-fetch), so the RPC
node and your ISP cannot link you to the query, and **Railgun** hides the
application layer, as shielded notes break the on-chain link between sender,
receiver, and amount. This demo covers just the **shield** step on Sepolia:
depositing testnet ETH into a private note. It does not do private transfers or
unshielding.
## What you can do here
This page is interactive. You bring up a mixnet tunnel, derive a Railgun wallet,
and broadcast a **real shield transaction** on the Sepolia testnet, with every
Ethereum RPC call routed through the mixnet. The shield lands on chain (you can
open it on Etherscan), but the IP that submitted it is the Nym exit's, not yours.
The entire integration is a single ethers shim (shown below). Because the
Railgun engine talks to the chain through the `ethers` library, routing through
`mixFetch` is enough to put a whole privacy SDK behind the mixnet. The same
pattern drops into any `ethers`-based app or library.
## How it works
The [ENS demo](/developers/demos/ens) swapped one provider's transport, but Railgun
constructs its own providers internally, so routing only our provider would leak
the engine's RPC to clearnet. Instead this demo installs a **global** `ethers`
transport: `FetchRequest.registerGetUrl` routes every ethers HTTP call in the
page through `mixFetch`, including the ones the Railgun engine makes.
```ts
import { FetchRequest } from 'ethers';
import { mixFetch } from '@nymproject/mix-fetch';
// Every ethers HTTP request in the process now goes through the mixnet.
FetchRequest.registerGetUrl(async (req) => {
const res = await mixFetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body ?? undefined,
});
return {
statusCode: res.status,
statusMessage: res.statusText,
headers: Object.fromEntries(res.headers),
body: new Uint8Array(await res.arrayBuffer()),
};
});
```
`registerGetUrl` is global static state on the `FetchRequest` class, so this only
works if `ethers` is a **single instance** across your bundle. If your app and
Railgun resolve to different ethers copies, the handler installs on one and the
engine uses the other. Pin the exact version Railgun peer-depends on.
On npm: [`@nymproject/mix-fetch`](https://www.npmjs.com/package/@nymproject/mix-fetch), [`@railgun-community/wallet`](https://www.npmjs.com/package/@railgun-community/wallet), and [`ethers`](https://www.npmjs.com/package/ethers).
Shielding is a four-step flow, all over the mixnet: sign a shield key, estimate
gas, populate the transaction, then sign and broadcast. The broadcast that lands
on Sepolia is observable on Etherscan, but the IP that submitted it stays hidden.
## Try it
<div className="nx-mt-4" />
The demo auto-loads a funded Sepolia testnet wallet. Connect the tunnel (the
Railgun address derives once the engine is up), check the balance, then shield a
small amount. If the wallet is low, top it up at a
[Sepolia faucet](https://sepoliafaucet.com/) using the public address shown.
**Sepolia testnet only.** The wallet holds test ETH and the mnemonic is
stored in plain browser storage. Never paste a mainnet mnemonic.
<NetworkTabCallout />
<RailgunDemo />
## What to expect
- **Engine init is the slow part.** `loadProvider` hits Sepolia over a cold
mixnet route, which can exceed Railgun's internal timeout on the first try; the
demo retries and the second attempt finds the connection pool warm.
- **Shielding makes several RPC calls** (gas estimate, fee data, broadcast,
receipt), each a mixnet round trip. The broadcast step retries idempotently:
the tx hash is fixed before broadcasting, so a dropped response can be re-sent
or detected as already-on-chain.
- **Rate limiting.** If RPC calls start failing with 403/429 or connection
errors, the exit IP is flagged: disconnect, tick **Use random IPR**, reload,
and reconnect for a fresh exit.
## Glossary
<MixnetGlossary />
@@ -9,6 +9,7 @@ lastUpdated: "2026-06-09"
import { Callout } from 'nextra/components'
import { MixPlayground } from '../../components/playground/MixPlayground'
import { MessagingDemo } from '../../components/playground/messaging-section'
import { NetworkTabCallout } from '../../components/demos/shared/NetworkTabCallout'
# Mixnet playground
@@ -19,18 +20,11 @@ This playground runs Nym's browser TypeScript packages against the live mixnet.
Some sections send the same request over the tunnel and over the clearnet, so you can compare the two.
On npm: [`@nymproject/mix-fetch`](https://www.npmjs.com/package/@nymproject/mix-fetch), [`@nymproject/mix-dns`](https://www.npmjs.com/package/@nymproject/mix-dns), [`@nymproject/mix-tunnel`](https://www.npmjs.com/package/@nymproject/mix-tunnel), [`@nymproject/mix-websocket`](https://www.npmjs.com/package/@nymproject/mix-websocket), and [`@nymproject/sdk`](https://www.npmjs.com/package/@nymproject/sdk).
## HTTPS / DNS / WebSockets
<Callout type="info">
**Watch the Network tab.** Open DevTools → Network before you connect. Once
`setupMixTunnel` reports ready, every tunnel operation here (`mixFetch`,
`mixDNS`, `MixWebSocket`) adds **no new request** to that tab: it is multiplexed
inside the single WebSocket to the entry gateway. Only the *clearnet* comparison
buttons add rows. (Setup also fetches the network topology over HTTPS and
refreshes it periodically, so those nym-api calls and the gateway WebSocket are
the only clearnet requests you will see.) Your real traffic never leaves the
browser as an identifiable, per-destination request.
</Callout>
<NetworkTabCallout />
<Callout type="info">
Everything here runs client-side over the live Nym mixnet. The first
+5499 -98
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -7,3 +7,16 @@ allowBuilds:
sharp: false
tiny-secp256k1: false
unrs-resolver: false
# Pulled in by the railgun demo deps. Native crypto / ws speedups (the browser
# bundle uses the JS implementations) and postinstall scripts the webpack-built
# demo does not need; skip their builds (and avoid native-compile failures in CI).
"@railgun-community/wallet": false
blake-hash: false
bufferutil: false
es5-ext: false
keccak: false
secp256k1: false
utf-8-validate: false
web3: false
web3-bzz: false
web3-shh: false
+315 -14
View File
@@ -2,7 +2,7 @@
@version: 1.20.4
@generated: 2026-06-09
@pages: 163
@pages: 165
@source: https://github.com/nymtech/nym/tree/develop/documentation/docs
---
@@ -3671,16 +3671,9 @@ This playground runs Nym's browser TypeScript packages against the live mixnet.
Some sections send the same request over the tunnel and over the clearnet, so you can compare the two.
## HTTPS / DNS / WebSockets
On npm: [`@nymproject/mix-fetch`](https://www.npmjs.com/package/@nymproject/mix-fetch), [`@nymproject/mix-dns`](https://www.npmjs.com/package/@nymproject/mix-dns), [`@nymproject/mix-tunnel`](https://www.npmjs.com/package/@nymproject/mix-tunnel), [`@nymproject/mix-websocket`](https://www.npmjs.com/package/@nymproject/mix-websocket), and [`@nymproject/sdk`](https://www.npmjs.com/package/@nymproject/sdk).
**Watch the Network tab.** Open DevTools → Network before you connect. Once
`setupMixTunnel` reports ready, every tunnel operation here (`mixFetch`,
`mixDNS`, `MixWebSocket`) adds **no new request** to that tab: it is multiplexed
inside the single WebSocket to the entry gateway. Only the *clearnet* comparison
buttons add rows. (Setup also fetches the network topology over HTTPS and
refreshes it periodically, so those nym-api calls and the gateway WebSocket are
the only clearnet requests you will see.) Your real traffic never leaves the
browser as an identifiable, per-destination request.
## HTTPS / DNS / WebSockets
Everything here runs client-side over the live Nym mixnet. The first
`setupMixTunnel` is slow (a few seconds): it loads the WebAssembly client,
@@ -3702,6 +3695,200 @@ For the API of each package, see
[mix-tunnel](/developers/mix-tunnel), [mix-fetch](/developers/mix-fetch),
[mix-dns](/developers/mix-dns), and [mix-websocket](/developers/mix-websocket).
---
title: Demo: ENS resolution over the Nym mixnet
description: Resolve an ENS name to its address and IPFS contenthash, then fetch the site, with every JSON-RPC and gateway request routed through the mixnet via mix-fetch. Shows the ethers-to-mixFetch adapter.
url: https://nym.com/docs/developers/demos/ens
---
export const EnsDemo = dynamic(
() => import('../../../components/demos/ens/EnsDemo').then((m) => m.EnsDemo),
{ ssr: false },
)
# ENS over the mixnet
A normal ENS lookup (name to address to IPFS website) built with
[ethers.js](https://docs.ethers.org/v6/), except every network request goes
through the Nym mixnet instead of leaving over your normal connection. The
Ethereum RPC node and the IPFS gateway see the [IPR](/network/infrastructure/exit-services#ip-packet-router)
exit's IP, not yours, and your ISP cannot see which names or sites you reach.
The trade-off is latency: every packet takes a multi-relay path, so requests are
slower than a direct route.
## How it works
The whole integration is one adapter. ethers v6 exposes
`FetchRequest.getUrlFunc` as a settable property, so you replace its HTTP
transport with a function that calls [`mixFetch`](/developers/mix-fetch). To
ethers it looks like an ordinary fetch; to the mixnet, ethers looks like any
other caller.
```ts
function buildProvider(rpcUrl: string): JsonRpcProvider {
const base = new FetchRequest(rpcUrl);
// Route every JSON-RPC call through mixFetch, renaming the response
// fields ethers expects (status -> statusCode, statusText -> statusMessage).
base.getUrlFunc = async (req) => {
const res = await mixFetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body ?? undefined,
});
return {
statusCode: res.status,
statusMessage: res.statusText,
headers: Object.fromEntries(res.headers),
body: new Uint8Array(await res.arrayBuffer()),
};
};
// staticNetwork skips the eth_chainId probe: one mixnet round trip saved.
return new JsonRpcProvider(base, 'mainnet', { staticNetwork: true });
}
```
One caveat: browser `fetch` decompresses gzip transparently and `mixFetch` does
not, so the demo adds a `DecompressionStream` step after each response (Cloudflare
gzips RPC replies). The full version with decompression and per-call logging is in
[`components/demos/ens/lib.ts`](https://github.com/nymtech/nym/tree/develop/documentation/docs/components/demos/ens).
On npm: [`@nymproject/mix-fetch`](https://www.npmjs.com/package/@nymproject/mix-fetch) and [`ethers`](https://www.npmjs.com/package/ethers).
The lookup itself is three steps, each an Ethereum call or HTTPS GET over the same
tunnel:
1. **Resolve address.** Two `eth_call`s: the ENS Registry's `resolver(node)`
returns the resolver contract for the name, then `resolver.addr(node)` returns
the Ethereum address. `node` is the namehash (recursive keccak256 over the
labels).
2. **Get contenthash.** One more `eth_call`: `resolver.contenthash(node)`. ethers
decodes the EIP-1577 multicodec bytes to a URI; this demo handles `ipfs://`.
3. **Fetch from IPFS.** A plain HTTPS GET to a gateway with the CID as a subdomain
or path label. CIDv0 (`Qm...`) is re-encoded as CIDv1 (`bafy...`) for subdomain
gateways, since DNS is case-insensitive.
## Try it
Connect to bring the tunnel up (a default IPR exit is pinned; tick **Use random
IPR** for auto-discovery), click **Verify IP routing** to confirm traffic exits
through Nym, then run the three steps.
## What to expect
- **The first request is the slow one.** Connecting builds the mixnet client and
handshakes with the IPR; no TCP or TLS yet. The first request to a host then
runs a TCP and TLS handshake carried as IP packets over the mixnet (several
sequential round trips). smolmix keeps that connection warm and reuses it, so
later requests to the same host are much quicker. A long pause is handshakes in
flight, not a hang.
- **You will not see the tunnelled requests in DevTools.** The RPC and IPFS
requests never touch the browser's `fetch`. They leave the worker as encrypted
packets over a single WebSocket to the entry gateway, which is the one
connection the Network tab shows. The exception is **Verify IP routing**, which
deliberately makes one direct clearnet call to ipinfo.io for comparison.
- **Rate limiting.** Public IPFS gateways and Ethereum RPCs rate-limit shared IP
addresses. If requests start failing with 403, 429, or connection errors, the
exit IP is likely flagged: tick **Use random IPR** and reload for a fresh exit.
## Glossary
---
title: Demo: Railgun private payments over the Nym mixnet
description: Shield testnet ETH into a Railgun private note with every Ethereum RPC call routed through the Nym mixnet. Shows the global ethers-to-mixFetch routing that covers a whole SDK.
url: https://nym.com/docs/developers/demos/railgun
---
export const RailgunDemo = dynamic(
() => import('../../../components/demos/railgun/RailgunDemo').then((m) => m.RailgunDemo),
{ ssr: false },
)
# Railgun over the mixnet
Two privacy layers stacked. **Nym** hides the network layer: every Ethereum RPC
call goes through the mixnet via [`mixFetch`](/developers/mix-fetch), so the RPC
node and your ISP cannot link you to the query. **Railgun** hides the
application layer: shielded notes break the on-chain link between sender,
receiver, and amount. This demo shields testnet ETH on Sepolia.
## What you can do here
This page is interactive. You bring up a mixnet tunnel, derive a Railgun wallet,
and broadcast a **real shield transaction** on the Sepolia testnet, with every
Ethereum RPC call routed through the mixnet. The shield lands on chain (you can
open it on Etherscan), but the IP that submitted it is the Nym exit's, not yours.
The entire integration is a single ethers shim (shown below). Because the
Railgun engine talks to the chain through ethers, routing ethers through
`mixFetch` is enough to put a whole privacy SDK behind the mixnet. The same
pattern drops into any ethers-based app or library.
## How it works
The [ENS demo](/developers/demos/ens) swapped one provider's transport. Railgun
constructs its own providers internally, so routing only our provider would leak
the engine's RPC to clearnet. Instead this demo installs a **global** ethers
transport: `FetchRequest.registerGetUrl` routes every ethers HTTP call in the
page through `mixFetch`, including the ones the Railgun engine makes.
```ts
// Every ethers HTTP request in the process now goes through the mixnet.
FetchRequest.registerGetUrl(async (req) => {
const res = await mixFetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body ?? undefined,
});
return {
statusCode: res.status,
statusMessage: res.statusText,
headers: Object.fromEntries(res.headers),
body: new Uint8Array(await res.arrayBuffer()),
};
});
```
`registerGetUrl` is global static state on the `FetchRequest` class, so this only
works if ethers is a **single instance** across your bundle. If your app and
Railgun resolve to different ethers copies, the handler installs on one and the
engine uses the other. Pin the exact ethers version Railgun peer-depends on (this
demo aliases ethers to one instance in the bundler).
On npm: [`@nymproject/mix-fetch`](https://www.npmjs.com/package/@nymproject/mix-fetch), [`@railgun-community/wallet`](https://www.npmjs.com/package/@railgun-community/wallet), and [`ethers`](https://www.npmjs.com/package/ethers).
Shielding is a four-step flow, all over the mixnet: sign a shield key, estimate
gas, populate the transaction, then sign and broadcast. The broadcast that lands
on Sepolia is observable on Etherscan, but the IP that submitted it stays hidden.
## Try it
The demo auto-loads a funded Sepolia testnet wallet. Connect the tunnel (the
Railgun address derives once the engine is up), check the balance, then shield a
small amount. If the wallet is low, top it up at a
[Sepolia faucet](https://sepoliafaucet.com/) using the public address shown.
**Sepolia testnet only.** The wallet holds only test ETH and the mnemonic is
stored in plain browser storage. Never paste a mainnet mnemonic.
## What to expect
- **Engine init is the slow part.** `loadProvider` hits Sepolia over a cold
mixnet route, which can exceed Railgun's internal timeout on the first try; the
demo retries and the second attempt finds the connection pool warm.
- **Shielding makes several RPC calls** (gas estimate, fee data, broadcast,
receipt), each a mixnet round trip. The broadcast step retries idempotently:
the tx hash is fixed before broadcasting, so a dropped response can be re-sent
or detected as already-on-chain.
- **Rate limiting.** If RPC calls start failing with 403/429 or connection
errors, the exit IP is flagged: disconnect, tick **Use random IPR**, reload,
and reconnect for a fresh exit.
## Glossary
---
title: mix-tunnel: Shared Mixnet Tunnel for the Browser
description: TypeScript package that owns the shared Nym mixnet tunnel in the browser. The base layer for mix-fetch, mix-dns, and mix-websocket.
@@ -4089,7 +4276,7 @@ Consequences:
- **One WASM module, smaller bundle.** v1's Go runtime accounted for ~6 MB of the full-fat bundle; v2 drops it.
- **Shared infrastructure with `mix-dns` and `mix-websocket`.** The same tunnel handles all three.
- **IPR exit policies apply.** What was allowed by your previous Network Requester may not be allowed by your default IPR; pin one with `preferredIpr` if you need a specific exit policy.
- **IPR exit policies apply.** What was allowed by your previous Network Requester may not be allowed by your default IPR, which applies the current [Nym exit policy](https://nymtech.net/.wellknown/network-requester/exit-policy.txt).
---
title: mix-dns: Hostname Resolution Over the Nym Mixnet
@@ -10096,7 +10283,7 @@ The outcome of [NIP-10: Nym Exit Policy Update Opening Ports for Dash, SIP,
- [New documentation logic to `network/`, `developers/` and `apis/`](https://github.com/nymtech/nym/pull/6494) according to the [diataxis.fr](https://diataxis.fr/) framework, making basis for adding Lewes Protocol documentation. Additionally developer docs now include tutorials for the [Rust SDK modules](/developers/rust), and documentation on the `stream` [Mixnet module](/developers/rust/mixnet). See the pages at:
- [Network docs](/network)
- [Developer docs](/developers/integrations)
- [Developer docs](/developers)
- [APIs docs](/apis/introduction)
#### Update Nym exit policy
@@ -10748,7 +10935,7 @@ cargo Profile: release
- [Typescript SDK 1.4.1](https://github.com/nymtech/nym/pull/6146): This PR is a new release of the Typescript SDK, `mixFetch` and `WASM` client. It also removes the Harbour Master client from `mixFetch`, replacing it with the Nym API's described endpoint for nym-nodes
- [Overhauled **developer integrations** pages](/developers/integrations) explaining the different restrictions for the different SDK options on offer
- [Overhauled **developer integrations** pages](/developers) explaining the different restrictions for the different SDK options on offer
- [Fixed `mixFetch` and `WASM Client` playground + examples](/developers/typescript): new versions of the Typescript SDK and `mixFetch` have been published, examples and live playground have been updated accordingly
@@ -20358,9 +20545,109 @@ ansible-playbook deploy.yml
###### 2. Bond
Anyone having acces to your account mnemonic can take all your funds and manage manage your node, be careful where you store it!
Bonding can be managed via two playbooks:
1. `bond.yml`: an interactive way, requiring operator to use own wallet (desktop or CLI)
2. `auto-bond.yml`: automatic bonding flow requiring operator to prepare `nodes.csv` and have `nym-cli` installed
<Tabs items={[
<code>bond.yml</code>,
<code>auto-bond.yml</code>,
]} defaultIndex="1">
A playbook to *interactively* register your nodes to Nym network by bonding it to Nyx blockchain accounts.
This playbook is intercative as it prompts user for data from Nym wallet to sign a message. It will run roles on one inventory entry at a time by default.
**Requirements**
- Nym Wallet or `nym-cli` to be used as a CLI wallet
- An account per each node
- At least 101 NYM per account
**Usage**
1. Sign in to the wallet per node
2. Follow steps in `Bond` section
3. Run the playbook on a side and follow the prompts
```sh
cd playbooks
ansible-playbook bond.yml
```
Your nodes are bonded and will show in the network in the next epoch (max 60min).
A playbook to *automatically* register your nodes to Nym Network by bonding it to Nyx blockchain accounts.
This automatic flow is slightly harder to setup in the beginning and it's recommended for operators bonding many nodes, as the initial work is worth it by saving the time of bonding a node at a time.
**Requirements**
- Installed [`nym-cli`](/developers/tools/nym-cli)
- Python3
- Nym repository with directory `scripts/nym-node-setup/auto-bond/`, containing:
- Python program [`auto_bond_all.py`](https://github.com/nymtech/nym/tree/develop/scripts/nym-node-setup/auto-bond/auto_bond_all.py)
- `nodes.csv.example` with correct data
**Usage**
1. Copy `nodes.csv.example` to your prefered location without `.example` suffix
2. Fill correctly the csv columns for each node you want to bond:
- `inventory_node_id`: Your Ansible inventory node ID (ie `node1`)
- `hostname`: same like in your `playbooks/inventory/all` (without `https://` !)
- `ip`: same like `ansible_host` value in your Ansible inventory
- `account`: Nyx account to bond this node with
- `mnemonic`: Your nyx acount mnemonic
- `identity_key`: node identity key - the easiest way to get it is to navigate to `playbooks/` and run:
```sh
ansible all -i inventory/all -a "/root/nym-binaries/nym-node bonding-information"
```
- `amount`: Amount to bond in `uNYM` (1 NYM = 1 000 000 uNYM), Make sure to leave extra 1 NYM (1 000 000 uNYM) for fees
- `operator_cost`: [Operator cost](/operators/tokenomics/mixnet-rewards#rewards-distribution) in `uNYM` (1 NYM = 1 000 000 uNYM)
3. Save the csv
4. Run `auto_bond_all.py` with all needed arguments.
- To see help menu:
```sh
python3 ./auto_bond_all.py --help
```
- To test your paths run with `--dry-run`
- Argument usage:
```sh
--ansible-repo ANSIBLE_REPO
Path to ansible playbooks directory (contains auto-bond.yml and inventory/)
--cli-dir CLI_DIR Directory containing the nym-cli binary
--dry-run Print commands without executing
```
- Example (note that the `nodes.csv` has no flag as it's a required argument):
```sh
python ./auto_bond_all.py \
--ansible-repo ~/admin/nym-nodes/nym-nodes-ansible/playbooks \
--cli-dir ~/repos/nymtech/nym/target/release \
~/admin/nym-nodes/nodes.csv
```
5. Your nodes should be bonded and come up in the next epoch (max 60min)
**Additional scripts**
Your `nodes.csv` can be used for other operations:
- [`show_balances.py`](https://github.com/nymtech/nym/tree/develop/scripts/nym-node-setup/auto-bond/auto_bond_all.py): Shows all accounts balances if provided with Nyx accounts (`account` column)
- [`unbond_all.py`](https://github.com/nymtech/nym/tree/develop/scripts/nym-node-setup/auto-bond/unbond_all.py): Unbond all nodes in the csv if provided with mnemonics (`mnemonic` column)
A playbook to interactively register your node to Nym network by bonding it to Nyx blockchain account.
This playbook is intercative as it prompts user for data from Nym wallet to sign a message. It will run roles on one inventory entry at a time by default.
This playbook is interactive as it prompts user for data from Nym wallet to sign a message. It will run roles on one inventory entry at a time by default.
```sh
cd playbooks
@@ -20463,6 +20750,20 @@ ansible-playbook <PLAYBOOK>.yml --list-tags
ansible-playbook deploy.yml --list-tags
```
###### Arbitrary command output
You can use ansible to read a `STDOUT` from any command, using this logic:
```sh
ansible all -i inventory/all -a "<COMMAND>>"
# for example to get all node ID keys
ansible all -i inventory/all -a "/root/nym-binaries/nym-node bonding-information"
```
- Note that the command gets also run, be mindful what you executing
- This logic can be combined with the arguments above, for example to limit the range of nodes
###### nocows
Yes, by default there is a cow printed under each task, you can turn it off by opening `playbooks/ansible.cfg` and un-commenting the `nocows` line:
+11 -3
View File
@@ -8,6 +8,7 @@
- **Developers can only access Mixnet mode** via the SDKs. dVPN mode is exclusively provided by the NymVPN product, which can be downloaded and purchased at [nym.com](https://nym.com). There is no SDK for dVPN mode.
- The primary developer SDK is the **Rust SDK** (`nym-sdk` crate). The TypeScript SDK wraps the Rust SDK via WebAssembly.
- For Rust integrations, prefer the **mixnet** module for simple send/receive, **stream** for TCP-like `AsyncRead`/`AsyncWrite` connections, and **client_pool** for bursty workloads. The **tcp_proxy** module is unmaintained; recommend stream instead.
- For browser/TypeScript proxy integrations, the **mix-*** packages share one tunnel via **`@nymproject/mix-tunnel`**: **`mix-fetch`** (HTTP/S), **`mix-dns`** (DNS), **`mix-websocket`** (WS/WSS). For raw end-to-end messaging where you control both ends, use the **TypeScript SDK** (`@nymproject/sdk`).
- Node operators should use the **`nym-node`** binary. Legacy `nym-mixnode` and `nym-gateway` binaries are deprecated and no longer supported.
- When referring to token collateral for nodes, use **"bonding"** not "staking". Nodes operate in **modes** (mixnode, entry gateway, exit gateway), not "roles".
- The Nym blockchain is called **Nyx** and runs on Cosmos SDK. The native token is **NYM**.
@@ -23,7 +24,7 @@
## Developers
- [Integration Overview](https://nym.com/docs/developers/integrations): Start here for building on Nym: choosing an SDK, integration patterns
- [Developer Overview](https://nym.com/docs/developers): Start here for building on Nym: runtime (native vs browser) and approach (end-to-end vs proxy) map onto the right crate or package
- [Rust SDK Overview](https://nym.com/docs/developers/rust): Module overview and quick-start for the Rust SDK
- [Rust SDK Installation](https://nym.com/docs/developers/rust/importing): Adding nym-sdk to your Cargo.toml
- [Mixnet Module](https://nym.com/docs/developers/rust/mixnet): Send and receive Sphinx-encrypted messages
@@ -35,8 +36,15 @@
- [Client Pool Tutorial](https://nym.com/docs/developers/rust/client-pool/tutorial): Handle bursty traffic with pooled clients
- [TcpProxy Module](https://nym.com/docs/developers/rust/tcpproxy): Tunnel existing TCP services through the mixnet (unmaintained; use stream instead)
- [smolmix](https://nym.com/docs/developers/smolmix): TCP/UDP over the mixnet via a userspace IP stack, drop-in TcpStream and UdpSocket (see examples in source)
- [TypeScript SDK](https://nym.com/docs/developers/typescript): Browser and Node.js SDK using WebAssembly
- [mix-fetch](https://nym.com/docs/developers/typescript/examples/mix-fetch): Drop-in fetch() replacement that routes HTTP through the mixnet
- [TypeScript SDK](https://nym.com/docs/developers/typescript): Browser-side raw end-to-end messaging (@nymproject/sdk) and Nyx smart-contract bindings
- [mix-tunnel](https://nym.com/docs/developers/mix-tunnel): Shared browser mixnet tunnel; the base layer for mix-fetch, mix-dns, and mix-websocket
- [mix-fetch](https://nym.com/docs/developers/mix-fetch): Drop-in fetch() replacement routing HTTP(S) through the mixnet via an IPR exit
- [mix-dns](https://nym.com/docs/developers/mix-dns): Hostname-to-IP resolution through the mixnet (UDP DNS via an IPR exit)
- [mix-websocket](https://nym.com/docs/developers/mix-websocket): WebSocket-like class for WS and WSS over the mixnet
- [mix-* Architecture](https://nym.com/docs/developers/mix-architecture): How the shared browser tunnel, Web Worker, Comlink boundary, and smoltcp+rustls stack are wired
- [Playground](https://nym.com/docs/developers/playground): Interactive browser playground driving the mix-* packages and the raw messaging SDK against the live mixnet
- [ENS Demo](https://nym.com/docs/developers/demos/ens): Resolve ENS names and fetch IPFS sites over the mixnet via an ethers-to-mixFetch shim
- [Railgun Demo](https://nym.com/docs/developers/demos/railgun): Shield testnet ETH into a Railgun private note with every Ethereum RPC routed through the mixnet
## Operators