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.
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.
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
| Constant | Value | Why |
|---|---|---|
| Grid ladder | S_j = 144·2^j | Spacing scales with the range length; every rung is a multiple of 144, nesting with ZIP 318's anchor grid.ZIP 318 |
| Grid floor | 1152 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 convention | half-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 lookahead | 10 blocks | The 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 delay | exp. mean 144 blocks (~3 h), capped 576 (~12 h) | ZIP 318's transfer-scheduling distribution: wallet sends pool with migration traffic.ZIP 318 |
| Fast profile | exp. 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 delta | tip + 40 | Built 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 days | Anchors stay valid ~2 days, so a broadcast usually needs no preceding sync; needs_refresh_sync says when it does.librustzcash (AnchorRetentionInterval::ZIP_318) |
| Block time | 75 s | Delays 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
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-zcashOr 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.