New: a threat-model-first guide to choosing your network defence, plus the nym-smoldvpn dVPN package and nym-swizzle sender hygiene.
Developers
nym-swizzle (traffic hygiene)

Traffic-shape hygiene

Baseline hygiene is the second layer of the two-layer model: transport-independent client discipline that shapes the timing and content of your requests, so the destination (L2) learns less from the pattern of what you send. It closes the V2 timing and V3 content vectors that transport alone cannot.

nym-swizzle is the Rust library that provides those primitives. It changes what your application puts on the wire, and when. That makes it transport-independent: it works the same over the mixnet, a VPN, Tor, or a direct connection.

⚠️

Not yet released. The crate is merged to develop but is not yet published to crates.io. Install it from Git until the next platform release.

What your request pattern leaks

A mixnet hides who is talking. It does not hide what an application's request pattern says about it. Two leaks survive any transport, because both live in the application layer.

Index correlation. A light client that requests exactly blocks 4_120_000..4_120_010 reveals its resume point. The start index also acts as a linking key across sessions, because today's start is yesterday's end. The destination can chain separate sessions into one history from the indexes alone.

Timing correlation. The destination observes wall-clock arrival. A wallet that broadcasts a transaction immediately after it reaches the chain tip is linkable to its own sync activity. The destination attributes the broadcast to the session seconds earlier, and through index chaining, to that client's whole history.

The library provides one primitive for each.

PrimitiveClosesWhat it does
DelayV2, timingRuns an async action after a randomly sampled delay
RangeV3, contentFetches an index range as overlapping, shuffled chunks

Installation

nym-swizzle is not on crates.io yet, so import it from the repository:

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

Minimum Rust version: 1.87+

The crate has no network stack and no build script. Every non-development dependency compiles for wasm32-unknown-unknown, so a wasm-pack wrapper can wrap it unmodified. On wasm targets, timing uses wasmtimer (opens in a new tab) and randomness reaches the browser through getrandom's js feature. Verify the guarantee with:

cargo check -p nym-swizzle --target wasm32-unknown-unknown

Delay: decorrelate an action from its trigger

Delay samples a wait, waits it out, then runs the future. The future is not polled before its scheduled time, so nothing observable happens early.

use std::time::Duration;
use nym_swizzle::Delay;
 
let mut delay = Delay::uniform(Duration::ZERO, Duration::from_secs(10));
let result = delay.run(async move { broadcast_tx(tx).await }).await;

Three distributions are available. Samples outside the configured bounds are rejection-resampled rather than clamped, so no probability mass piles up at the edges.

ConstructorDistributionBounds
Delay::uniform(min, max)Uniform over [min, max]Bounded on construction
Delay::poisson(mean)Exponential inter-arrival times, the family the mixnet uses for cover trafficUnbounded above until you set max
Delay::normal(mean, std_dev)NormalNegative samples always rejected; restrict further with bounds

Adjust bounds with min, max or bounds. Call sample to draw a duration without running anything, for schedules that must outlive the process.

Range: hide the edges of a sequential fetch

Range decomposes an index range into randomly sized, deliberately overlapping, shuffled chunks. Coverage is total: the union of the chunks is exactly the obfuscated range, with no gap.

use nym_swizzle::{Range, Snap};
 
Range::new(resume_height, tip)
    .snap_start(Snap::Spacing(1000))
    .start_jitter(2500)
    .plan()
    .for_each_concurrent(4, |start, end| get_blocks(start, end))
    .await;

Two ways to hide the start

The start index is the linking key, so the library offers two mechanisms to break it. They work differently, and you can combine them.

start_jittersnap_start
PrincipleAnonymity by noiseAnonymity by collision
MechanismMoves the start down by a sampled amountRounds the start down to a checkpoint grid
Randomness usedYesNone; the result is deterministic
EffectYour start is no longer an exact pointerEvery client in the same interval emits an identical start

Snap::Spacing(n) puts checkpoints at every multiple of n. Snap::Checkpoints(vec) takes an explicit list, such as a chain's canonical checkpoints. When both mechanisms are configured, jitter applies first and snapping last, so the emitted start is always on-grid.

Set floor to the lowest index the start may reach. A wallet birthday would leak, so prefer an activation height, or 0.

Align the floor with the grid when you use both. Snapping rounds down, and it never crosses below the floor. A start in the first partial interval above an off-grid floor therefore has no grid point available. The plan emits the true start unchanged. Choose a floor that is a multiple of the spacing, or a member of the checkpoint list.

Executing the plan

plan() materialises the chunks and fixes a random execution order. Drive it in whichever style suits the application.

