demos added to docs
This commit is contained in:
@@ -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}>Confirm traffic exits through Nym before resolving.</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,252 @@
|
||||
// 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;
|
||||
|
||||
const stream = new Blob([body]).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,
|
||||
});
|
||||
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,321 @@
|
||||
// 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'];
|
||||
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 [shieldAmount, setShieldAmount] = useState('0.001');
|
||||
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: shieldAmount.trim(),
|
||||
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 : ''} 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} 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>
|
||||
|
||||
<div style={box}>
|
||||
<div style={legend}>Shield ETH into a private note</div>
|
||||
<div style={row}>
|
||||
<label style={sub}>amount (ETH)</label>
|
||||
<input style={input} value={shieldAmount} onChange={(e) => setShieldAmount(e.target.value)} placeholder="0.001" />
|
||||
<Button onClick={shield} disabled={!connected || !railgunWallet || busy}>Shield</Button>
|
||||
</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,214 @@
|
||||
// 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...');
|
||||
const gasEstResp = 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)}`);
|
||||
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,224 @@
|
||||
// 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);
|
||||
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,
|
||||
});
|
||||
setConnected(true);
|
||||
setStatus({ text: 'Connected', colour: 'green' });
|
||||
log('tunnel', 'Tunnel ready', 'green');
|
||||
onReady(m.mixFetch);
|
||||
} catch (e) {
|
||||
setStatus({ text: 'Failed', colour: 'red' });
|
||||
log('tunnel', `Connection failed: ${e}`, '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} />
|
||||
<label style={{ ...sub, marginLeft: 'auto', cursor: 'pointer' }} onClick={() => setShowAdvanced((v) => !v)}>
|
||||
{showAdvanced ? '▾ advanced' : '▸ advanced'}
|
||||
</label>
|
||||
</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,119 @@
|
||||
// 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;
|
||||
const stream = new Blob([body]).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,
|
||||
});
|
||||
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": 659,
|
||||
"nodes": 679,
|
||||
"locations": 75,
|
||||
"mixnodes": 238,
|
||||
"exit_gateways": 413
|
||||
"mixnodes": 240,
|
||||
"exit_gateways": 431
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
Tuesday, June 9th 2026, 08:23:52 UTC
|
||||
Tuesday, June 9th 2026, 15:17:20 UTC
|
||||
|
||||
@@ -8,10 +8,9 @@ Commands:
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the Nym API [env:
|
||||
NYMAPI_CONFIG_ENV_FILE_ARG=]
|
||||
--no-banner A no-op flag included for consistency with other binaries (and compatibility with
|
||||
nymvisor, oops) [env: NYMAPI_NO_BANNER_ARG=]
|
||||
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the Nym API [env: NYMAPI_CONFIG_ENV_FILE_ARG=]
|
||||
--no-banner A no-op flag included for consistency with other binaries (and compatibility with nymvisor, oops) [env:
|
||||
NYMAPI_NO_BANNER_ARG=]
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
@@ -12,8 +12,8 @@ Commands:
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nym-node and overrides any
|
||||
preconfigured values [env: NYMNODE_CONFIG_ENV_FILE_ARG=]
|
||||
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nym-node and overrides any preconfigured values [env:
|
||||
NYMNODE_CONFIG_ENV_FILE_ARG=]
|
||||
--no-banner Flag used for disabling the printed banner in tty [env: NYMNODE_NO_BANNER=]
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
|
||||
@@ -12,141 +12,119 @@ Options:
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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]
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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=]
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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=]
|
||||
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=]
|
||||
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]
|
||||
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
|
||||
```
|
||||
|
||||
@@ -11,8 +11,7 @@ Commands:
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nymvisor and overrides any
|
||||
preconfigured values
|
||||
-c, --config-env-file <CONFIG_ENV_FILE> Path pointing to an env file that configures the nymvisor and overrides any preconfigured values
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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": "Railgun private payments"
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
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 },
|
||||
)
|
||||
|
||||
# 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).
|
||||
|
||||
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.
|
||||
|
||||
<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 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.
|
||||
- **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
|
||||
|
||||
- **Mixnet.** An overlay network that routes your traffic through several relays
|
||||
and mixes it with other people's, hiding who is talking to whom. Nym operates one.
|
||||
- **Entry gateway.** Your first hop into the mixnet. Your browser holds one
|
||||
WebSocket to it; all tunnelled traffic rides that connection as opaque frames.
|
||||
- **IPR (IP Packet Router), the exit.** The mixnet's exit point onto the normal
|
||||
internet. The RPC node and IPFS gateway see the IPR's IP address, never yours.
|
||||
- **SURB (single-use reply block).** A prepaid, single-use return envelope. It
|
||||
lets the exit send a reply back through the mixnet without learning your address.
|
||||
- **Cover traffic / Poisson timing.** Decoy packets and randomised send timing.
|
||||
Together they keep your real traffic statistically hard to pick out.
|
||||
- **mixFetch.** The [`@nymproject/mix-fetch`](/developers/mix-fetch) package's
|
||||
`fetch()`-shaped function. It runs the mixnet client (smolmix) in a Web Worker
|
||||
and sends your request through the mixnet instead of the browser's network stack.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
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."
|
||||
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 },
|
||||
)
|
||||
|
||||
# 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
|
||||
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 ethers version Railgun peer-depends on (this
|
||||
demo aliases ethers to one instance in the bundler).
|
||||
|
||||
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 only test ETH and the mnemonic is
|
||||
stored in plain browser storage. Never paste a mainnet mnemonic.
|
||||
|
||||
<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.
|
||||
Generated
+5499
-98
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user