Skip to content

Latest commit

 

History

196 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CoinJoin Simulator

A simulation engine for evaluating privacy attacks and anonymity metrics in CoinJoin protocols, with a focus on JoinMarket-style maker/taker architectures.

What it does

The simulator models realistic JoinMarket networks, including maker wallet structures, fidelity bonds, and mixdepths sampled from live orderbook data. It lets you run controlled experiments against different attack models (probing, Sybil, role identification, surveillance) and measure their impact on taker anonymity sets.

Study writeups (published as gists)

This repository is the simulation and analysis engine. The narrative writeups for the studies are published as standalone GitHub gists (each with its figures), not as a site in this repo. The runner scripts below regenerate the underlying result JSON that those writeups are built from:

  • JoinMarket equal-output anonymity in practice - fee-fingerprint clustering and forward-spend attribution replayed against a mainnet JoinMarket corpus
  • CoinJoin probing attack and countermeasures - how a malicious participant builds a maker UTXO database by probing, and which protocol changes limit it
  • A note to makers: fee quantization - why a per-maker fee is a fingerprint and how an on-grid fee fixes it
  • Whirlpool blind-key tagging and WabiSabi Round ID tagging - malicious coordinator deanonymization PoCs
  • Tor guard-fingerprint timing attack on WabiSabi - measured on real Tor
  • Making JoinMarket makers harder to follow - swap providers, channel-as-change, and a third role

Research studies (exploratory)

In addition to the published studies above, three research runner scripts produce exploratory results:

# Subset-sum ambiguity sweep: partition entropy vs. output count/type,
# interval-constraint relaxation (mini-CoinJoinXT gain), Lagarias-Odlyzko threshold.
python run_subset_sum_sweep.py          # subset_sum_sweep_results.json

# Mini-CoinJoinXT lifecycle study: ZKP + swap input + taproot dual-funded channel
# vs. baseline JoinMarket variants, with the off-chain LN funding source observer.
python run_mini_coinjoinxt_study.py     # mini_coinjoinxt_study_results.json

# Sybil-taker and multi-round composition study: ZKP credentials vs. extension
# round, cascade entropy composition, Sybil-taker accumulation.
python run_sybil_taker_study.py         # sybil_taker_study_results.json

# Bonded LN swap-maker subgraph study: why LN reverse swaps alone are not
# CoinJoin replacements, and what maker/provider subgraph properties help.
python run_ln_subgraph_study.py         # ln_subgraph_results.json

Protocol-anonymity study (probabilistic mixdepth state machine)

The protocol_ecosystem, clusterer_diffusion, taker_anonymity, lifecycle_anonymity, wasabi_anonymity, and protocol_anonymity_study modules measure the taker's realized anonymity across a whole simulated JoinMarket ecosystem, with a probabilistic attacker that tracks ownership beliefs per (wallet, mixdepth) and diffuses them across CoinJoins (a Diaz et al. style posterior over a stateful mixdepth machine):

  • protocol_ecosystem.py — a parameterized ecosystem simulator emitting the canonical SimResult, with knobs for fee homogeneity, makers spending equal outputs externally, takers spending after 3-5 rounds, mixed roles, and shared-coin mixed roles (a maker bot whose coin is later spent externally).
  • clusterer_diffusion.py — the probabilistic mixdepth-keyed diffusion clusterer: a precision-1.0 hard backbone (must-not-link, change-chain, CIOH bundle, round-trip CIOH) plus a soft layer that assigns 1/k belief on ambiguous fee matches and collapses them via later co-spends.
  • taker_anonymity.py — Diaz posterior over each round's equal outputs (taker and own-spend anonymity), reporting degree d and effective set S = 2^H.
  • lifecycle_anonymity.py — the multi-round counterfactual-path anonymity of a spent coin (how many origin-to-spend paths are consistent), by coin depth.
  • wasabi_anonymity.py — the same Diaz ruler on real Wasabi 2.0 rounds from liquisabi.com + mempool.space (trusted-coordinator structural upper bound).
  • protocol_anonymity_study.py — the S1-S6 scenario ladder sweep assembly, including the anonymity-per-blockspace efficiency metric.

