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.

1A web wallet?Mixnet-onlybrowser sandbox

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.

2A native wallet?Full hybridRust · Swift · Kotlin

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:

Mixnet mode

Fixed-size Sphinx packets across three mix layers with per-hop delays, Poisson sending and cover traffic. This protects the timing of your traffic in transit against a local (L3L) or global (L3G) network observer (V2). Use it for the small, timing-sensitive broadcast.

2-hop dVPN mode

A WireGuard tunnel through an entry and an exit gateway (1-hop is also configurable). Fast and line-rate; it hides the client IP from the destination (L2, P1 via V1) but adds no in-transit timing protection. Use it for bulk block sync.

Architecture illustration — how the components pack into a native app and bundle in a web app (coming).

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:

1Start-height obfuscationV3 · vs L2

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.

  • Randomized overlap: start a random number of blocks before the true resume point, re-fetching blocks already held, so starts stop being exact pointers to previous ends and chaining degrades to approximate, deniable joins.
  • Checkpoint snapping: round the start down to a standard checkpoint (an absolute height), so every client whose previous sync ended in the same checkpoint interval emits an identical start — the collision window grows from one block interval to the checkpoint spacing.
2Broadcast hygiene and schedulingV2 · vs L2

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.

  • Never send a broadcast over the sync transport or session.
  • Decorrelate broadcast times from sync milestones — in particular, do not broadcast immediately upon reaching the tip.
  • Use a randomized timer for broadcasts (the note's Defense B for migration rounds), extended to every broadcast.
  • Destination splitting: sync from one lightwalletd and broadcast through another.
3Tuning the overlap distribution and checkpoint spacingV3 · vs L2

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.

  • Trade-off: wider overlap / spacing ⇒ larger anonymity set, but more wasteful re-download.
  • Open question: how to set the overlap distribution or the checkpoint spacing, and what each buys quantitatively in terms of anonymity-set size, is left open by the source note — presented here without fixed values.

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

Note — this is a brand new addition to the Nym SDK, so please give feedback and help improve it if you find any problems.

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", &reg.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(&reg, /* 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

1Acquire NYM for bandwidth credentials

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.

2Plan for crowding

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.