README
¶
zecmix: Zcash light-wallet traffic over the Nym mixnet
zecmix hides your IP address from the lightwalletd server your Zcash wallet
syncs from and broadcasts through. It has two parts:
- a local lightwalletd gateway:
zecmix gatewaylistens on127.0.0.1:9067and forwards every gRPC call from your wallet to a public lightwalletd server (defaultzec.rocks:443) through the Nym mixnet. Any wallet or tool that accepts a custom lightwalletd server can use it, with no code changes to the wallet. Tested live withzcash-devtool. - a CLI that speaks lightwalletd over the mixnet directly:
info,tip,blocks(streams compact blocks),treestate,tx, andsend(raw transaction broadcast), plusipto show which address the servers see.
It is a single static Go binary. It drives Nym's official nym-socks5-client
and does not reimplement any mixnet cryptography. Sphinx packets, cover
traffic, Poisson delays and gateway handling all come from Nym's code.
By apeir (https://apeir.dk/online/).
The problem
A Zcash light wallet hides amounts, addresses and memos on chain. The network layer is a separate matter. To sync, the wallet connects to a lightwalletd server (or a Zaino indexer) and:
- downloads compact blocks from its birthday height, which roughly tells the server when the wallet was created;
- asks for tree states, transactions and, for transparent addresses, UTXOs and history by address;
- broadcasts its own transactions with
SendTransaction.
All of this is tied to the client's IP address. A server operator, or anyone who can watch or subpoena it, learns "this IP sent transaction X" and "this IP's wallet is about this old and syncs at these times". For transparent address queries it learns "this IP owns these addresses". Shielded crypto cannot fix that, because it is metadata about the connection.
ZecHub's issue #2219
("Routing Zcash Wallet Traffic Over the Nym Mixnet") proposes a guide on
system-level setup for exactly this gap. zecmix is a tool for the job: a
single command that puts a wallet's lightwalletd traffic on the mixnet without
a VPN, a system-wide proxy, or a server run by anyone.
Design
wallet (zcash-devtool, zingo-cli, your script)
│ plaintext h2c gRPC (or TLS with a local cert), loopback only
▼
zecmix gateway 127.0.0.1:9067 ── HTTP/2 reverse proxy, streams + trailers preserved
│ TLS 1.3 to zec.rocks (verified against system roots, SNI zec.rocks, ALPN h2)
▼ ...inside a SOCKS5 stream
nym-socks5-client 127.0.0.1:1080 ── Sphinx packets, cover traffic, reply SURBs
▼
entry gateway → 3 mix layers → exit gateway (network requester, Nym exit policy)
▼ TCP
public lightwalletd (zec.rocks:443) ── sees the exit gateway's IP, not yours
- End-to-end TLS. The exit gateway relays ciphertext only. The server certificate is verified for the real hostname.
- One mixnet stream per gateway session. All of a wallet's calls are multiplexed as HTTP/2 streams over a single TLS connection inside one SOCKS5 stream. You pay the mixnet setup cost once, and a sync does not open hundreds of streams.
- No local DNS. By default (
--dns doh) server names are resolved with DNS-over-HTTPS sent through the mixnet (Cloudflare 1.1.1.1, then Google 8.8.8.8, both reached by IP literal), then connected by IPv4. The reason: some exits have broken IPv6 routes and stall on AAAA records (I hit this while building mixpool).--dns exithands the hostname to the exit instead. Either way, your local resolver never sees the server name. The gRPC client uses thepassthroughresolver on purpose, because gRPC's defaultdnsresolver would look the name up locally. - No clearnet fallback. If
nym-socks5-clientis not running, the CLI refuses to connect, and the gateway answers every call with gRPCUNAVAILABLE("upstream unreachable over the Nym mixnet"). It never goes direct. - No identifying headers. The gateway sets
:authorityto the upstream host, never addsX-Forwarded-For/Forwarded/Via, strips them if a client sends them, and replaces the wallet's User-Agent with a generic one. - Loopback only. The gateway refuses a non-loopback listen address unless
you pass
--allow-remote.
How Nym is used
zecmix nym setupasks the Nym API for active exit gateways that run a network requester with the exit policy and perform at 0.95 or better. It picks one at random, runsnym-socks5-client initwith reply SURBs on (so the exit never learns the client's mixnet address), and starts the client.- lightwalletd ports are allowed by the Nym exit policy: 443 (HTTPS), 9067 (in the 8081-9080 range), and 8232-8233 (listed as Zcash).
nym-socks5-client1.1.86 (release v2026.19) writes a config atinitthat its ownruncannot load (missing field credential_requests_database). zecmix patches it automatically. If you run the client by hand, addcredential_requests_database = '<data dir>/credential_requests.db'undercredentials_database.
Install
# 1. zecmix (Go 1.26+)
go install github.com/apeirs/zecmix/cmd/zecmix@latest
# 2. Nym's SOCKS5 client: download nym-socks5-client from
# https://github.com/nymtech/nym/releases (nym-binaries-*), check its sha256
# against the release's hashes.json, chmod +x, then:
export ZECMIX_NYM_SOCKS5=/path/to/nym-socks5-client
# 3. Join the mixnet (random active exit gateway, reply SURBs on)
zecmix nym setup
Usage
Gateway for wallets
zecmix gateway -v # http://127.0.0.1:9067 -> mixnet -> zec.rocks:443
zecmix gateway --server na.zec.rocks:443
zecmix gateway --tls --listen 127.0.0.1:9443 # https://localhost:9443, self-signed cert
Then point the wallet at the gateway:
# zcash-devtool (treats localhost as plaintext)
zcash-devtool wallet -w ./w init --name demo -i ./w.age -n main -s 127.0.0.1:9067
zcash-devtool wallet -w ./w sync -s 127.0.0.1:9067
# zingo-cli (not tested here; its docs say a scheme-less --server becomes http://)
zingo-cli --server 127.0.0.1:9067 --data-dir ./zw
# zecmix itself, through the gateway
zecmix info --server http://127.0.0.1:9067
On startup the gateway does a self-check: one GetLightdInfo through itself,
and so through the mixnet. It then logs one line per proxied call: method,
gRPC status, bytes and time.
Direct CLI
zecmix info # GetLightdInfo
zecmix tip # GetLatestBlock
zecmix blocks tip-9 tip # GetBlockRange: stream + check the prev-hash chain
zecmix treestate 3513720 # GetTreeState (or `latest`)
zecmix tx <txid> [--raw] # GetTransaction
zecmix send tx.hex # SendTransaction (hex string, file, or - for stdin)
zecmix ip # what IP do servers see? checked against the nym-api node list
zecmix servers # known public servers
zecmix nym exits | status | stop
Common flags: --server host:port, --proxy 127.0.0.1:1080, --dns doh|exit,
-v (trace every mixnet step), --json, --timeout 3m. Environment
variables: ZECMIX_SERVER, ZECMIX_PROXY, ZECMIX_DNS, ZECMIX_NYM_SOCKS5.
send checks the transaction header locally first (hex, version, version group
id), so a copy-paste mistake never reaches the network. It exits 3 when the
server rejects the transaction.
Proof: live run on the mainnet mixnet
All output below is from a real run on 2026-10-11 against Zcash mainnet
through the live Nym mixnet (nym-socks5-client 1.1.86, release v2026.19).
Full logs: examples/live-run-cli.txt and
examples/live-run-gateway-zcash-devtool.txt.
Servers see a Nym exit gateway, not me. zecmix nym setup picked exit
node 3156 (CH) at random from 98 active exits. ipify, reached over the same
path, reports that node's IP:
$ zecmix ip -v
[ 3.91s] dns api.ipify.org -> [104.26.13.205 104.26.12.205 172.67.74.152] (DoH via 1.1.1.1, over the mixnet, 3.91s)
[ 3.91s] dial api.ipify.org:443 -> socks5 127.0.0.1:1080 -> mixnet -> exit -> 104.26.13.205:443
https://api.ipify.org sees this request coming from 185.191.239.61 (8.30s over the mixnet)
= Nym node 3156 (CH, exit gateway: true) in the nym-api node list
GetLightdInfo / GetLatestBlock, mainnet height:
$ zecmix info -v
[ 0.00s] lwd server zec.rocks:443 over the Nym mixnet (socks5 127.0.0.1:1080, dns=doh); no clearnet fallback
[ 3.11s] dns zec.rocks -> [137.66.62.60] (DoH via 1.1.1.1, over the mixnet, 3.11s)
[ 3.11s] dial zec.rocks:443 -> socks5 127.0.0.1:1080 -> mixnet -> exit -> 137.66.62.60:443
server zec.rocks:443 (via Nym mixnet, 7.08s)
chain main
block height 3513917 (estimated 3513918)
vendor ECC LightWalletD
version v0.5.3 (062e8bfc5bb47e42e3c26567cd6e093cfb854ed4)
consensus branch 37a5165b
$ zecmix tip
3513917 0000000000536e9a4fe3af7f4fb2a5f786525ee35a8e3dab3e4c4359c3581ada (zec.rocks:443, via Nym mixnet, 5.35s)
GetBlockRange, streaming compact blocks (prev-hash chain checked; the IRONW column is the new NU6.3 Ironwood pool):
$ zecmix blocks tip-9 tip
HEIGHT HASH TIME (UTC) TXS T-IN T-OUT SAPOUT SAPSP ORCH IRONW BYTES
3513908 000000000039c21fddd8f98aed70d284fdd7605c2df546a5304495ff0e8a56ed 2026-10-11 09:19:09 1 0 0 2 1 0 0 418
...
3513917 0000000000536e9a4fe3af7f4fb2a5f786525ee35a8e3dab3e4c4359c3581ada 2026-10-11 09:27:36 3 0 0 0 0 0 7 1325
10 compact blocks 3513908..3513917, 6022 bytes, 8 sapling outputs, 0 orchard + 22 ironwood actions, prev-hash chain linked; 2.20s (zec.rocks:443, via Nym mixnet)
SendTransaction: the server's rejection comes back through the mixnet.
No coins move. First, a real mainnet transaction from block 3,513,720
(fetched with zecmix tx --raw over the mixnet) is re-broadcast. Then a
malformed one is sent:
$ zecmix send examples/already-mined-tx.hex -v
tx: v6 (overwintered, version group d884b698, consensus branch 37a5165b), 21790 bytes
REJECTED zec.rocks:443 code -25 4.80s failed to validate tx: WtxId("private"), error: transaction is already in state (via Nym mixnet)
$ zecmix send examples/invalid-tx.hex
tx: v5 (overwintered, version group 26a7270a, consensus branch 00000000), 68 bytes
REJECTED zec.rocks:443 code -22 7.24s parse error: invalid consensus branch id (via Nym mixnet)
A real light wallet syncing through the gateway. This is unmodified
zcash-devtool (built from zcash/zcash-devtool main, no Nym support of its
own), with a fresh zero-balance wallet:
$ zecmix gateway -v
[ 0.00s] gateway listening on http://127.0.0.1:9067 -> socks5 127.0.0.1:1080 -> Nym mixnet -> exit gateway -> zec.rocks:443 (TLS)
[ 5.52s] tls zec.rocks TLS 1.3 alpn="h2" verified for zec.rocks (2.42s)
[ 7.27s] self-check ok: upstream ECC LightWalletD v0.5.3, chain=main height=3513950 (7.27s through the gateway)
[ 19.14s] call #2 .../GetLatestBlock -> zec.rocks:443 via mixnet: grpc-status=0, 44 bytes, 2.82s
[ 20.26s] call #3 .../GetTreeState -> zec.rocks:443 via mixnet: grpc-status=0, 2925 bytes, 1.12s
[ 26.75s] call #4 .../GetSubtreeRoots -> zec.rocks:443 via mixnet: grpc-status=0, 86888 bytes, 2.61s
[ 28.69s] call #5 .../GetSubtreeRoots -> zec.rocks:443 via mixnet: grpc-status=0, 59345 bytes, 1.94s
[ 29.41s] call #6 .../GetSubtreeRoots -> zec.rocks:443 via mixnet: grpc-status=0, 858 bytes, 0.73s
[ 30.51s] call #7 .../GetLatestBlock -> zec.rocks:443 via mixnet: grpc-status=0, 44 bytes, 1.09s
[ 31.54s] call #8 .../GetAddressUtxosStream -> zec.rocks:443 via mixnet: grpc-status=0, 0 bytes, 1.03s
[ 33.33s] call #9 .../GetBlockRange -> zec.rocks:443 via mixnet: grpc-status=0, 78999 bytes, 1.79s
[ 34.69s] call #10 .../GetTreeState -> zec.rocks:443 via mixnet: grpc-status=0, 2925 bytes, 1.36s
$ zcash-devtool wallet -w ./w init --name nymdemo -i ./w.age -n main -s 127.0.0.1:9067 --birthday 3513850
$ zcash-devtool wallet -w ./w sync -s 127.0.0.1:9067
INFO zcash_devtool::commands::wallet::sync: Sapling tree has 1128 subtrees
INFO zcash_devtool::commands::wallet::sync: Orchard tree has 770 subtrees
INFO zcash_devtool::commands::wallet::sync: Ironwood tree has 11 subtrees
INFO zcash_devtool::commands::wallet::sync: Latest block height is 3513950
INFO zcash_devtool::commands::wallet::sync: Fetching ChainTip(3513850..3513951)
INFO zcash_devtool::commands::wallet::sync: Scan complete for range ChainTip(3513850..3513951); progress is 76/76
$ zcash-devtool wallet -w ./w balance # excerpt
chain_tip_height: BlockHeight(3513950), fully_scanned_height: BlockHeight(3513950)
The wallet went from nothing to fully scanned at the chain tip (101 blocks
plus subtree roots and tree states) in about 11 seconds. Every byte went
through the mixnet. Also verified live: --dns exit, the regional servers
(eu./na.zec.rocks), testnet (testnet.zec.rocks, chain test), the
--tls listener, and ZECMIX_LIVE=1 go test -run Live.
Threat model and honest limits
What it hides: your IP address from the lightwalletd operator, from
anyone watching or compelling that server, and from network observers between
the exit and the server. It also hides the server name from your local DNS
resolver. Your ISP sees that you use Nym, but not which server you talk to or
what you send. The Nym exit sees a TLS connection to zec.rocks, but not its
contents and not your IP. Mixing, cover traffic and reply SURBs make
timing correlation much harder than with a plain proxy or VPN.
What it does not hide:
- What the wallet asks for. The server still sees your birthday height,
sync pattern and transparent-address queries, and all calls in one gateway
session share one connection. It can link them to each other, just not to
you. To avoid linking sessions, restart the gateway (new connection) or run
zecmix nym setup --force(new mixnet identity and exit). - Transaction contents. The mixnet hides who broadcast a transaction, not what is on chain. Transparent inputs and outputs are public whatever route the transaction takes.
- Exit and server collusion or a global adversary. The exit knows some Nym user talks to zec.rocks. A party running both the exit and the server still cannot see your IP, but a global passive adversary is what Nym's timing defences are designed for, and those defences have limits.
- Bootstrap metadata.
zecmix nym setupandipquery the public Nym API directly, just asnym-socks5-clientdoes to fetch the network topology. That reveals Nym use, nothing about Zcash. - The local hop. Wallet to gateway is plaintext on loopback by default.
Anyone who can sniff your loopback can already read your wallet. Use
--tlsif a wallet insists on HTTPS. - Speed. Expect seconds per call. A first call takes about 5 to 10 s (mixnet stream plus TLS handshake), later calls about 1 to 2 s, and compact blocks stream at mixnet bandwidth. Fine for a wallet that is mostly synced. A multi-year rescan over the mixnet would be slow.
- Free bandwidth. The socks5 client currently runs without zk-nym bandwidth credentials, which the network allows today. If that changes, credentials will be needed.
Relation to other work
- nymtech/nym-zcash-grpc-demo
(2024) carried lightwalletd gRPC over the mixnet to a dedicated
NymProxyServerthat someone runs next to a lightwalletd instance. zecmix needs no extra server. It uses Nym's public exit gateways to reach existing public lightwalletd/Zaino servers, with TLS end to end. - Zingo is adding a native "Mixnet Mode" (
nym-proxyin zingolib'szingo-netutils). If you use Zingo, use that. zecmix is wallet-agnostic: it covers zcash-devtool, wallets built on other SDKs, scripts, and any lightwalletd client that can take a custom server URL. It also gives you a scriptable CLI for broadcasts and lookups. - mixpool, my earlier Nym build, does the same for Bitcoin transaction broadcast via Esplora APIs. zecmix reuses its client setup knowledge, but this is a new Go code base for gRPC/HTTP2.
Tests
go test ./... # offline, with fakes
ZECMIX_LIVE=1 go test -run Live -v . # live: real mixnet + mainnet lightwalletd
The offline tests run a fake SOCKS5 proxy that records every CONNECT, a fake DoH resolver and a fake TLS lightwalletd. They check that:
- names are resolved through the mixnet and connected by IPv4;
--dns exitsends the name to the exit (SOCKS ATYP 3);- there is no clearnet fallback when the proxy is down, from the CLI or the
gateway (
UNAVAILABLE, upstream never reached); - exit-policy refusals surface as errors;
- block streaming and send rejections work end to end;
- the gateway preserves gRPC streams and trailer status codes, sends
:authority= upstream, and adds no forwarding headers; - plaintext is only allowed to loopback.
There are also unit tests for txid byte order, tx header checks (including a real v6/NU6.3 header) and the nym config patch.
Future plans
- A native Rust
nym-sdk(or Go FFI) build, so no separate client binary is needed, plus a fresh mixnet identity per broadcast. --splitmode: sendSendTransactionover a separate connection and identity from the sync session, so the server cannot link a broadcast to the wallet that synced.- Optional random delay before broadcast, and pacing/cover traffic for sync.
- Packaged builds (Linux/macOS/Windows), a systemd user unit, and a ZecHub guide that uses it.
- Zebra/zcashd P2P (port 8233) over Nym's IP packet router, for full-node users.
License
MIT. proto/*.proto come from
zcash/lightwalletd (MIT, The Zcash
developers), commit d16d481, with only go_package changed.
By apeir (https://apeir.dk/online/).
Documentation
¶
Overview ¶
Package zecmix routes Zcash light-wallet (lightwalletd gRPC) traffic through the Nym mixnet. See cmd/zecmix for the CLI and gateway.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
zecmix
command
Command zecmix: Zcash light-wallet traffic over the Nym mixnet.
|
Command zecmix: Zcash light-wallet traffic over the Nym mixnet. |
|
Package gateway is a local lightwalletd endpoint that forwards every gRPC call to a public lightwalletd server through the Nym mixnet.
|
Package gateway is a local lightwalletd endpoint that forwards every gRPC call to a public lightwalletd server through the Nym mixnet. |
|
internal
|
|
|
fakes
Package fakes provides offline stand-ins for the mixnet pieces: a SOCKS5 proxy that records every CONNECT, a DoH resolver and a lightwalletd server.
|
Package fakes provides offline stand-ins for the mixnet pieces: a SOCKS5 proxy that records every CONNECT, a DoH resolver and a lightwalletd server. |
|
Package lwd talks to a lightwalletd-compatible server (lightwalletd, Zaino) over gRPC, with every connection carried by the Nym mixnet.
|
Package lwd talks to a lightwalletd-compatible server (lightwalletd, Zaino) over gRPC, with every connection carried by the Nym mixnet. |
|
Package mixnet opens TCP streams through the Nym mixnet.
|
Package mixnet opens TCP streams through the Nym mixnet. |
|
Package nym sets up and runs the official nym-socks5-client release binary.
|
Package nym sets up and runs the official nym-socks5-client release binary. |