Regenerate the results with:

python run_protocol_anonymity_study.py            # protocol_anonymity_results.json
python fetch_wasabi_rounds.py --rounds 80         # data/wasabi2_rounds_enriched.json
python run_wasabi_anonymity_study.py              # wasabi_anonymity_results.json

Headline (100 makers, 9 makers + 1 taker per round, N = 10): under strictly separated roles takers retain almost no anonymity (effective set ~1.32 of 10, ~83% of rounds deanonymized, independent of the fee regime, and not helped by adding more participants). External maker spends create cover for individual spends (a 10% rate gives own-spend effective set ~4.8 of 10), but the naive external-spend knob also drains maker liquidity, so high-spend rows are stress tests, not deployable fixes. The durable fix direction is to make taker coins maker-like without exhausting liquidity, likely combined with swap inputs, variable amounts, or off-chain-change mechanisms. For calibration, real Wasabi 2.0 standard outputs score Diaz degree ~0.965 versus same-denomination output counts (~0.986 after feasibility filtering), but only under a trusted coordinator with sybil and tagging ignored.

The Wasabi 2.0 calibration uses the evaluator wasabi_anonymity.py, runner run_wasabi_anonymity_study.py, and data fetch fetch_wasabi_rounds.py.

Provenance-recycling countermeasure

provenance_study.py evaluates a concrete fix for the taker exposure above: a publicly checkable provenance-forced recycle (every equal output whose hash is 0 mod q must be spent out of the maker wallet), in three on-chain shapes (external drain / self-send / swap). The mechanism is in protocol_ecosystem.py (MakerRecyclePolicy, provenance_is_due), and the attacker is taught to exploit it via maker_reachable_inputs (self-send closure) in clusterer_diffusion.py and a due-aware spend boost in taker_anonymity.py. Metrics are restricted to genuine external exits (exit_anonymity).

Regenerate the results with:

python run_provenance_study.py            # provenance_results.json
python run_provenance_study.py --quick    # tiny smoke run

Headline (50 makers, 300 takers, 9 makers/CJ): only a swap recycle retains both maker liquidity (100%) and taker exit anonymity (genuine-exit deanon 56.8% to 4.6%); an external drain destroys two thirds of capital and collapses throughput to 18%, and a self-send buys no anonymity (the attacker follows the hop). The forced modulus q is the lever (deanon 0.0% at q=2 to 23.6% at q=10, at a swap fee scaling ~1/q), and aligning taker exits to the public trigger is a negative result (no net gain, extra mixing rounds).

Maker clustering at scale and the recycle-change route

maker_clustering_study.py scales the mainnet maker-clustering attack (the JoinMarket equal-output anonymity in practice study, published as a gist) into the simulator and stresses the swap recycle from the maker's side, scored by the sound global matching attacker (clusterer_matching.py), which propagates each round's AllDifferent (Latin-square) constraint and makes only forced deductions, so precision stays 1.0. It sweeps three fee regimes and four change routes via protocol_ecosystem.py's FeeRegime and ChangeRoute, and adds two metrics in clusterer_diffusion.py: mixdepth_bridge_counts (cross-mixdepth same-owner bridges) and fee_cover_floors (the taker-computable cover floor).

Regenerate the results with:

python run_maker_clustering_study.py            # maker_clustering_results.json
python run_maker_clustering_study.py --quick    # tiny smoke run

Headline (30 to 120 active makers, 5 to 9 makers/CJ, 2 seeds): heterogeneous fees resolve the m -> m+1 forward edge at every population (wallet recall 1.00), so scale is no defense (M1); uniform fees only hold below an mpc-dependent co-occurrence percolation threshold (recall 1.00 then 0.21) (M2); and swap recycling severs the link only when the recycle change is itself unlinkable, since same/next-mixdepth change relink 98 to 99% of recycled coins, and next-mixdepth change is strictly worse, forging precision-1.0 cross-mixdepth bridges (898.5 per run) that survive fee homogenization (M3). The taker-side cover floor confirms the fee channel without any maker identities (ratio 1.00 uniform, ~0.10 distinct).

