Implementing Baseline Hygiene: nym-swizzle-zcash

The baseline hygiene page says what every wallet owes; this page is how you pay it. nym-swizzle-zcash packages the duties as a small library — quantized sync ranges and decoupled, restart-safe broadcast scheduling — that you plug your own lightwalletd client into. Two dependencies, no network stack, compiles to wasm. You keep your transport (see the implementation guidance); the crate decides what goes on the wire and when.

The problem: your lightwalletd is watching

Suppose the transport is perfect — IP rotated on every request, nothing linkable at the network layer. The lightwalletd server still learns plenty from what a wallet asks and when. Both leaks below survive any mixnet, dVPN, VPN or Tor circuit, because they live in the application's query pattern, not in the connection that carries it.

Resume-point chaining

V3 · vs L2

A wallet resumes at exactly previous-end + 1 and syncs to the tip. Each request start names one specific block, and today's start is yesterday's end — the server chains a wallet's sessions into one history by height alone, no IP needed.

Sync-then-send

V2 · vs L2

Wallets sync right before broadcasting, so the server attributes each SendTransaction to the sync session seconds earlier — and, via chaining, to the wallet's whole history.

Transport anonymity is still worth having — it’s the other half of the story, and the half the Nym network exists for. This library works the same over any transport.

Mechanism 1 — quantized sync ranges

Instead of requesting exactly [resume, tip], the wallet requests a range widened to a network-wide grid: start rounded down, end rounded up, capped at the public chain tip. Grid spacing comes from the ladder S_j = 144·2^j — scaled to the range, never below 1152 blocks (~one day) — so every wallet resuming anywhere in the same grid cell emits identical boundaries: anonymity by collision, not by noise. The range then goes on the wire deterministically: ascending, disjoint, day-sized grid cells — no random sizes, no overlap, no shuffling. That determinism is deliberate: every wallet in the cell says exactly the same thing, and per-wallet variation inside a collision set would only hand the server a distinguishing dimension while costing bandwidth.

grid = multiples of Snaive: GetBlockRange(resume → tip)start = your last sessionemitted: grid-aligned start → tip, as ascending grid-cell requestsblock height →resumetip (public)S·⌊a/S⌋cover — already scanned, discardverify window — hash-check before commitnew — scanevery wallet resuming anywhere in the same cell emits identical boundaries — anonymity by collision

The reorg check rides inside the widened range: the ~10 blocks below your resume point that wallets re-fetch and hash-compare would, as a separate tiny request, have named the resume point exactly — so if the resume point sits within 10 blocks of a grid boundary, the start widens by a full cell instead. Delivered blocks arrive tagged with a disposition: cover (already scanned — discard, one branch in your scan loop), verify-window (hash-check before committing), or new (scan). Run the hash comparison as the verify blocks arrive, and commit scan results only after it passes.

Mechanism 2 — decoupled broadcast scheduling

Sends are delayed by an exponential draw with a mean of 144 blocks (~3 hours), rejection-resampled above 576 (~12 hours) — the same distribution ZIP 318 uses for transfer scheduling, so wallet sends pool with migration traffic. A delay runs longer than a phone keeps a process alive, so the schedule is plain data you persist: sample once, save, restart as many times as the OS likes, resume the remainder.

The transaction is built only when the delay fires, so its expiry (tip + 40, visible on-chain per ZIP 203) derives from a fresh tip instead of leaking your last sync height. And you usually don’t need to sync first: anchors stay valid for about two days — needs_refresh_sync tells you when you actually do. There’s a fast() profile (~30 min mean) when users need it — with a smaller crowd to hide in (~20 comparable transactions instead of ~120), and the docs say so.

The constants, and where they come from

ConstantValueWhy
Grid ladderS_j = 144·2^jSpacing scales with the range length; every rung is a multiple of 144, nesting with ZIP 318's anchor grid.ZIP 318
Grid floor1152 blocks (~1 day; 144·2³)Minimum spacing: short gaps still fetch a full cell (~0.5 MB). Sits on the ladder family by compile-time assertion.ZIP 318 (144-block grid × 2³)
Range conventionhalf-open [a′, b′)One network-wide convention, selected by half-open length; pinned by boundary tests at gaps of 1152 and 2304.crate boundary tests
Verify lookahead10 blocksThe reorg check rides inside the emitted range; a resume point within 10 blocks of a boundary widens the start by one cell.librustzcash verify window
Broadcast delayexp. mean 144 blocks (~3 h), capped 576 (~12 h)ZIP 318's transfer-scheduling distribution: wallet sends pool with migration traffic.ZIP 318
Fast profileexp. mean 24 blocks (~30 min)Opt-in, for sends that can't wait; a smaller crowd (~20 comparable transactions instead of ~120).ZIP 318 (scaled)
Expiry deltatip + 40Built at fire time from a fresh tip; a stale-tip expiry reveals the last sync height on-chain.ZIP 203 (DEFAULT_TX_EXPIRY_DELTA)
Anchor freshness~2 daysAnchors stay valid ~2 days, so a broadcast usually needs no preceding sync; needs_refresh_sync says when it does.librustzcash (AnchorRetentionInterval::ZIP_318)
Block time75 sDelays are denominated in blocks and converted with this named constant.Zcash consensus

