Developers
Demos
Shielding ETH with Railgun

Shielding testnet ETH into Railgun over the mixnet

Nym hides the network layer: every Ethereum RPC call goes through the mixnet via mixFetch, so the RPC node and your ISP cannot link you to the query, and Railgun hides the application layer, as shielded notes break the on-chain link between sender, receiver, and amount. This demo covers just the shield step on Sepolia: depositing testnet ETH into a private note. It does not do private transfers or unshielding.

What you can do here

This page is interactive. You bring up a mixnet tunnel, derive a Railgun wallet, and broadcast a real shield transaction on the Sepolia testnet, with every Ethereum RPC call routed through the mixnet. The shield lands on chain (you can open it on Etherscan), but the IP that submitted it is the Nym exit's, not yours.

The network-routing piece is a single ethers shim (shown below). Because the Railgun engine talks to the chain through the ethers library, routing every ethers call through mixFetch is enough to put the whole SDK's traffic behind the mixnet, and the same routing pattern drops into any ethers-based app. Railgun itself needs a few extra engine-config workarounds (disabling the POI gate and stubbing quick-sync), which live in components/demos/railgun/lib.ts (opens in a new tab), not in the shim.

How it works

The ENS demo swapped one provider's transport, but Railgun constructs its own providers internally, so routing only our provider would leak the engine's RPC to clearnet. Instead this demo installs a global ethers transport: FetchRequest.registerGetUrl routes every ethers HTTP call in the page through mixFetch, including the ones the Railgun engine makes.

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,
    // ethers' FetchRequest.body is Uint8Array | null; cast for RequestInit.
    body: (req.body ?? undefined) as BodyInit | undefined,
  });
  return {
    statusCode: res.status,
    statusMessage: res.statusText,
    headers: Object.fromEntries(res.headers),
    body: new Uint8Array(await res.arrayBuffer()),
  };
});

registerGetUrl is global static state on the FetchRequest class, so this only works if ethers is a single instance across your bundle. If your app and Railgun resolve to different ethers copies, the handler installs on one and the engine uses the other. Pin the exact version Railgun peer-depends on.

The block above is the shape of the shim. One caveat the real version adds: browser fetch decompresses gzip transparently and mixFetch does not, so the demo runs a DecompressionStream over each response before handing it back to ethers. The full handler with decompression is in components/demos/shared/mixfetch.ts (opens in a new tab).

On npm: @nymproject/mix-fetch (opens in a new tab), @railgun-community/wallet (opens in a new tab), and ethers (opens in a new tab).

Shielding is a four-step flow, all over the mixnet: sign a shield key, estimate gas, populate the transaction, then sign and broadcast. The broadcast that lands on Sepolia is observable on Etherscan, but the IP that submitted it stays hidden.

Try it

The demo auto-loads a funded Sepolia testnet wallet. Connect the tunnel (the Railgun address derives once the engine is up), check the balance, then shield a small amount. If the wallet is low, top it up at a Sepolia faucet (opens in a new tab) using the public address shown.

Sepolia testnet only. The wallet holds test ETH and the mnemonic is stored in plain browser storage. Never paste a mainnet mnemonic.

Watch the Network tab. Open DevTools → Network before you connect. Once the tunnel reports ready, every operation you run here adds no new request to that tab: it is multiplexed inside the single WebSocket to the entry gateway. Only the clearnet comparison buttons add rows. (Setup also fetches the network topology over HTTPS and refreshes it periodically, so those nym-api calls and the gateway WebSocket are the only clearnet requests you will see.) Your real traffic never leaves the browser as an identifiable, per-destination request.

What to expect

  • Engine init is the slow part. loadProvider's first RPC calls hit Sepolia over a cold mixnet route, paying TCP-connect and TLS-handshake time, so the ethers request can time out on the first try; the demo retries and the second attempt finds the connection pool warm.
  • Shielding makes several RPC calls (gas estimate, fee data, broadcast, receipt), each a mixnet round trip. The broadcast step retries idempotently: the tx hash is fixed before broadcasting, so a dropped response can be re-sent or detected as already-on-chain.
  • Rate limiting. If RPC calls start failing with 403/429 or connection errors, the exit IP is flagged: disconnect, tick Use random IPR, reload, and reconnect for a fresh exit.

Glossary

  • Mixnet. An overlay network that routes your traffic through several relays, mixed in with everyone else's, so no single point can link sender to receiver. See mixnet mode.
  • Entry gateway. Your first hop into the mixnet. The browser holds one WebSocket to it, and all tunnelled traffic travels over that single connection as opaque frames. See Nym nodes.
  • IPR (IP Packet Router), the exit. Where traffic leaves the mixnet for the public internet. The destination sees the IPR's IP, not yours. See exit services.
  • SURB (single-use reply block). A prepaid, single-use return envelope. The exit replies through it without ever learning your address. See anonymous replies.
  • Cover traffic / Poisson timing. Decoy packets sent on randomised timing, so your real traffic blends into a steady stream. See cover traffic.
  • mixFetch. A fetch()-shaped function from @nymproject/mix-fetch. It runs the mixnet client (smolmix) in a Web Worker, so each request goes through the mixnet rather than the browser's network stack.