Provenance-forced swap as a joint defense

provenance_defense_study.py joins the two studies above on the same runs: it scores one forced-swap sweep with both the strong maker-clustering attacker (clusterer_matching.py) and the exit-restricted taker scorer (provenance_study.py), plus liquidity, throughput, and swap-fee cost, to ask whether forcing due-coin swaps gives the makers a crowd too or only the takers. It adds the all-or-nothing wallet_full_recovery metric in clusterer_diffusion.py and one engine flag in protocol_ecosystem.py, provenance_force_all_due, which forces a swap on every due coin (the faithful model of a taker rejecting any due input) rather than only the due equal outputs, severing the change-chain backbone.

Regenerate the results with:

python run_provenance_defense_study.py            # provenance_defense_results.json
python run_provenance_defense_study.py --quick    # tiny smoke run

Headline (60 makers, 240 takers, 5 makers/CJ, 3 seeds, below the percolation threshold): forced swap restores the taker exit in every regime (exit deanon 0.53 to 0.02 monotone in 1/q, liquidity 1.00, throughput unchanged at 972, fee ~1/q), but the maker side turns on the recycle scope. Swapping only the due equal outputs leaves maker recall at 1.00 under distinct fees (a taker defense only); swapping every due coin collapses it, and the collapse shows first in wallet full recovery (q=4: recall 0.97 but full recovery 0.16) while pairwise recall lags because the fee fingerprint keeps relinking survivors. Under uniform fees forced swap collapses the maker side under either scope once q is small. Forced swapping is the chain defense, fee homogenization the fingerprint defense, and full maker unlinkability under distinct fees wants both.

Breaking cross-slot maker clustering (swap providers, channel-as-change, third role)

maker_swap_provider_study.py honestly answers the maker-anonymity question the protocol-anonymity and maker-clustering studies raise: a maker's coins are linked across its CoinJoin slots by three precision-1.0 structural edges (the change peel chain, the CIOH co-spend, and the reused-equal-output forward edge), so the sound matching attacker reconstructs a maker's whole wallet even under uniform fees. The simulator (maker_swap_provider.py) models three complementary improvements and the scorer (maker_swap_provider_metrics.py) measures slot-level clustering (sound matching + diffusion, with the Diaz effective-anonymity-set view), an honest subset-sum change-match by an on-chain observer that does not know input ownership, and the counterparty cross-layer leak. It does not assume an edge-free injection: a real swap-out leaves maker change, so being a provider is a partial (not total) fix.

The study writeup (Making JoinMarket makers harder to follow) is published as a gist. Regenerate the results with:

python run_maker_swap_provider_study.py                  # maker_swap_provider_results.json
python run_maker_swap_provider_study.py --quick          # tiny smoke run

