DeWhirlpooler Tx0 false-positive audit
Date: 2026-07-30
Contributor: TraceForgeAI
Commit: 41cd0dc (Reject public Tx0 structural collisions)
Finding
The v0.1.0 detector accepted either an arbitrary 64-byte OP_RETURN or an
exact legacy coordinator-fee-sized output as sufficient Tx0 corroboration.
This creates public-chain structural collisions that enter the historical
index as candidate Tx0s and can inflate pool-entry, liquidity, Tx0-count, and
coordinator-accounting results.
Two independently reproduced examples are:
32e4876d9144aa1234ae21f22939210b46985007e88468ce40be24798e8f0e8f: a human-readable BNB Beacon Chain memo misclassified as a legacy 0.001 BTC Tx0 marker.ba7cf8fa9d12281c8b90ea5d9516045b3bd9d7757b433fb0b6087dfe9578289b: an ordinary payment with a legacy fee-sized output but no Tx0 marker.
Fix
- Require exactly one observed Tx0 marker for Tx0 classification.
- Treat the documented marker as opaque binary data, rejecting fully printable 64-byte text payloads.
- Add both public transactions as negative regression vectors.
Independent validation
| Test set | Before | After |
|---|---|---|
| 20 control blocks / 44,020 transactions | 22 Tx0 candidates | 0 candidates |
| Recent labelled Whirlpool rounds | 25/25 detected | 25/25 detected |
| Recent labelled Ashigaru Tx0s | 14/14 detected | 14/14 detected |
| Project unit/integration suite | โ | 404 passed, 1 pre-existing Windows test deselected |
Additional checks: Ruff, Python compilation, JavaScript syntax, and Git diff validation all passed. The full control scan used raw public blocks from mempool.space. Positive labels came from Whirlpool.Observer and were independently resolved from public transaction hex and direct prevouts.
Apply
From the DeWhirlpooler repository root:
git am 0001-Reject-public-Tx0-structural-collisions.patch
From 41cd0dc7050848e8265ead2f24503808e54522fb Mon Sep 17 00:00:00 2001 From: TraceForgeAI traceforgeai@users.noreply.local Date: Thu, 30 Jul 2026 11:35:18 -0400 Subject: [PATCH] Reject public Tx0 structural collisions
docs/sources.md | 19 +++++++++++++++++++ src/dewhirlpooler/bitcoin.py | 9 +++++++-- src/dewhirlpooler/whirlpool.py | 5 ++--- tests/test_whirlpool.py | 17 +++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-)
diff --git a/docs/sources.md b/docs/sources.md index 3b36a72..242eb16 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -125,6 +125,25 @@ vB and the child a 15,598-sat fee over 110 vB. Their fee rates are 59.41 and same-block fee-lift evidence consistent with CPFP; it does not prove intent, participant identity, or a deterministic coinjoin input-to-output link.
+## Public Tx0 false-positive controls
+
+Two mainnet transactions are committed as negative regression vectors for
+Tx0 detection:
+
+- 32e4876d9144aa1234ae21f22939210b46985007e88468ce40be24798e8f0e8f
- contains a human-readable 64-byte BNB Beacon Chain memo and one output near
- the legacy 0.001 BTC pool amount. The memo is not an opaque Tx0 marker.
+-
ba7cf8fa9d12281c8b90ea5d9516045b3bd9d7757b433fb0b6087dfe9578289b - contains one exact legacy fee-sized output and one output near the 0.05 BTC
- pool amount, but no Tx0 marker.
+Their raw transaction hex was retrieved from the public mempool.space API on +July 30, 2026. Both examples were found in a control scan after Samourai's +coordinator shutdown and before the first Ashigaru round observed by +Whirlpool.Observer. They demonstrate why amount proximity, a fee-sized output, +or an arbitrary readable 64-byte OP_RETURN cannot independently establish a +Tx0 classification. +
Clean-room boundary
The parser and detector were written from the behavioral statements above and diff --git a/src/dewhirlpooler/bitcoin.py b/src/dewhirlpooler/bitcoin.py index 260b9d8..dc862fe 100644 --- a/src/dewhirlpooler/bitcoin.py +++ b/src/dewhirlpooler/bitcoin.py @@ -313,14 +313,19 @@ def encode_p2wpkh_address(
def is_tx0_marker(output: TxOutput) -> bool:
- """Return whether an output has the observed 64-byte Tx0 marker shape."""
- """Return whether an output has the observed opaque Tx0 marker shape."""
- return (
- shape_matches = ( output.value_sats == 0 and output.script_type is ScriptType.OP_RETURN and len(output.script_pubkey) == 66 and output.script_pubkey[:2] == b"\x6a\x40" )
- if not shape_matches:
-
return False - payload = output.script_pubkey[2:]
- return any(byte < 0x20 or byte > 0x7E for byte in payload)
def _serialize_transaction( diff --git a/src/dewhirlpooler/whirlpool.py b/src/dewhirlpooler/whirlpool.py index 5ef9cfd..0c62daf 100644 --- a/src/dewhirlpooler/whirlpool.py +++ b/src/dewhirlpooler/whirlpool.py @@ -192,6 +192,8 @@ def _tx0_candidates( return []
markers = [output for output in transaction.outputs if is_tx0_marker(output)]
-
if len(markers) != 1:
-
return []candidates: list[_Candidate] = []
for pool in pools: @@ -214,9 +216,6 @@ def _tx0_candidates( for premix_outputs in premix_groups.values(): if not 1 <= len(premix_outputs) <= pool.max_premix_outputs: continue
-
if not markers and not fee_outputs: -
continue -
score = 4 evidence = [ Evidence(
diff --git a/tests/test_whirlpool.py b/tests/test_whirlpool.py index a032abb..0b267ac 100644 --- a/tests/test_whirlpool.py +++ b/tests/test_whirlpool.py @@ -312,6 +312,23 @@ def test_equal_output_batch_without_tx0_signals_is_unknown() -> None: assert detection.kind is TransactionKind.UNKNOWN
- "raw_hex",
- [
-
# Public BNB Beacon Chain memo: a readable 64-byte OP_RETURN is not -
# an opaque Whirlpool Tx0 marker despite a nearby pool-sized output. -
"0100000000010194c3097aed203cb1a340e03e77e1578ea8823571986af875b04bf164f01acdf30100000000ffffffff0310270000000000001600145cc43c64b35c9cb11246ee0080023867bfb1109088a2010000000000160014993c1ba90531dfc84f4ac36d1fe3a38207980d9a0000000000000000426a403d3a4253432e424e423a3078394331334432313832324339353945634244316234664632644439313532353736313246353732303a302f312f303a74693a3730024730440220672f7389af6e16a3b63766e87d7f2f0d58bbb85b71ad295e40a42e027e9e402f02203696503dae825b82e7ab7d36da6b8ea90e6e626a75f099c030bda4b1ba5852e6012102956b53e69005c4fe22c35ae7774df8e7e421afb1fc3d1d3fd455c21b41023f6f00000000", -
# Public ordinary payment: an exact legacy fee-sized output and a -
# nearby denomination alone are insufficient without a Tx0 marker. -
"010000000001010245543e16544f6d64993b537142b0064b0056180764e451c2f5b4d79cedd2c30200000000ffffffff05187a08000000000017a9144513a437a83a2838d8079615af9f6eeb0292022a877606010000000000160014e392adb0e40206f861d38c0d63419e965fe88ccb90d003000000000016001442348820dbbf8adf3ea1dfb93c91d86fdfc3c949d4c801000000000016001461a3d07da3990d1eb2f4e2885cacd7cc306d84ec1d754d000000000016001428163e214d94445333baa8333e88094b0f502374024830450221008002fc4de575a91275b5dffe933fd16632b75dfb7a18c1eae2e18587f292f9e502201ce54b064c5be9097e959d8719dd5634a9113424562318a8db90b2f006a8055b0121037aa95ff6fb5c1e48171fc8fef2df2a1b18e41c7d4a8a73908d2b72e3a4dd382b00000000", - ], +) +def test_tx0_rejects_public_structural_collisions(raw_hex: str) -> None:
- transaction = parse_transaction_hex(raw_hex)
- assert detect_whirlpool(transaction).kind is TransactionKind.UNKNOWN
def test_multiple_residual_tx0_outputs_lower_confidence() -> None: transaction = _fixture("ashigaru-tx0-0.025.hex") extra_output = TxOutput(