Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/crates/partitions/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions src/crates/partitions/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod radix;

use std::collections::{HashMap, HashSet};
use tx_indexer_primitives::traits::abstract_types::{
EnumerateInputValueInArbitraryOrder, EnumerateOutputValueInArbitraryOrder,
Expand Down
80 changes: 80 additions & 0 deletions src/crates/partitions/src/radix/analysis.rs
Original file line number Diff line number Diff line change
@@ -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<u64, usize>,
}

/// 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<u64> = inputs.iter().chain(outputs).copied().collect();
Comment thread
bc1cindy marked this conversation as resolved.
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<u64, usize> = 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));
}
}
141 changes: 141 additions & 0 deletions src/crates/partitions/src/radix/denoms.rs
Original file line number Diff line number Diff line change
@@ -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<u64> = 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<u64> {
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<u64> {
let mut s: BTreeSet<u64> = 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<u64> {
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<u64> {
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<u64> {
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<u64> {
if max == 0 || max < min {
return Vec::new();
}
let mut s: BTreeSet<u64> = 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());
}
}
8 changes: 8 additions & 0 deletions src/crates/partitions/src/radix/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! Radix CoinJoin primitives vendored from "Small Hamming Weight Denominations
//! for CoinJoins" by Yuval Kogman (@nothingmuch):
//! <https://colab.research.google.com/drive/1We_FvfX_Ob9BapFW3X_By9vTtxUrt3pm>

pub mod analysis;
pub mod denoms;

pub use analysis::{PerSeriesAnalysis, RadixAnalysis, analyze};
Loading