MethodStyle
Iterator<Item = (u64, u64)>Pull, in random order
for_eachPush, sequential
for_each_concurrent(limit, f)Push, bounded concurrency, fire and forget
stream_concurrent(limit, f)Push, bounded concurrency, returns a Stream of results

stream_concurrent yields exactly one output per chunk, in completion order rather than plan order. Use it when you need to collect, fold or short-circuit on the results, instead of threading them through shared state.

Attach a Delay with ChunkPlan::delay to space the chunks out in time as well as reorder them. Under concurrency each chunk's delay is pre-sampled and elapses in its own task, so the schedule stays randomised.

Reproducible plans

Sampling uses OS entropy by default. Call seed with 32 bytes for a deterministic ChaCha20 generator: the same seed and configuration produce a byte-identical plan, including the execution order. Seeds derived externally, for example from a VRF output, are treated as opaque seed material. with_rng accepts your own crypto-grade generator.

What stays your responsibility

The library shapes a request pattern. It does not manage your transport, and three things remain yours.

Destination splitting. Sync from one server and broadcast through another. Never broadcast over the sync session. This is application-level routing, and the library cannot do it for you.

Range widening. The library never extends the end of a range, because it cannot know which indexes exist. It has no view of the chain tip or the array bounds. If you want to hide which sub-range you care about, widen the range before you pass it in. The only outward extension the library performs is the downward start overlap, because earlier indexes always exist.

Deduplication. Overlapping chunks re-fetch data deliberately. Index-addressed data is idempotent, so re-fetching is safe, and deduplicating the results is your application's job.

Tuning

⚠️

There are no settled numbers. Wider overlaps and wider checkpoint spacing buy a larger anonymity set, and cost re-downloaded data. The defaults are conservative percentage-of-range derivations, exposed as knobs. Treat them as starting points; nobody has validated them as anonymity parameters.

Defaults, for reference:

KnobDefault
chunk_size[total/50, total/10], at least 1
overlap[1, clamp(total/20, 1, max_chunk/2)]
start_jitter_default10% of the total range, at least 1
floor0

Chain-specific policy is yours to supply

This crate is a general primitive. It takes no view on what a good grid spacing or a good delay distribution is for your chain. The right answers depend on block times, on what a typical wallet's traffic looks like, and on what other traffic yours can hide among.

For Zcash that work is done: nym-swizzle-zcash packages the primitives into chain-specific policy, with quantised sync ranges and ZIP 318-aligned broadcast scheduling, and ships a live example against a public lightwalletd.

For any other chain, use that page as a worked example of the reasoning. Do not port the Zcash numbers directly: the grid and the delay distribution are design decisions you make for your chain.

Examples

Runnable examples in sdk/rust/nym-swizzle/examples/ (opens in a new tab). Each is self-contained; read the //! doc comments at the top of each file for the reasoning behind it.

cargo run -p nym-swizzle --example <name>
ExampleSourceWhat it demonstrates
Delay a broadcastdelay_broadcast.rs (opens in a new tab)Scheduling a broadcast after a sampled delay. Broadcast timing is a leak the destination reads directly: a wallet that sends the moment it reaches chain tip is correlatable with its own sync activity
Overlapping range fetchfetch_blocks_overlapping.rs (opens in a new tab)Resuming a sync through overlapping, shuffled chunks, so the start height stops naming the resume point and stops chaining one session to the last
Poisson delayspoisson_sampling.rs (opens in a new tab)Exponential inter-arrival times, the same construction the mixnet uses for its own cover traffic
Seeded determinismseeded_vrf.rs (opens in a new tab)A fixed 32-byte seed reproducing a byte-identical plan and delay sequence, for VRF-derived or otherwise reproducible schedules

Profiling harness

A development-time harness checks the statistical claims: three suites pairing SVG plots with numeric assertions, covering the delay distributions, the chunk geometry, and seeded determinism. The plots are there to inspect; the assertions gate the run, so a regression fails the harness instead of only changing a plot.

cargo run --release -p nym-swizzle --example profiling
# plots land in <workspace>/target/swizzle-profiling/

API reference

Source and rustdoc comments: sdk/rust/nym-swizzle (opens in a new tab) and sdk/rust/nym-swizzle-zcash (opens in a new tab).

See also

  • nym-swizzle-zcash: the same primitives as finished policy for Zcash light clients.

  • The two-layer model: why transport and hygiene are separate problems.

  • Linkage vectors: what V2 and V3 consist of, and which actors observe them.

  • Choose a defence: pick a transport configuration for your threat. Hygiene is owed on top of whichever row you pick.