Conversation
|
Looks great already, did not dig into the heuristics to deep but form a structural perspective, no complaints. Can we add annotate the source of the heuristics in the code (pointer to the paper) for future reference? Are you still planning to add the additional tag verification step? |
soad003
left a comment
There was a problem hiding this comment.
Only nits, otherwise looks great.
src/graphsenselib/web/routes/txs.py
Outdated
| Literal[ | ||
| "all", | ||
| "one_time_change", "direct_change", "multi_input_change", "all_change", | ||
| "all_coinjoin", "whirlpool", "wasabi", "wasabi_1_0", "wasabi_1_1", "wasabi_2_0", "joinmarket", |
There was a problem hiding this comment.
also prefix whirlpool etc. with _coinjoin for consistency.
| detected: bool | ||
| confidence: int | ||
| n_participants: int | ||
| denomination_sat: int |
There was a problem hiding this comment.
would it be fair to also call this pool_denomination?
| return all(results) | ||
|
|
||
|
|
||
| WHIRLPOOL_TX0_MAX_FORWARD_CHECKS = 20 |
There was a problem hiding this comment.
I would add all constants to the beginning of the file. For things we might want to change some time, please pass them to the function (eg. the max forward checks might be something we want to play with)
I would probably add some config class for the heuristics that we can already pass to get_tx
|
|
||
| # forward verification: if Tx0 detected and DB callbacks available, | ||
| # check if pre-mix outputs were spent in Whirlpool CoinJoins | ||
| if whirlpool_tx0 is not None and get_spent_in and get_tx: |
There was a problem hiding this comment.
maybe add a logger.warning if whirlpool_tx0 is not not and get_spent_in or get_tx is not avail.
|
|
||
| async def calculate_heuristics( | ||
| tx, currency, get_address, heuristics: list | ||
| tx, currency, get_address, heuristics: list, |
There was a problem hiding this comment.
it might make sense to bundle the parameters? eg. coinjoin holds get_spent_in and get_tx
CoinJoin Heuristics
This PR adds structural heuristics for detecting CoinJoin transactions on
UTXO chains (Bitcoin). Heuristics are exposed via the
/txs/{tx_hash}/include_heuristics=all_coinjoinREST endpoint and computed on-demand.Protocol Overview
Comparison table
Key protocol differences
JoinMarket is a peer-to-peer, maker/taker protocol. It has no fixed
denomination — the denomination is simply the most frequent output value.
This makes the heuristic the most permissive: Wasabi 1.x and Whirlpool
CoinJoin transactions also satisfy its structural conditions.
Wasabi 1.0 (ZeroLink) uses a centralized coordinator and a fixed 0.1 BTC
denomination. Every participant gets exactly one equal-value output; change
goes to a single additional output per participant. One extra output carries
the coordinator fee.
Wasabi 1.1 extends ZeroLink with mixing levels: post-mix outputs appear
at powers-of-two multiples of the base denomination (0.1, 0.2, 0.4 BTC, …).
This allows participants to mix non-standard amounts by combining levels.
Wasabi 2.0 (WabiSabi) drops the fixed denomination entirely. The
coordinator negotiates a variable denomination set per round. Rounds are large
(≥ 50 inputs), all outputs are above a minimum value (5 000 sat), and a
denomination is identified as any output value that appears at least twice.
Whirlpool CoinJoin is highly constrained: exactly 5 inputs and 5 outputs,
all at a known pool denomination (100 k / 1 M / 5 M / 50 M sat). Inputs are
either remixers (value == d) or new entrants (value in (d, d + 100 000 sat]).
The protocol requires at least one of each.
Whirlpool Tx0 is not a CoinJoin — it is the pre-mix preparation step.
It splits funds into equal pre-mix UTXOs (d + ε), pays a coordinator fee, and
encodes pool selection in an OP_RETURN output. It is deliberately excluded from
the CoinJoin consensus signal.
API Changes
New
include_heuristicsvalues onGET /{currency}/txs/{tx_hash}CoinJoin heuristics are opt-in via the existing
include_heuristicsqueryparameter. The following values are new:
all_coinjoinwhirlpoolwasabiwasabi_1_0wasabi_1_1wasabi_2_0joinmarketPreviously available values (
one_time_change,direct_change,multi_input_change,all_change,all) are unchanged.Response shape
CoinJoin results are returned under
heuristics.coinjoin_heuristics.All sub-fields are optional and only present when the corresponding value
was requested.
{ "tx_hash": "...", "heuristics": { "coinjoin_heuristics": { "consensus": { "detected": true, "confidence": 90, "sources": ["whirlpool_coinjoin"] }, // Only present when detected (or when explicitly requested and not detected) "whirlpool_coinjoin": { "detected": true, "confidence": 90, // 60 structural / 90 with lineage verification "pool_denomination_sat": 1000000, "n_remixers": 3, "n_new_entrants": 2 }, "whirlpool_tx0": { "detected": false, "confidence": 0, "pool_denomination_sat": 0, "n_premix_outputs": 0 }, "wasabi": { "detected": false, "confidence": 0, "version": "2.0", // "1.0" | "1.1" | "2.0" "n_participants": 0, "denominations": [] }, "joinmarket": { "detected": false, "confidence": 0, "n_participants": 0, "denomination_sat": 0 } } } }Consensus logic
consensusis the single field. It is computedafter all requested heuristics have run and reflects the combined signal:
joinmarket,wasabi, andwhirlpool_coinjoincontribute.whirlpool_tx0is intentionally excluded — it is a preparation transaction,not a privacy-enhancing mix.
max(confidence)across all firing sources.sourceslist: every protocol that fired, e.g.["joinmarket", "wasabi"]when both match (JoinMarket is a structural superset, so this is expected).
consensusisnull/ omitted if noprotocol fires. It is never
{"detected": false, ...}.Detection order and mutual exclusion:
specific — large rounds, no fixed denomination).
_wasabi_11_heuristic) handles both 1.0 and 1.1; the returnedversionfield distinguishes them based on whether mixing levels are active.run in parallel (no mutual exclusion). Multiple can fire for the same tx.
Example — Wasabi 2.0 + JoinMarket: tx
06f5b0ec...block 562 464 (56 inputs, 83 outputs)JoinMarket firing alongside Wasabi is expected: the most frequent Wasabi output
value satisfies the JoinMarket denomination condition. The consensus confidence
(60) reflects the more specific Wasabi 2.0 detection.
Example — Whirlpool + JoinMarket: tx
c73f367c...block 716 576 (5 inputs, 5 outputs, 100 000 sat pool)The 100 000 sat Whirlpool pool denomination is also the most frequent output
value, so JoinMarket fires as well. Consensus confidence (60) reflects the more
specific Whirlpool detection.
Confidence values at a glance
whirlpool_coinjoin.confidencewhirlpool_tx0.confidencewasabi.confidencejoinmarket.confidenceconsensus.confidenceCaveats
JoinMarket is a superset
Every Wasabi 1.x and Whirlpool CoinJoin transaction structurally satisfies the
JoinMarket conditions (equal-value outputs, distinct scripts, majority
post-mix). The JoinMarket confidence cap (49) is intentionally kept below the
Wasabi and Whirlpool scores so the more specific detection takes precedence in
the consensus. When both fire, the consensus reports both sources.
JoinMarket n=2 false positives
A transaction with exactly two equal-value outputs satisfies the JoinMarket
structural check but can occur by coincidence (e.g. splitting funds equally).
This case is reported with confidence 20.
Wasabi 1.x vs 2.0 disambiguation
Wasabi 2.0 detection runs first. If it fires, the 1.x check is skipped.
This matters because a large Wasabi 2.0 round could in principle also look
like a multi-level Wasabi 1.1 transaction. The 2.0 path is more specific
(
≥ 50 inputs, no fixed base denomination) and should be preferred.Whirlpool confidence boost via lineage and forward verification
The structural Whirlpool checks yield confidence 60. Two additional
verifications can upgrade this:
_verify_whirlpool_lineage): recursively checks thatevery input traces back to a Tx0 (new entrant) or a prior CoinJoin
(remixer). A single failure anywhere in the input tree cancels the check.
_verify_tx0_forward): confirms that atleast one pre-mix output was later spent in a Whirlpool CoinJoin. Raises
Tx0 confidence to 90.
Both checks require the optional
get_tx/get_spent_inDB callbacks.If they are not provided (e.g. lightweight call path), structural confidence
(60) is returned as-is.
Known Limitations & Future Work
Exchange batch payout false positives (JoinMarket, Wasabi 1.0, Wasabi 1.1)
Problem: Exchange batch payouts can pass all structural checks for
JoinMarket, Wasabi 1.0, and Wasabi 1.1.
Solution:
Check for exchange tags, if they are present we ignore the match.
Statistics
AI Disclosure
While change heuristics were 100% written by me, this time it was a co-working process. However no auto-edits were allowed and each edit was understood. Tests where 100% created by AI. Documentation mostly written by AI, but I drafted the structure.