From 7b2ff93c0879e3d98b48b7b0ff6980ff08e70295 Mon Sep 17 00:00:00 2001 From: bc1cindy Date: Mon, 25 May 2026 13:35:01 -0300 Subject: [PATCH 1/2] Add radix standard denomination series helpers --- src/crates/partitions/src/lib.rs | 2 + src/crates/partitions/src/radix/denoms.rs | 141 ++++++++++++++++++++++ src/crates/partitions/src/radix/mod.rs | 5 + 3 files changed, 148 insertions(+) create mode 100644 src/crates/partitions/src/radix/denoms.rs create mode 100644 src/crates/partitions/src/radix/mod.rs diff --git a/src/crates/partitions/src/lib.rs b/src/crates/partitions/src/lib.rs index 7f9e987..414f912 100644 --- a/src/crates/partitions/src/lib.rs +++ b/src/crates/partitions/src/lib.rs @@ -1,3 +1,5 @@ +pub mod radix; + use std::collections::{HashMap, HashSet}; use tx_indexer_primitives::traits::abstract_types::{ EnumerateInputValueInArbitraryOrder, EnumerateOutputValueInArbitraryOrder, diff --git a/src/crates/partitions/src/radix/denoms.rs b/src/crates/partitions/src/radix/denoms.rs new file mode 100644 index 0000000..1b8cd07 --- /dev/null +++ b/src/crates/partitions/src/radix/denoms.rs @@ -0,0 +1,141 @@ +//! Standard denomination series (Hamming-weight-1 values in bases 2, 3, 10). + +use std::collections::BTreeSet; +use std::sync::LazyLock; + +use bitcoin::ScriptBuf; +use bitcoin::WPubkeyHash; +use bitcoin::hashes::Hash; + +// TODO: other script types have different dust thresholds; parameterize when we support them. +/// P2WPKH dust at the default 3 sat/vb relay feerate, from rust-bitcoin's +/// `minimal_non_dust()` (294 sats). +pub static P2WPKH_DUST_SATS: LazyLock = LazyLock::new(|| { + ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([0u8; 20])) + .minimal_non_dust() + .to_sat() +}); + +pub fn powers_in_range(b: u64, min: u64, max: u64) -> Vec { + if b < 2 { + return Vec::new(); + } + std::iter::successors(Some(1u64), |&p| p.checked_mul(b)) + .skip_while(|&p| p < min) + .take_while(|&p| p <= max) + .collect() +} + +pub fn multiples_in_range(values: &[u64], coefficients: &[u64], min: u64, max: u64) -> Vec { + let mut s: BTreeSet = BTreeSet::new(); + for &v in values { + for &c in coefficients { + if let Some(cv) = v.checked_mul(c) + && cv >= min + && cv <= max + { + s.insert(cv); + } + } + } + s.into_iter().collect() +} + +/// `{2^k}` series — powers of 2; `{1}` coefficient is identity. +pub fn binary_denoms_in_range(min: u64, max: u64) -> Vec { + powers_in_range(2, min, max) +} + +/// `{1, 2}·3^k` series — 2 multiples per power of 3. +pub fn ternary_denoms_in_range(min: u64, max: u64) -> Vec { + multiples_in_range(&powers_in_range(3, min, max), &[1, 2], min, max) +} + +/// `{1, 2, 5}·10^k` series — 3 multiples per power of 10. +pub fn decimal_denoms_in_range(min: u64, max: u64) -> Vec { + multiples_in_range(&powers_in_range(10, min, max), &[1, 2, 5], min, max) +} + +/// Combined set. +pub fn standard_denoms_in_range(min: u64, max: u64) -> Vec { + if max == 0 || max < min { + return Vec::new(); + } + let mut s: BTreeSet = BTreeSet::new(); + s.extend(binary_denoms_in_range(min, max)); + s.extend(ternary_denoms_in_range(min, max)); + s.extend(decimal_denoms_in_range(min, max)); + s.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn p2wpkh_dust_is_294() { + assert_eq!(*P2WPKH_DUST_SATS, 294); + } + + #[test] + fn powers_in_range_b2_dust_to_1e8() { + let p = powers_in_range(2, 294, 100_000_000); + assert_eq!(p.first(), Some(&512)); + assert_eq!(p.last(), Some(&67_108_864)); + } + + #[test] + fn powers_in_range_b_below_2_is_empty() { + assert!(powers_in_range(0, 1, 100).is_empty()); + assert!(powers_in_range(1, 1, 100).is_empty()); + } + + #[test] + fn multiples_in_range_sorted_and_deduplicated() { + let m = multiples_in_range(&[2, 3, 6], &[1, 2, 3], 1, 100); + assert_eq!(m, vec![2, 3, 4, 6, 9, 12, 18]); + } + + #[test] + fn binary_denoms_in_range_default_params() { + let b = binary_denoms_in_range(294, 100_000_000); + assert_eq!(b.first(), Some(&512)); + assert_eq!(b.last(), Some(&67_108_864)); + } + + #[test] + fn ternary_denoms_in_range_default_params() { + let t = ternary_denoms_in_range(294, 100_000_000); + assert!(t.contains(&729)); + assert!(t.contains(&1458)); + // 3^5 = 243 < dust, so 2·3^5 = 486 is dropped. + assert!(!t.contains(&486)); + } + + #[test] + fn decimal_denoms_in_range_default_params() { + let d = decimal_denoms_in_range(294, 100_000_000); + assert!(d.contains(&1_000)); + assert!(d.contains(&5_000)); + assert!(!d.contains(&200_000_000)); + } + + #[test] + fn standard_denoms_in_range_is_sorted_union_of_three_series() { + let s = standard_denoms_in_range(294, 100_000_000); + for w in s.windows(2) { + assert!(w[0] < w[1]); + } + assert!(s.contains(&512)); + assert!(s.contains(&729)); + assert!(s.contains(&1_000)); + // 3·2^k is not in any series. + assert!(!s.contains(&768)); + } + + #[test] + fn standard_denoms_in_range_empty_when_max_below_min() { + assert!(standard_denoms_in_range(1_000, 500).is_empty()); + assert!(standard_denoms_in_range(0, 0).is_empty()); + } +} diff --git a/src/crates/partitions/src/radix/mod.rs b/src/crates/partitions/src/radix/mod.rs new file mode 100644 index 0000000..9c305ea --- /dev/null +++ b/src/crates/partitions/src/radix/mod.rs @@ -0,0 +1,5 @@ +//! Radix CoinJoin primitives vendored from "Small Hamming Weight Denominations +//! for CoinJoins" by Yuval Kogman (@nothingmuch): +//! + +pub mod denoms; From 9362fa835e57c72a5c6ace85a8d563893451d119 Mon Sep 17 00:00:00 2001 From: bc1cindy Date: Mon, 25 May 2026 13:35:22 -0300 Subject: [PATCH 2/2] Add radix-ness multiplicity classifier --- src/crates/partitions/Cargo.toml | 2 +- src/crates/partitions/src/radix/analysis.rs | 80 +++++++++++++++++++++ src/crates/partitions/src/radix/mod.rs | 3 + 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/crates/partitions/src/radix/analysis.rs diff --git a/src/crates/partitions/Cargo.toml b/src/crates/partitions/Cargo.toml index bb3a970..4555ff5 100644 --- a/src/crates/partitions/Cargo.toml +++ b/src/crates/partitions/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" [dependencies] tx-indexer-primitives = { path = "../primitives" } +bitcoin = { workspace = true } [dev-dependencies] -bitcoin = { workspace = true } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/crates/partitions/src/radix/analysis.rs b/src/crates/partitions/src/radix/analysis.rs new file mode 100644 index 0000000..793821c --- /dev/null +++ b/src/crates/partitions/src/radix/analysis.rs @@ -0,0 +1,80 @@ +//! Naive radix-ness analysis. + +use std::collections::BTreeMap; + +use super::denoms::{ + P2WPKH_DUST_SATS, binary_denoms_in_range, decimal_denoms_in_range, ternary_denoms_in_range, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PerSeriesAnalysis { + pub multiplicities: BTreeMap, +} + +/// Callers compose their own classification from the per-series data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadixAnalysis { + pub pow2: PerSeriesAnalysis, + pub base3: PerSeriesAnalysis, + pub base10: PerSeriesAnalysis, +} + +pub fn analyze(inputs: &[u64], outputs: &[u64]) -> RadixAnalysis { + let amounts: Vec = inputs.iter().chain(outputs).copied().collect(); + let max_amount = amounts.iter().copied().max().unwrap_or(0); + debug_assert!( + max_amount <= bitcoin::Amount::MAX_MONEY.to_sat(), + "amount exceeds bitcoin supply ceiling" + ); + + let pow2_denoms = binary_denoms_in_range(*P2WPKH_DUST_SATS, max_amount); + let base3_denoms = ternary_denoms_in_range(*P2WPKH_DUST_SATS, max_amount); + let base10_denoms = decimal_denoms_in_range(*P2WPKH_DUST_SATS, max_amount); + + RadixAnalysis { + pow2: count_multiplicities(&amounts, &pow2_denoms), + base3: count_multiplicities(&amounts, &base3_denoms), + base10: count_multiplicities(&amounts, &base10_denoms), + } +} + +fn count_multiplicities(amounts: &[u64], denoms: &[u64]) -> PerSeriesAnalysis { + // denoms is sorted (BTreeSet output), so binary_search avoids HashSet alloc. + let mut multiplicities: BTreeMap = BTreeMap::new(); + for &a in amounts { + if denoms.binary_search(&a).is_ok() { + *multiplicities.entry(a).or_insert(0) += 1; + } + } + PerSeriesAnalysis { multiplicities } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_inputs_and_outputs() { + let a = analyze(&[], &[]); + assert!(a.pow2.multiplicities.is_empty()); + assert!(a.base3.multiplicities.is_empty()); + assert!(a.base10.multiplicities.is_empty()); + } + + #[test] + fn per_series_multiplicities_count_exact_denom_matches() { + // 1000, 2000 → Base10; 1024 → Pow2; 1500 → none. + let a = analyze(&[], &[1000, 1000, 2000, 1024, 1500]); + assert_eq!(a.base10.multiplicities.get(&1000), Some(&2)); + assert_eq!(a.base10.multiplicities.get(&2000), Some(&1)); + assert!(!a.base10.multiplicities.contains_key(&1024)); + assert_eq!(a.pow2.multiplicities.get(&1024), Some(&1)); + } + + #[test] + fn analyze_concatenates_inputs_and_outputs() { + let a = analyze(&[50_000], &[100_000]); + assert_eq!(a.base10.multiplicities.get(&50_000), Some(&1)); + assert_eq!(a.base10.multiplicities.get(&100_000), Some(&1)); + } +} diff --git a/src/crates/partitions/src/radix/mod.rs b/src/crates/partitions/src/radix/mod.rs index 9c305ea..45e1bf1 100644 --- a/src/crates/partitions/src/radix/mod.rs +++ b/src/crates/partitions/src/radix/mod.rs @@ -2,4 +2,7 @@ //! for CoinJoins" by Yuval Kogman (@nothingmuch): //! +pub mod analysis; pub mod denoms; + +pub use analysis::{PerSeriesAnalysis, RadixAnalysis, analyze};