SDK: nym-swizzle - #6984
Conversation
Proposes `nym-swizzle`, an SDK utility crate for application-layer traffic-shape obfuscation aimed at wallets and other clients that fetch sequential, index-addressed data or broadcast at meaningful moments. A mixnet hides who is talking but not what a query pattern says about the client: broadcast timing correlates with sync milestones, and a light client's start height acts as a linking key across sessions (today's start is yesterday's end). The proposal captures two primitives (randomized delay scheduling, overlapping randomized range chunking with start-edge obfuscation), a shared seedable randomness configuration, and a development-time profiling harness that proves the statistical claims. design.md records the load-bearing decisions: rejection-resampling rather than truncation-clamping (a clamp leaves a probability spike at the bound), Poisson-as-exponential-inter-arrival to match mixnet cover traffic, deterministic RNG-free checkpoint snapping so independent clients actually collide, jitter expressed in whole checkpoint intervals when snapping is enabled, and VRF support via seed injection rather than built-in VRF machinery.
Two composable primitives for clients whose traffic shape leaks what the transport layer hides: - `delay` schedules an async action after a sampled delay (uniform, Poisson-process, or normal). The wrapped future is not polled before its scheduled time, so the observable event is what moves — a "delay the result, start the work now" semantic would obfuscate nothing. - `range` decomposes an index range into randomly sized, deliberately overlapping chunks covering it exactly, executable as a shuffled iterator or via async push drivers. `start_jitter` and `snap_start` obfuscate the start edge, which is what links a light client's sync sessions together. Bounded sampling rejects and redraws rather than clamping: clamping piles probability mass at the bound, which is itself a fingerprint. Snapping is deterministic and consumes no randomness, because its whole value is that independent clients collide on an identical start; when jitter and snapping are combined the jitter moves the start in whole checkpoints so it stays on-grid. The crate has no nym dependencies and every dependency compiles to wasm32-unknown-unknown (tokio time is gated to non-wasm, wasmtimer to wasm32), so a wasm-pack wrapper can distribute it unmodified.
One per capability, each runnable: - delay_broadcast: defers a broadcast by a sampled duration, with the app-level caveats noted in comments (never broadcast over the sync session; consider destination splitting). - fetch_blocks_overlapping: resumes a sync at a realistic height and obfuscates the start with checkpoint snapping plus jitter, then fetches the overlapping chunks concurrently and reports the deliberate waste. - poisson_sampling: shows why the empirical mean sits below the configured mean once a maximum bound truncates the exponential tail. - seeded_vrf: seeds from fixed bytes standing in for a VRF output and shows two runs producing identical plans, plus a third seed diverging. The crate stays VRF-agnostic: a VRF output is just opaque seed material.
The crate makes distributional claims — delays follow their configured distribution, chunk geometry follows its bounds, seeded runs reproduce exactly — and claims like that should be demonstrated rather than asserted in a doc comment. The harness streams 10M samples per delay distribution (plus 50k chunk plans and 500k jitter observations) through fixed-size accumulators, so memory stays flat, and renders each against its theoretical density as SVG. The plots are evidence, not the gate: every one is paired with a moment check that fails the run if the sample mean or variance drifts beyond tolerance, and the exponential suite explicitly asserts the absence of a spike at the max bound, which is what a clamping implementation would produce. Dev-dependencies only, so nothing here reaches downstream consumers.
The crate promises to be wrappable by wasm-pack unmodified, which is a promise about its dependency tree and so is easy to break accidentally with an innocuous-looking dependency. Add it to WASM_CRATES so sdk-wasm-lint clippies it for wasm32 with -Dwarnings, and trigger the workflow on changes under sdk/rust/nym-swizzle so a regression fails here rather than surfacing later when someone builds the wrapper.
Measures the real cost of the obfuscation against a public lightwalletd (zec.rocks) over gRPC, since the synthetic examples can show the shape of the traffic but not what it costs. Three runs over one block range: a direct fetch, overlapping chunks sequentially, and the same chunks concurrently. Each verifies it received every block in the range, and reports the redundancy as wastage. Typical result: ~25% extra blocks, with concurrency recovering nearly all of the wall-clock. Two details that make the comparison mean something. Both swizzled runs share a seed, so they execute a byte-identical plan and concurrency is the only variable — without this they draw different plans and the numbers are not comparable. And because network timings are noisy enough that a single sample swung the sequential ratio between 1.9x and 12.8x across runs, each strategy is measured as a median of several trials after a discarded warm-up pass equalises server-side caching. This is a separate crate rather than an example under nym-swizzle because a gRPC/TLS stack does not compile to wasm; keeping it out preserves that crate's dependency guarantee and keeps its test suite fast. The lightwalletd messages are hand-written prost structs rather than generated code, so there is no build script and no protoc requirement; prost skips unknown fields, so declaring a subset stays forward-compatible. Note lightwalletd's BlockRange is inclusive at both ends, converted from the half-open chunks at the wire boundary.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
📝 WalkthroughWalkthroughAdds the Changesnym-swizzle SDK
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Benchmark
participant Range
participant Lightwalletd
participant ChunkWorkers
Benchmark->>Lightwalletd: request chain tip
Benchmark->>Range: create deterministic overlapping plan
Range-->>Benchmark: shuffled chunk ranges
Benchmark->>ChunkWorkers: execute chunks with configured concurrency
ChunkWorkers->>Lightwalletd: request compact block ranges
Lightwalletd-->>ChunkWorkers: stream compact blocks
ChunkWorkers-->>Benchmark: aggregate coverage and timing
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Documents what the example measures and, more importantly, why its numbers can be trusted: both swizzled runs share a seed so concurrency is the only variable between them, and timings are medians after a warm-up pass because single samples swung the sequential ratio between 1.9x and 12.8x. Also records the two things that surprise readers of the source — the messages are hand-written prost structs so there is no protoc requirement, and lightwalletd's BlockRange is inclusive at both ends while the chunks are half-open — plus the leaks this example deliberately does not address.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@sdk/rust/nym-swizzle/src/delay.rs`:
- Around line 152-159: Update Delay::sample and the rejection-resampling path
used by Poisson/normal sampling so an infinite max cannot cause indefinite
retries when a draw is below min: after MAX_REJECTION_RETRIES, return the
unbounded fallback sample or reject the unbounded configuration before sampling.
Add a regression test covering Delay::poisson(...).min(...) and verify sampling
terminates.
- Around line 131-133: Update the bounds method to validate the supplied min and
max together before mutating the delay state, then assign both values
atomically. Do not chain self.max and self.min, since each validates against the
existing counterpart; preserve the method’s panic behavior for invalid bounds
and return the updated Self.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: a0ae3955-8583-4993-b363-1409af1af486
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (23)
.github/workflows/ci-sdk-wasm.ymlCargo.tomlMakefileopenspec/changes/add-nym-swizzle/design.mdopenspec/changes/add-nym-swizzle/proposal.mdopenspec/changes/add-nym-swizzle/specs/nym-swizzle/spec.mdopenspec/changes/add-nym-swizzle/tasks.mdsdk/rust/nym-swizzle-zcash/Cargo.tomlsdk/rust/nym-swizzle-zcash/src/lightwalletd.rssdk/rust/nym-swizzle-zcash/src/main.rssdk/rust/nym-swizzle/Cargo.tomlsdk/rust/nym-swizzle/README.mdsdk/rust/nym-swizzle/examples/delay_broadcast.rssdk/rust/nym-swizzle/examples/fetch_blocks_overlapping.rssdk/rust/nym-swizzle/examples/poisson_sampling.rssdk/rust/nym-swizzle/examples/profiling.rssdk/rust/nym-swizzle/examples/seeded_vrf.rssdk/rust/nym-swizzle/src/delay.rssdk/rust/nym-swizzle/src/lib.rssdk/rust/nym-swizzle/src/range.rssdk/rust/nym-swizzle/src/rng.rssdk/rust/nym-swizzle/src/timer.rssdk/rust/nym-swizzle/tests/invariants.rs
| pub fn bounds(self, min: Duration, max: Duration) -> Self { | ||
| self.max(max).min(min) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make bounds validate and assign both values atomically.
self.max(max).min(min) validates the new maximum against the old minimum first. For example, changing an existing min = 100s to valid bounds 0s..20s panics even though the supplied pair is valid.
Proposed fix
- pub fn bounds(self, min: Duration, max: Duration) -> Self {
- self.max(max).min(min)
+ pub fn bounds(mut self, min: Duration, max: Duration) -> Self {
+ assert!(
+ min <= max,
+ "delay bounds inverted: min {min:?} > max {max:?}"
+ );
+ self.min = min;
+ self.max = Some(max);
+ self
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| pub fn bounds(self, min: Duration, max: Duration) -> Self { | |
| self.max(max).min(min) | |
| } | |
| pub fn bounds(mut self, min: Duration, max: Duration) -> Self { | |
| assert!( | |
| min <= max, | |
| "delay bounds inverted: min {min:?} > max {max:?}" | |
| ); | |
| self.min = min; | |
| self.max = Some(max); | |
| self | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/rust/nym-swizzle/src/delay.rs` around lines 131 - 133, Update the bounds
method to validate the supplied min and max together before mutating the delay
state, then assign both values atomically. Do not chain self.max and self.min,
since each validates against the existing counterpart; preserve the method’s
panic behavior for invalid bounds and return the updated Self.
| pub fn sample(&mut self) -> Duration { | ||
| let min = self.min.as_nanos() as f64; | ||
| let max = self | ||
| .max | ||
| .map(|m| m.as_nanos() as f64) | ||
| .unwrap_or(f64::INFINITY); | ||
| let nanos = sample_bounded(&mut self.rng, &self.sampling, min, max); | ||
| Duration::from_nanos(nanos.round() as u64) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate file and outline =="
if [ -f sdk/rust/nym-swizzle/src/delay.rs ]; then
wc -l sdk/rust/nym-swizzle/src/delay.rs
ast-grep outline sdk/rust/nym-swizzle/src/delay.rs || true
else
echo "file not found"
fi
echo "== relevant delay.rs section =="
sed -n '1,240p' sdk/rust/nym-swizzle/src/delay.rs
echo "== search sample_bounded and related bounds =="
rg -n "sample_bounded|pub struct|struct Sampling|fn new|unbounded|bounded|Poisson|Uniform|RejectionSampling|Rejection|infinite|INFINITY" sdk/rust/nym-swizzle/src/delay.rs sdk/rust/nym-swizzle/src || trueRepository: nymtech/nym
Length of output: 12783
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('sdk/rust/nym-swizzle/src/delay.rs')
print(p.exists(), p.is_file())
if p.exists():
lines = p.read_text().splitlines()
for i,l in enumerate(lines,1):
if 'sample(' in l or 'sample_bounded' in l or 'f64::INFINITY' in l:
print(f"{i}: {l}")
PYRepository: nymtech/nym
Length of output: 761
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== rng.rs outline =="
ast-grep outline sdk/rust/nym-swizzle/src/rng.rs || true
echo "== rng.rs relevant implementation =="
sed -n '1,330p' sdk/rust/nym-swizzle/src/rng.rs
echo "== deterministic Rust semantics probe by inspecting loop/branch condition math =="
python3 - <<'PY'
from pathlib import Path
text = Path('sdk/rust/nym-swizzle/src/rng.rs').read_text()
print("contains rejection loop:", "while true" in text or "loop" in text)
for needle in ["sample_bounded", "sample_poisson", "fn sample_normal", "infinite", "INFINITY", "try"]:
print(f"{needle}: {text.find(needle)}")
PYRepository: nymtech/nym
Length of output: 10904
Handle the infinite upper bound in rejection-resampling.
reject_into_bounds rejects until sample <= max; with max == f64::INFINITY, an undershoot below min is redrawn indefinitely instead of falling back after MAX_REJECTION_RETRIES. Return an unbounded fallback sample, or reject configurations that leave Poisson/normal rejection-resampling unbounded before sampling, and add a regression test for Delay::poisson(...).min(...).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/rust/nym-swizzle/src/delay.rs` around lines 152 - 159, Update
Delay::sample and the rejection-resampling path used by Poisson/normal sampling
so an infinite max cannot cause indefinite retries when a draw is below min:
after MAX_REJECTION_RETRIES, return the unbounded fallback sample or reject the
unbounded configuration before sampling. Add a regression test covering
Delay::poisson(...).min(...) and verify sampling terminates.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@sdk/rust/nym-swizzle-zcash/README.md`:
- Line 56: Update the fenced code block in the README sample output section to
specify the text language by changing its opening fence to use text, satisfying
markdownlint MD040.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b00828fa-5ca4-4d74-94bc-72969f2538de
📒 Files selected for processing (2)
sdk/rust/nym-swizzle-zcash/Cargo.tomlsdk/rust/nym-swizzle-zcash/README.md
🚧 Files skipped from review as they are similar to previous changes (1)
- sdk/rust/nym-swizzle-zcash/Cargo.toml
|
|
||
| ### Sample output | ||
|
|
||
| ``` |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Specify the sample-output fence language.
Add text to this fenced block so it satisfies markdownlint MD040.
Proposed fix
-```
+```text📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 56-56: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/rust/nym-swizzle-zcash/README.md` at line 56, Update the fenced code
block in the README sample output section to specify the text language by
changing its opening fence to use text, satisfying markdownlint MD040.
Source: Linters/SAST tools
Adds
nym-swizzle, an SDK utility crate for application-layer traffic-shape obfuscation, aimed at wallets and any client that fetches sequential, index-addressed data or broadcasts at meaningful moments.Why
A mixnet hides who is talking. It does not hide what an application's query pattern says about it, and two leaks survive perfect transport anonymity:
4_120_000..4_120_010states its resume point, and because today's start is yesterday's end, the start height acts as a linking key that chains otherwise-unlinkable sessions together.Every nym-powered wallet currently has to reinvent these mitigations. They are pure application-layer transforms with no dependency on nym internals, so they belong in one small reusable crate.
What's in it
Two composable primitives plus a shared, seedable randomness configuration:
delay— schedules an async action after a sampled delay (uniform, Poisson-process, or normal).range— decomposes an index range into randomly sized, deliberately overlapping, randomly ordered chunks that cover it exactly, with start-edge obfuscation.Design decisions worth reviewing
These are the choices a reviewer should push back on if they disagree — each is recorded with its rationale in
openspec/changes/add-nym-swizzle/design.md.maxpiles probability mass into a spike at exactlymax— itself a fingerprint. Out-of-bounds samples are redrawn instead, with a bounded-retry fallback so a pathological config still terminates.sample_poisson_durationincommon/nymsphinx. Deliberate: delays drawn from the same family as mixnet cover traffic blend into traffic the adversary already observes.Wasm is a hard constraint, not a goal
The crate is designed to be wrapped, unmodified, by a
wasm-packwrapper with JS conveniences — wallets are the target and wallets are often wasm. Every non-dev dependency compiles towasm32-unknown-unknown(tokiotime gated to non-wasm,wasmtimerto wasm32, followingcommon/http-api-client). This is enforced in CI rather than trusted:nym-swizzleis added toWASM_CRATESsosdk-wasm-lintclippies it for wasm32 with-Dwarnings.It also has zero nym dependencies, so it stays independently publishable and honest about what it is.
Evidence
Profiling harness (
--example profiling, dev-deps only) streams 10M samples per distribution through fixed-size accumulators and renders each against its theoretical density as SVG. The plots are evidence, not the gate — every one is paired with a moment check that fails the run on drift:It also asserts the absence of a spike at the max bound — the artefact a clamping implementation would produce.
Live Zcash example (
nym-swizzle-zcash-example) fetches real compact blocks from the publiczec.rockslightwalletd over gRPC and measures what the obfuscation actually costs, median of 3 trials over 1000 blocks:All three verify they received every block in the range (an incomplete fetch fails the run rather than printing a nice number). Headline: obfuscation costs ~25% extra bandwidth, and concurrency buys back nearly all of the wall-clock.
Both swizzled runs share a seed so they execute a byte-identical plan, making concurrency the only variable — without that they draw different plans and the numbers aren't comparable. A discarded warm-up pass equalises server-side caching, and timings are medians because single samples swung the sequential ratio between 1.9x and 12.8x across runs.
This lives in its own crate rather than under
examples/because a gRPC/TLS stack does not compile to wasm and would break the dependency guarantee above. Its lightwalletd messages are hand-writtenproststructs, so there is no build script and noprotocrequirement.Testing
39 tests (unit + integration) and 4 doctests, covering the coverage/overlap/permutation invariants across many seeds, the delay laziness guarantee (poll-counting future under paused time), rejection-resampling bounds, floor and on-grid behaviour, determinism, and the concurrency limit. Clippy clean with
-Dwarningson native and wasm32.tokiois asserted absent from the resolved wasm dependency graph.Not in scope
Transport and destination splitting (never broadcast over the sync session; sync from one server and broadcast through another) and deduplication of overlapping results stay with the application — both documented in the crate docs.
Tuning numbers are deliberately unvalidated. Wider overlaps and checkpoint spacing buy a larger anonymity set at the cost of re-downloaded data, and there are no settled values for that trade-off. Defaults are conservative percentage-of-range derivations, exposed as knobs and documented as such, not presented as validated anonymity parameters. Quantifying anonymity-set size against overlap distribution is left as open research in
design.md.This change is
Summary by CodeRabbit
nym-swizzleSDK utility for randomized delays and obfuscated, overlapping range chunking with shuffled execution and configurable concurrency.nym-swizzle-backed Zcash lightwalletd example to compare direct vs swizzled fetching strategies.nym-swizzle.nym-swizzlecomponents.