A simulation engine for evaluating privacy attacks and anonymity metrics in CoinJoin protocols, with a focus on JoinMarket-style maker/taker architectures.
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.
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
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.jsonThe 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 canonicalSimResult, 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 assigns1/kbelief 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 degreedand effective setS = 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.jsonHeadline (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_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 runHeadline (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_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 runHeadline (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_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 runHeadline (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.
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 runHeadline (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).
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 minimalsecp256k1.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 roundsThe 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.pymeasures a real per-guard latency dataset by driving a runningtordaemon over its control port (stem;pip install -e ".[tor]"). The committed snapshot isdata/tor_latency_dataset.json(tor 0.4.9.8, 8 guards).tor_timing/latency.py,round.py,partition.py,study.pymodel 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 --quickAll 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).
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 aprivate_balance_splitflag 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-chainLightningFundingSourceObserver(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.jsonThe 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 anonymityd = H(X)/log2(N), the Serjantov-Danezis (2003) effective setS = 2^H(X), plus min-entropyH_inf, Bayes vulnerabilityV, 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.jsonRequires Python 3.11+.
pip install -e ".[dev]"Run the test suite:
pytest tests/MIT