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 grid-quantized 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 — but you don’t have to build them yourself: nym-swizzle-zcash implements them (use the library, or read on for how it fits the stack):

1Start-height quantizationV3 · vs L2

Since the linking key is the requested range (V3, content), above all its start height, the fix is to make ranges collide: quantize every requested range to a network-wide grid, so that every client resuming anywhere in the same grid cell emits exactly the same request.

  • Grid snapping: round the range start down, and its end up, to multiples of a standard grid spacing (absolute heights). The spacing scales with the range length on the ladder S_j = 144·2^j, never below one day of blocks (1152), so the collision window grows from one block interval to at least a day.
  • Deterministic emission: the quantized range goes on the wire as ascending, disjoint grid cells, with no random sizes, no overlap, no shuffling. Every wallet in the cell says exactly the same thing; per-wallet variation inside a collision set is a distinguishing dimension, not protection.

The cost is re-downloaded cover blocks only: for catch-ups above a day, typically 1.5x to 2x the blocks actually needed, never more than 3x; below a day the cost is bounded in absolute terms, at most one grid cell (~0.5 MB of compact blocks).

Implemented by nym-swizzle-zcash use the library →

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.
  • Destination splitting: sync from one lightwalletd and broadcast through another.
  • Build the transaction when the timer fires, with expiry derived from a tip fetched at that moment: a transaction's expiry height is visible on-chain to everyone (V3), and an expiry derived from a stale tip reveals the wallet's last sync height.

Implemented by nym-swizzle-zcash use the library →

3ParametersV3 + V2 · vs L2

The parameters are part of the protocol, not per-wallet tuning: the mechanism works by every wallet emitting the identical rule.

  • Defaults: the grid parameters (ladder S_j = 144·2^j, one-day floor of 1152 blocks) nest with ZIP 318's 144-block anchor grid, and the broadcast delays (exponential, mean 144 blocks, capped at 576 blocks) are ZIP 318's transfer-scheduling parameters, so wallet broadcasts pool with migration traffic. A wallet that customises its grid floor, split rule, or delay distribution becomes recognisably different from the rest of the crowd.
  • Anonymity sets: anonymity by collision is only as strong as the crowd that collides. The size of the collision sets grows with the number of wallets emitting the identical rule.

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.

Shaping what goes on the wire: the swizzle crates

The transport crates above carry the bytes; the swizzle layer decides what goes on the wire and when. It discharges the baseline hygiene for you, and it works the same over any of the transports on this page.

nym-swizzle

The chain-agnostic traffic-shape primitives: Delay decorrelates actions from the events that trigger them (uniform, Poisson or normal draws); Range breaks index fetches into randomized overlapping chunks, a primitive for chains without a quantization rule: the Zcash layer below does not use it, emitting deterministic grid cells instead. Crypto-grade, seedable sampling; every non-dev dependency compiles to wasm32-unknown-unknown.

nym-swizzle-zcash

The Zcash policy layer on top: quantized sync ranges on a network-wide grid, emitted as deterministic ascending grid-cell requests, and decoupled, restart-safe broadcast scheduling with ZIP 318’s delay parameters. You implement two small traits with your own lightwalletd client; the crate never opens a connection. Full walkthrough →

Where do I get these?

nym-smolmix

Follow the instructions at nym.com/docs/developers/smolmix — copy the dependency block from there, since crate names and versions move with releases. 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
}

nym-swizzle-zcash

The hygiene policy layer described above — see the full walkthrough, the crate README and the runnable wallet_sync example:

[dependencies]
nym-swizzle-zcash = { git = "https://github.com/nymtech/nym", branch = "develop" }

# optional: derive Serialize/Deserialize on BroadcastPlan
# nym-swizzle-zcash = { git = "...", branch = "develop", features = ["serde"] }

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.