Headline (30 makers, 9 makers/CJ, 600 rounds, 3 seeds): the cross-slot link is an intersection over many CoinJoins, so it builds up with corpus length (baseline match-link 0.17 at 150 rounds, 0.99 by 300, 1.0 by 600), and at the long corpus the baseline maker is fully recovered (~180 slots in one effective wallet). (1) Being a bidirectional swap provider is a significant, easy partial fix: realistic change-leaving swaps drop the largest linked fragment to 6.5 (same under uniform and distinct fees) and, read with the Diaz metric, fragment the maker into ~107 effective wallets (degree 0.90), with no new protocol and while earning swap fees. (2) Emitting CoinJoin change as a Taproot channel-funding output severs the slot-level peel chain (fragment 1.0, Diaz degree 1.0), feasible on stock LND today (PSBT funding shim, simple-taproot channels). But the chain break is not the same as escaping value attribution: an honest observer that does not know input ownership still change-matches a plain change OR a single channel funding output (both equal one maker's exact residual) ~0.99 of the time. Splitting hides from that per-output attack, but a single-owner whole-round partition attacker (CoinJoin-Sudoku) re-groups the split outputs; a combined shared output has no single-owner partition, yet a maximal attacker that hypothesizes group-funded shared outputs inverts a fixed small pool and attributes combined too at a small anonymity set (~1.0 at 4 makers/CJ). The only real protection is the anonymity set: as makers/CJ grows, splitting's attribution falls toward zero (low-density subset-sum hardness) while a fixed-pool combine sawtooths (invertible at set-divisible sizes), so no on-chain change shape is unconditionally private. The combined channel's co-signing counterparty is a fellow maker (the funding link stays in-family); a force close re-exposes the channel on chain. (3) A liquidity-arbitrage third role that accepts a maker's whole coin at near-zero fee also severs the chain (fragment 1.0) at the cost of concentrating the linkage onto the arbitrageur's own wallet. This is not "more mixdepths": the fixes stop the reuse or remove the chaining successor, not relabel the bucket.

The companion ln_maker_anonymity.py is a Diaz-scored surface model of the maker's Lightning-level anonymity (Kappos/Romiti cross-layer surfaces) that sweeps one shared node vs a node per mixdepth, lnproxy-hidden pubkeys, probing resistance, and acting as an lnproxy relay; it bounds the off-chain side of the channel-as-change defense above (tests under tests/test_ln_maker_anonymity.py).

Centralized-coordinator tagging attacks (Whirlpool & WabiSabi PoCs)

Two self-contained proofs-of-concept reproduce the malicious-coordinator deanonymization attacks that Kogman (nothingmuch) reiterated on the bitcoindev list (Reiterating centralized coinjoin ... deanonymization attacks, 2024-12-21). Both are protocol flaws that reduce to a missing key-consistency check, and both collapse the full anonymity set to 1 (100% deanonymization), independent of the round size:

  • whirlpool_tagging.py - Whirlpool's RSA blind signature with a per-input signing key. A genuine Chaumian blind signature is implemented; an honest single-key mix keeps the 5-of-5 set, a per-input-key mix lets the coordinator read the TxOut -> TxIn map off output registration. Published as the Whirlpool blind-key tagging gist.
  • wabisabi_tagging.py - WabiSabi's per-client Round ID (issuer key), using a genuine GGM algebraic MAC (Chase-Meiklejohn-Zaverucha) over a minimal secp256k1.py. It also implements the key-consistency defense (poll the Round ID over independent circuits, require one value), which detects the equivocation and aborts. Published as the WabiSabi Round ID tagging gist.
python run_whirlpool_tagging_poc.py            # whirlpool_tagging_results.json
python run_whirlpool_tagging_poc.py --quick    # 256-bit RSA, faster
python run_wabisabi_tagging_poc.py             # wabisabi_tagging_results.json
python run_wabisabi_tagging_poc.py --quick     # smaller rounds

Tor guard-fingerprint timing attack (measured on real Tor)

The covert timing variant is implemented and measured against the live Tor network. The tor_timing subpackage models the guard-pinning fingerprint a coordinator (or Cloudflare/VPS/exit) can exploit: because Tor pins each client to a long-lived guard, that client's circuit latencies sit at a near-constant offset that re-links a round's anonymous outputs to its known inputs.

  • tor_timing/tor_probe.py measures a real per-guard latency dataset by driving a running tor daemon over its control port (stem; pip install -e ".[tor]"). The committed snapshot is data/tor_latency_dataset.json (tor 0.4.9.8, 8 guards).
  • tor_timing/latency.py, round.py, partition.py, study.py model the round, run the guard-fingerprint linkage attack, and score it on the Diaz ruler.

Only a plaintext observer (the coordinator, the TLS-terminating CDN the popular coordinators sit behind, or the VPS between them) can turn the timing correlation into a UTXO map; a Tor exit sees only ciphertext. Headline (real guards, 8-client round): a single request barely beats chance, but amplifying over the many guard-pinned requests of a round drops the effective anonymity set from ~7.5 to ~4.6 (no-defense build) or ~3.8 (the residual guard RTT that survives the circuit-pool fix); and because ~84% of rounds fail and clients retry with the same inputs, the multi-shot linkage of a coin rises from 27% (one round) to 98% (over 32 retries). Timing randomization restores anonymity at a multi-second cost. Published as the Tor guard-fingerprint timing attack on WabiSabi gist.

python run_tor_timing_study.py                 # tor_timing_results.json
python run_tor_timing_study.py --quick

Worst-case metrics

All scoring pipelines now report both Shannon-based metrics and worst-case bounds:

  • min_entropy_bits = H_inf(X) = -log2(max p_i) (Smith 2009, tight single-guess bound)
  • bayes_vulnerability = V(X) = max p_i (adversary's optimal one-shot success probability)
  • worst_case_effective_set = 2^H_inf (worst-case analog of the Serjantov-Danezis set)

The change-output analysis additionally reports partition_entropy_bits and partition_min_entropy_bits over the distribution of valid input-to-change owner assignments (the measure of combinatorial ambiguity in subset-sum analysis).

Ecosystem lifecycle framework

The ecosystem subpackage measures the realized privacy of CoinJoin use across the full lifecycle, against many observers whose partial knowledge intersects across the whole transaction graph:

  • ecosystem/ledger.py - ground-truth graph of coins/txs + a Lightning sub-ledger of channels and balances. Channels support a private_balance_split flag for dual-funded (mini-CoinJoinXT) openings.
  • ecosystem/knowledge.py - the intersection core: per-coin structural priors, hard/soft owner facts, and CIOH + CoinJoin-exclusivity propagation to a fixed point, scored by the Diaz/Serjantov-Danezis metrics.
  • ecosystem/strategies.py - real tx structure for JoinMarket, JoinMarket+fee- quantization, JoinMarket+LN-as-change, JoinMarket+mini-CoinJoinXT, Wasabi 2.0, and Whirlpool, plus spends, consolidation, and channel opens.
  • ecosystem/observers.py - seven adversaries: Silent Payments payer, passive chain, co-participant/Sybil, evil coordinator, spend counterparty, Lightning, and the off-chain LightningFundingSourceObserver (invoice-pattern clustering, the PR #280 residual surface).
  • ecosystem/actors.py, ecosystem/evaluate.py, ecosystem/study.py - lifecycle actors, scoring, and study assembly.

Regenerate the results with:

python run_ecosystem_study.py                 # ecosystem_study_results.json

Protocol comparison framework

The diaz, protocols, improvements, and comparison modules put the three deployed CoinJoin designs on a single information-theoretic scale:

  • diaz.py - the Diaz et al. (2002) degree of anonymity d = H(X)/log2(N), the Serjantov-Danezis (2003) effective set S = 2^H(X), plus min-entropy H_inf, Bayes vulnerability V, and their composition rules.
  • protocols.py - per-protocol posterior models (JoinMarket, Wasabi 2.0, Whirlpool) under a shared, parameterized threat-model ladder.
  • improvements.py - seven JoinMarket improvement proposals (fee quantization, ZKP coordination, round extension, Lightning swap input, CoinJoinXT, taproot, and the new mini-CoinJoinXT composite) modeled as transforms on the same scale.
  • comparison.py / comparison_data.py - the study assembly and real-data calibration loaders (live JoinMarket orderbook, live liquisabi Wasabi 2.0 observer, literature for Whirlpool).

Regenerate the results with:

python run_diaz_comparison_study.py            # writes diaz_comparison_results.json

Getting started

Requires Python 3.11+.

pip install -e ".[dev]"

Run the test suite:

pytest tests/

License

MIT

About

Compact simulation toolkit for analyzing CoinJoin privacy under probing and adversarial taker pressure.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Contributors

Languages