Using it: two trait slots

You implement two traits — deliberately two, because a session should sync or broadcast, never both, and ideally against different servers. BlockSource fetches compact blocks; TxBroadcaster sends. The crate never opens a connection: your gRPC / proxied / Nym-tunnelled client goes in the slot — the implementation guidance covers filling it with nym-smolmix or nym-smol-dvpn.

[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"] }

The sync path

use nym_swizzle_zcash::{sync, BlockSource, QueuedRange, SyncOutcome};

struct MyClient { /* your gRPC / proxied / tunnelled lightwalletd client */ }

impl BlockSource for MyClient {
    type Block = MyCompactBlock;
    type Error = MyError;
    async fn block_range(&mut self, start: u64, end: u64)
        -> Result<Vec<(u64, MyCompactBlock)>, MyError>
    {
        // one wire request for [start, end); the crate chooses the ranges
    }
}

// the routine catch-up, from your resume point to the tip
let outcome = sync::fetch(
    &mut my_client,
    QueuedRange::catch_up(resume_point, tip),
    tip,
    |height, block, disposition| {
        // disposition says what to do: discard cover, scan the rest;
        // buffer results — commit only on SyncOutcome::Committed
    },
    |height, block| my_db.stored_hash(height) == block.hash(), // reorg check
)
.await?;

if outcome == SyncOutcome::ReorgDetected {
    // rewind and requeue, exactly as your SDK does today
}

The send path, in two restart-safe phases

use nym_swizzle_zcash::{Scheduler, BroadcastPlan, TxBroadcaster, expiry_height};

// phase 1: at "user pressed send" — sample ONCE, persist, forget
let plan = Scheduler::standard().schedule();
my_db.save(("plan", plan.delay_secs, plan.profile), ("scheduled_at", now));

// phase 2: at every wallet startup (and right after phase 1)
let plan = my_db.load_plan();
plan.resume(now - scheduled_at, &mut my_broadcaster, || async {
    let tip = fetch_fresh_tip().await?;          // AFTER the delay, on purpose
    build_tx(spend_request, expiry_height(tip))  // expiry = fresh tip + 40
}).await?;

Enable the serde cargo feature if you’d rather derive Serialize/Deserialize on BroadcastPlan than store two integers by hand. The types are bare heights and two small enums — no librustzcash dependency, so the crate drops into any wallet stack.

What it costs — honestly

Sync

A wallet catching up from more than a day behind downloads typically 1.5x to 2x the compact blocks it strictly needs, never more than 3x; below a day the cost is absolute: at most one grid cell plus the gap, roughly half a megabyte. Grid cover is the only overhead: requests are disjoint and exact, and cover blocks arrive tagged, so discarding them is one branch in your scan loop.

Send

The default profile delays broadcasts by ~3 hours on average, up to 12. That’s the price of pooling with roughly a hundred comparable transactions instead of standing alone. The fast profile (~30 min) buys speed with a smaller crowd.

The grid constants and broadcast parameters follow ZIP 318’s published design. The strength of anonymity by collision depends on how many wallets emit the identical rule. A wallet that customises its grid floor, split rule, or delay distribution becomes recognisably different from the rest of the crowd.

Don’t take our word for it

Everything above is checkable on your own machine, against a server Nym doesn’t run — the example prints every grid-aligned boundary as it goes on the wire:

# watch the wire, in two parts:
#  - compact block sync: real fetch from a public lightwalletd,
#    grid-aligned boundaries printed per request (~half a minute,
#    dominated by the download)
#  - transaction broadcast: five wallet "restarts", 2 s apart — the plan
#    restored from disk each wake-up, the tx built only at fire time
cargo run --release -p nym-swizzle-zcash --example wallet_sync

# pick any server you like — none of this needs Nym infrastructure
ZEC_SERVER=https://zec.rocks:443 ZEC_GAP=400 ZEC_ROUNDS=5 ZEC_ROUND_SECS=2 \
    cargo run --release -p nym-swizzle-zcash --example wallet_sync

# the live suite: reorg detection on real chain data, grid alignment of
# every emitted boundary, measured overhead for both sync regimes
cargo test -p nym-swizzle-zcash -- --ignored

# the pure logic, no network: quantization arithmetic, coverage and
# reproducibility invariants, delay distribution bounds
cargo test -p nym-swizzle-zcash

Or skip the tooling: point your wallet’s existing client at sync::fetch and log what block_range gets asked for — ascending 1152-block grid cells, identical on every run from the same state, the union starting on a multiple of the printed grid spacing. The constants cite their public sources (ZIP 318, ZIP 203) in the rustdoc, so you can check the arithmetic against the specs. Full docs: the crate README, built on nym-swizzle.