Implementation Guidance for Wallet Developers
So you’re building a Zcash wallet and you want your users’ migration traffic to stay private. Good — this is the practical how-to. The network-privacy analysis works out why; here we get to how. The short version: run the recommended hybrid — sync over the dVPN, broadcast over the mixnet — and don’t skip the baseline hygiene, because the network can’t do that part for you. Skim the threat model once so the L1/L2/L3 names below land, then come back.
What technology stack do you use?
A short decision — and why each answer falls out the way it does.
Then you are mixnet-only. mixFetch (WASM + TypeScript) is the one way to carry traffic over the Nym network from the browser, and it suits the broadcast side — sending transactions over the mixnet. Compact-block sync over the mixnet is impractically slow and not recommended: bulk transfers do not belong on the mixnet. The dVPN is unavailable in the browser at all — the sandbox cannot open a raw UDP WireGuard socket, and the QUIC-bridge fallback is rejected because the sandbox enforces Web PKI while the bridges use self-signed certificates. You still owe the full baseline hygiene below.
Then you get the full hybrid. Use nym-smolmix to send transactions over the mixnet, and the 2-hop dVPN mode (1-hop is also configurable) to sync compact blocks quickly. On mobile, reuse the Rust crates through uniffi for Swift (iOS) and Kotlin (Android) bindings rather than reimplementing the stack. Then add the baseline hygiene — client-side sending delays and overlapping block requests — because the transport does not provide it.
What do these do?
mixFetch, nym-smolmix and nym-smol-dvpn all run user-space IP stacks. Your application uses standard primitives — tokio async read/write traits, or sockets bound by crates like hyper (HTTP) or tonic (gRPC) — and any traffic handed to the stack is carried over the Nym network in one of two modes:
Note the category error to avoid: in-transit mixing does not protect you against the server you are talking to (L2). No amount of mixing hides what the destination sees; only the absence of session state, plus the client-side discipline below, does. See the threat model and the analysis.
Baseline hygiene (what your app must add)
Whatever the transport, the destination (L2) still sees each request’s content and its arrival time. These duties are always the wallet’s — the same measures shown on the baseline hygiene page:
Since the linking key is the start height (V3, content), it is enough to obfuscate the start. Both approaches cost only re-downloaded blocks and work in every configuration.
Broadcast timing is a V2 (timing) leak at the destination. In-transit mixing does not remove it — the destination still observes wall-clock arrival time — so the wallet must decorrelate broadcasts itself.
How aggressively to obfuscate the start is a design choice with a clear qualitative shape but no settled numbers. A larger overlap, or a wider checkpoint spacing, widens the collision window — and therefore the anonymity set of clients emitting an indistinguishable start — at the cost of more re-downloaded blocks. Both approaches cost only re-downloaded blocks and apply in every configuration.
And rotate: a fixed exit or IPR is a linking key that fails P2 at the destination within a session, so rotate the exit or IPR per request — or, with an SP-integrated lightwalletd, use a fresh sender tag and its own SURBs per request. Full detail is on the baseline hygiene page.
Where do I get these?
nym-smolmix
Follow the instructions at nym.com/docs/developers/smolmix:
[dependencies]
smolmix = "1.21.4"
nym-bin-common = { version = "1.21.4", features = ["basic_tracing"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }See the tcp.rs example for making HTTP requests.
nym-smol-dvpn
Follow the instructions in the smol-dvpn repo. There is a zcash compact-block sync example:
async fn run() -> Result<(), BoxError> {
common::init_logging();
common::init_crypto();
// 1. Provision: deposit NYM + issue zk-nym ticketbooks (reused across
// runs from the credential store), then register a random two-hop
// tunnel (entry + exit gateways).
let cli = common::parse_cli()?;
let session = common::new_session("zcash-sync").await;
let result = async {
let reg = common::register(&session, &cli).await?;
common::print_gateway("entry", ®.entry.gateway);
if let Some(exit) = reg.exit.as_ref() {
common::print_gateway("exit", &exit.gateway);
}
// 2. Bring up the userspace WireGuard tunnel.
let tunnel: Tunnel = common::build_tunnel(®, /* quic = */ false).await?;
// 3. Build a TLS gRPC channel to lightwalletd whose transport dials
// through the tunnel, and stream compact blocks over it.
let channel = Endpoint::from_static("https://zec.rocks:443")
.connect_with_connector(TlsWrap::h2(tunnel.connector()))
.await?;
info!("syncing last {BLOCKS} blocks from {LWD} through the tunnel …");
let synced = sync_last_blocks(channel, BLOCKS).await?;
info!("PASS: synced {synced} blocks through the tunnel");
// 4. Tear down (bounded — live teardown can be slow).
let _ = tokio::time::timeout(Duration::from_secs(5), tunnel.shutdown()).await;
Ok::<(), BoxError>(())
}
.await;
// Close the session's credential store cleanly (stored tickets are retained).
session.shutdown().await;
result
}Before you ship
Access to the Nym network in either mode requires NYM. Acquire it ahead of the migration — for example by swapping ZEC for NYM with Nym’s swap API.
Anonymity depends on crowding: your protection grows as more wallets share the same exits, IPRs, or SP-integrated service. Coordinate on shared exit-gateway sets rather than each wallet choosing its own.