From 5e20a6a571e359e78d6059f5c9de07faa5590758 Mon Sep 17 00:00:00 2001 From: Oladapo Oyindamola <111582215+0xZaddyy@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:36:22 +0100 Subject: [PATCH 1/2] add Annotator for roundness classification introduce `RoundNumberAnnotation` enum and annotator that classifies amounts as round or not based on decimal Hamming weight threshold --- .../heuristics/src/change_identification.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/crates/heuristics/src/change_identification.rs b/src/crates/heuristics/src/change_identification.rs index 5b62333..9cc52a6 100644 --- a/src/crates/heuristics/src/change_identification.rs +++ b/src/crates/heuristics/src/change_identification.rs @@ -1,3 +1,4 @@ +use tx_indexer_primitives::hamming_weight::decimal_hamming_weight; use tx_indexer_primitives::{ handle::TxHandle, traits::abstract_types::{HasNLockTime, HasScriptPubkey, OutputCount, TxConstituent}, @@ -9,6 +10,12 @@ pub enum TxOutChangeAnnotation { NotChange, } +#[derive(Debug, PartialEq,Eq)] +pub enum RoundNumberAnnotation { + Round, + NotRound, +} + pub struct NaiveChangeIdentificationHueristic; impl NaiveChangeIdentificationHueristic { @@ -45,6 +52,24 @@ impl NLockTimeChangeIdentification { } } +pub struct RoundNumberAnnotator; + +impl RoundNumberAnnotator { + /// Maximum decimal Hamming weight for an amount to be considered round. + pub const ROUNDNESS_THRESHOLD: u32 = 2; + + /// judge how round an amount is via it's hamming weight + pub fn annontate(satoshi: u64) -> RoundNumberAnnotation { + if decimal_hamming_weight(satoshi) <= Self::ROUNDNESS_THRESHOLD { + RoundNumberAnnotation::Round + } else { + RoundNumberAnnotation::NotRound + } + + } + +} + pub struct ScriptTypesMatchingChangeIdentification; impl ScriptTypesMatchingChangeIdentification { From 36ef5d1ae532a1978b2ef7ab4391b2809116f90a Mon Sep 17 00:00:00 2001 From: Oladapo Oyindamola <111582215+0xZaddyy@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:39:29 +0100 Subject: [PATCH 2/2] Add `RoundNumberChangeHeuristic` for change identification Label every output via `RoundNumberAnnotator`(hamming weight) in future more measure will be added to annonote e.g historic exchange value and classify the target from the label distribution: sole `NotRound` is `Change`, multiple `NotRounds` is `Inconclusive`, `Round` is `NotChange`. Adds `Inconclusive` to `TxOutChangeAnnotation` and tests for the new heuristic. --- .../heuristics/src/change_identification.rs | 135 +++++++++++++++++- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/src/crates/heuristics/src/change_identification.rs b/src/crates/heuristics/src/change_identification.rs index 9cc52a6..94f2924 100644 --- a/src/crates/heuristics/src/change_identification.rs +++ b/src/crates/heuristics/src/change_identification.rs @@ -1,16 +1,19 @@ use tx_indexer_primitives::hamming_weight::decimal_hamming_weight; use tx_indexer_primitives::{ handle::TxHandle, - traits::abstract_types::{HasNLockTime, HasScriptPubkey, OutputCount, TxConstituent}, + traits::abstract_types::{ + AbstractTransaction, HasNLockTime, HasScriptPubkey, OutputCount, TxConstituent, + }, }; #[derive(Debug, PartialEq, Eq)] pub enum TxOutChangeAnnotation { Change, NotChange, + Inconclusive, } -#[derive(Debug, PartialEq,Eq)] +#[derive(Debug, PartialEq, Eq)] pub enum RoundNumberAnnotation { Round, NotRound, @@ -52,22 +55,65 @@ impl NLockTimeChangeIdentification { } } -pub struct RoundNumberAnnotator; +pub struct RoundNumberAnnotator; impl RoundNumberAnnotator { /// Maximum decimal Hamming weight for an amount to be considered round. pub const ROUNDNESS_THRESHOLD: u32 = 2; - /// judge how round an amount is via it's hamming weight - pub fn annontate(satoshi: u64) -> RoundNumberAnnotation { - if decimal_hamming_weight(satoshi) <= Self::ROUNDNESS_THRESHOLD { + /// Judge how round a satoshi amount is via its decimal Hamming weight. + pub fn annotate(satoshis: u64) -> RoundNumberAnnotation { + if decimal_hamming_weight(satoshis) <= Self::ROUNDNESS_THRESHOLD { RoundNumberAnnotation::Round } else { RoundNumberAnnotation::NotRound } - } +} + +pub struct RoundNumberChangeHeuristic; + +impl RoundNumberChangeHeuristic { + /// Classify a txout as change by labeling every output via + /// `RoundNumberAnnotator` and reasoning over the label distribution. + /// + /// - `Change`: target is the sole `NotRound` output (others are all `Round`). + /// - `NotChange`: target is `Round`, or the tx has a single output. + /// - `Inconclusive`: target is `NotRound` but other outputs are too — can't tell. + pub fn is_change( + txout: impl TxConstituent, + ) -> TxOutChangeAnnotation { + let tx = txout.containing_tx(); + let vout = txout.vout(); + let labels: Vec = tx + .outputs() + .map(|out| RoundNumberAnnotator::annotate(out.value().to_sat())) + .collect(); + + if labels.len() <= 1 { + return TxOutChangeAnnotation::NotChange; + } + + let Some(target_label) = labels.get(vout) else { + return TxOutChangeAnnotation::Inconclusive; + }; + if *target_label == RoundNumberAnnotation::Round { + return TxOutChangeAnnotation::NotChange; + } + + let other_non_round = labels + .iter() + .enumerate() + .filter(|&(i, label)| i != vout && *label == RoundNumberAnnotation::NotRound) + .count(); + + if other_non_round == 0 { + TxOutChangeAnnotation::Change + } else { + TxOutChangeAnnotation::Inconclusive + } + } } pub struct ScriptTypesMatchingChangeIdentification; @@ -323,4 +369,79 @@ mod tests { TxOutChangeAnnotation::NotChange ); } + + #[test] + fn test_round_number_annotator() { + assert_eq!( + RoundNumberAnnotator::annotate(100_000_000), + RoundNumberAnnotation::Round + ); + assert_eq!( + RoundNumberAnnotator::annotate(1_600), + RoundNumberAnnotation::Round + ); + assert_eq!( + RoundNumberAnnotator::annotate(143_000), + RoundNumberAnnotation::NotRound + ); + assert_eq!( + RoundNumberAnnotator::annotate(34_567_891), + RoundNumberAnnotation::NotRound + ); + } + + #[test] + fn test_round_number_change_picks_sole_non_round() { + // 1 BTC payment + high-precision change. + let tx = DummyTxData::new_with_amounts(vec![100_000_000, 34_567_891]); + + let payment = DummyTxOut { + vout: 0, + containing_tx: tx.clone(), + }; + assert_eq!( + RoundNumberChangeHeuristic::is_change(payment), + TxOutChangeAnnotation::NotChange + ); + + let change = DummyTxOut { + vout: 1, + containing_tx: tx, + }; + assert_eq!( + RoundNumberChangeHeuristic::is_change(change), + TxOutChangeAnnotation::Change + ); + } + + #[test] + fn test_round_number_change_multiple_non_round_is_inconclusive() { + // Two non-round outputs → can't tell which is change. + let tx = DummyTxData::new_with_amounts(vec![12_345_678, 87_654_321]); + for vout in 0..2 { + let txout = DummyTxOut { + vout, + containing_tx: tx.clone(), + }; + assert_eq!( + RoundNumberChangeHeuristic::is_change(txout), + TxOutChangeAnnotation::Inconclusive + ); + } + } + + #[test] + fn test_round_number_change_all_round_is_not_change() { + let tx = DummyTxData::new_with_amounts(vec![100_000_000, 50_000_000, 10_000_000]); + for vout in 0..3 { + let txout = DummyTxOut { + vout, + containing_tx: tx.clone(), + }; + assert_eq!( + RoundNumberChangeHeuristic::is_change(txout), + TxOutChangeAnnotation::NotChange + ); + } + } }