Skip to content
Merged
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
23 changes: 18 additions & 5 deletions src/crates/heuristics/src/ast/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use tx_indexer_pipeline::{
node::{Node, NodeId},
value::{TxMask, TxOutClustering, TxOutMask, TxOutSet, TxSet},
};
use tx_indexer_primitives::unified::{AnyOutId, AnyTxId};
use tx_indexer_primitives::{
handle::SpendableTxConstituent,
unified::{AnyOutId, AnyTxId},
};

use crate::change_identification::{
NLockTimeChangeIdentification, NaiveChangeIdentificationHueristic, TxOutChangeAnnotation,
Expand Down Expand Up @@ -40,9 +43,14 @@ impl Node for ChangeIdentificationNode {
let mut result = HashMap::new();

for output_id in txouts.iter() {
let output = output_id.with(ctx.unified_storage());
let Ok(spendable) =
SpendableTxConstituent::try_new(output_id.with(ctx.unified_storage()))
else {
result.insert(*output_id, false);
continue;
};
let is_change = matches!(
NaiveChangeIdentificationHueristic::is_change(output),
NaiveChangeIdentificationHueristic::is_change(spendable),
TxOutChangeAnnotation::Change
);
result.insert(*output_id, is_change);
Expand Down Expand Up @@ -108,11 +116,16 @@ impl Node for FingerPrintChangeIdentificationNode {

for output_id in txouts.iter() {
let output = output_id.with(ctx.unified_storage());
let is_change = match output.spender_txin() {
let spender = output.spender_txin();
let Ok(spendable) = SpendableTxConstituent::try_new(output) else {
result.insert(*output_id, false);
continue;
};
let is_change = match spender {
Some(spending_txin) => {
let spending_tx = spending_txin.containing_tx();
matches!(
NLockTimeChangeIdentification::is_change(output, spending_tx),
NLockTimeChangeIdentification::is_change(spendable, spending_tx),
TxOutChangeAnnotation::Change
)
}
Expand Down
100 changes: 55 additions & 45 deletions src/crates/heuristics/src/ast/uih.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,56 +4,57 @@
//! - UIH1 (Optimal change): smallest output is likely change when min(out) < min(in).
//! - UIH2 (Unnecessary input): transaction could pay outputs without the smallest input.

use std::collections::HashMap;

use tx_indexer_pipeline::{
engine::EvalContext,
expr::Expr,
node::{Node, NodeId},
value::{TxMask, TxOutSet, TxSet},
value::{TxMask, TxOutMask, TxOutSet, TxSet},
};
use tx_indexer_primitives::{
handle::SpendableTxConstituent,
unified::{AnyOutId, AnyTxId},
};
use tx_indexer_primitives::unified::{AnyOutId, AnyTxId};

use crate::uih::UnnecessaryInputHeuristic;

/// Node that implements UIH1 (Optimal change heuristic).
///
/// For each transaction where min(output values) < min(input values), adds the
/// smallest output(s) by value to the result set (likely change).
/// For each output, returns `true` if its value is less than the minimum input
/// value of its containing transaction.
pub struct UnnecessaryInputHeuristic1Node {
input: Expr<TxSet>,
input: Expr<TxOutSet>,
}

impl UnnecessaryInputHeuristic1Node {
pub fn new(input: Expr<TxSet>) -> Self {
pub fn new(input: Expr<TxOutSet>) -> Self {
Self { input }
}
}

impl Node for UnnecessaryInputHeuristic1Node {
type OutputValue = TxOutSet;
type OutputValue = TxOutMask;

fn dependencies(&self) -> Vec<NodeId> {
vec![self.input.id()]
}

fn evaluate(&self, ctx: &EvalContext) -> Vec<AnyOutId> {
let tx_ids = ctx.get_or_default(&self.input);
let mut result = Vec::new();

for tx_id in tx_ids.iter() {
let tx = tx_id.with(ctx.unified_storage());
fn evaluate(&self, ctx: &EvalContext) -> HashMap<AnyOutId, bool> {
let txouts = ctx.get_or_default(&self.input);
let mut result = HashMap::new();

let outputs: Vec<_> = tx.outputs().map(|o| (o.id(), o.value())).collect();
if outputs.is_empty() {
for output_id in txouts.iter() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per-output mask makes sense, but is_uih1_candidate recomputes min(input_values) per output

O(K²) lookups for a K-in/K-out tx

could group output_ids by tx here and compute min(in) once per tx

same mask output

wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah something like that could make sense. Alternatively we could redesignt the optimal change node to iterate over txs instead of txouts. I have a feeling we would do this kind of refactor when we tackle #65

let Ok(spendable) =
SpendableTxConstituent::try_new(output_id.with(ctx.unified_storage()))
else {
result.insert(*output_id, false);
continue;
}

if let Some(min_out) = UnnecessaryInputHeuristic::uih1_min_output_value(&tx) {
for output in tx.outputs() {
if output.value() == min_out {
result.push(output.id());
}
}
}
};
result.insert(
*output_id,
UnnecessaryInputHeuristic::is_uih1_candidate(spendable),
);
}

result
Expand All @@ -68,9 +69,9 @@ impl Node for UnnecessaryInputHeuristic1Node {
pub struct UnnecessaryInputHeuristic1;

impl UnnecessaryInputHeuristic1 {
/// Returns the set of outputs that are the smallest by value in each tx
/// where min(out) < min(in) (BlockSci optimal change heuristic).
pub fn new(input: Expr<TxSet>) -> Expr<TxOutSet> {
/// Returns a mask over outputs where `true` indicates a UIH1 candidate
/// (output value < min input value of its containing transaction).
pub fn new(input: Expr<TxOutSet>) -> Expr<TxOutMask> {
let ctx = input.context().clone();
ctx.register(UnnecessaryInputHeuristic1Node::new(input))
}
Expand Down Expand Up @@ -243,15 +244,16 @@ mod tests {
let mut engine = engine_with_loose(ctx.clone(), all_txs);

let source = AllLooseTxs::new(&ctx);
let uih1 = UnnecessaryInputHeuristic1::new(source.txs());
let uih1 = UnnecessaryInputHeuristic1::new(source.txs().outputs());
let result = engine.eval(&uih1);

let smallest_out = AnyOutId::from(TxOutId::new(TxId(3), 0));
assert!(
result.contains(&smallest_out),
"UIH1 should contain the smallest output (value 50)"
assert_eq!(
result.get(&smallest_out),
Some(&true),
"UIH1 should flag the smallest output (value 50)"
);
assert_eq!(result.len(), 1);
assert_eq!(result.values().filter(|&&v| v).count(), 1);
}

#[test]
Expand All @@ -261,12 +263,12 @@ mod tests {
let mut engine = engine_with_loose(ctx.clone(), all_txs);

let source = AllLooseTxs::new(&ctx);
let uih1 = UnnecessaryInputHeuristic1::new(source.txs());
let uih1 = UnnecessaryInputHeuristic1::new(source.txs().outputs());
let result = engine.eval(&uih1);

assert!(
result.is_empty(),
"UIH1 should be empty when min(out) >= min(in)"
result.values().all(|&v| !v),
"UIH1 should have no candidates when min(out) >= min(in)"
);
}

Expand All @@ -277,12 +279,18 @@ mod tests {
let mut engine = engine_with_loose(ctx.clone(), all_txs);

let source = AllLooseTxs::new(&ctx);
let uih1 = UnnecessaryInputHeuristic1::new(source.txs());
let uih1 = UnnecessaryInputHeuristic1::new(source.txs().outputs());
let result = engine.eval(&uih1);

assert!(result.contains(&AnyOutId::from(TxOutId::new(TxId(3), 0))));
assert!(result.contains(&AnyOutId::from(TxOutId::new(TxId(3), 1))));
assert_eq!(result.len(), 2);
assert_eq!(
result.get(&AnyOutId::from(TxOutId::new(TxId(3), 0))),
Some(&true)
);
assert_eq!(
result.get(&AnyOutId::from(TxOutId::new(TxId(3), 1))),
Some(&true)
);
assert_eq!(result.values().filter(|&&v| v).count(), 2);
}

#[test]
Expand Down Expand Up @@ -364,21 +372,23 @@ mod tests {
let mut engine = engine_with_loose(ctx.clone(), all_txs);

let source = AllLooseTxs::new(&ctx);
let uih1 = UnnecessaryInputHeuristic1::new(source.txs());
let uih1 = UnnecessaryInputHeuristic1::new(source.txs().outputs());
let uih2 = UnnecessaryInputHeuristic2::new(source.txs());

// `.into_owned()` because we hold both results simultaneously below;
// each `eval` borrows the engine, so we drop the borrows by cloning out.
let uih1_result = engine.eval(&uih1).into_owned();
let uih2_result = engine.eval(&uih2).into_owned();

assert!(
uih1_result.contains(&AnyOutId::from(TxOutId::new(TxId(5), 1))),
assert_eq!(
uih1_result.get(&AnyOutId::from(TxOutId::new(TxId(5), 1))),
Some(&true),
"UIH1 should flag tx4's smallest output (vout=1)"
);
assert!(
!uih1_result.contains(&AnyOutId::from(TxOutId::new(TxId(6), 0))),
"UIH1 should not flag tx5 (min(out) >= min(in))"
assert_ne!(
uih1_result.get(&AnyOutId::from(TxOutId::new(TxId(6), 0))),
Some(&true),
"UIH1 should not flag tx5's vout=0 (min(out) >= min(in))"
);

// uih2: tx4 true, tx5 false
Expand Down
49 changes: 36 additions & 13 deletions src/crates/heuristics/src/change_identification.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use tx_indexer_primitives::{
handle::TxHandle,
handle::{SpendableTxConstituent, TxHandle},
traits::abstract_types::{HasNLockTime, HasScriptPubkey, OutputCount, TxConstituent},
};

Expand All @@ -13,7 +13,9 @@ pub struct NaiveChangeIdentificationHueristic;

impl NaiveChangeIdentificationHueristic {
/// Check if a txout is change based on its containing transaction.
pub fn is_change(txout: impl TxConstituent<Handle: OutputCount>) -> TxOutChangeAnnotation {
pub fn is_change(
txout: SpendableTxConstituent<impl TxConstituent<Handle: OutputCount>>,
) -> TxOutChangeAnnotation {
let tx = txout.containing_tx();
if tx.output_count() > 0 && txout.vout() == tx.output_count() - 1 {
TxOutChangeAnnotation::Change
Expand All @@ -27,7 +29,7 @@ pub struct NLockTimeChangeIdentification;

impl NLockTimeChangeIdentification {
pub fn is_change(
tx_out: impl TxConstituent<Handle: HasNLockTime>,
tx_out: SpendableTxConstituent<impl TxConstituent<Handle: HasNLockTime>>,
spending_tx: impl HasNLockTime,
) -> TxOutChangeAnnotation {
let containing_tx_n_locktime = tx_out.containing_tx().locktime();
Expand Down Expand Up @@ -56,7 +58,7 @@ impl ScriptTypesMatchingChangeIdentification {
/// types, unresolved prevouts, or multiple matching outputs are all treated
/// as inconclusive and return `NotChange`.
pub fn is_change<'a>(
tx_out: impl TxConstituent<Handle = TxHandle<'a>>,
tx_out: SpendableTxConstituent<impl TxConstituent<Handle = TxHandle<'a>>>,
) -> TxOutChangeAnnotation {
let tx = tx_out.containing_tx();
let mut input_types = tx.inputs().map(|input| input.output_type());
Expand Down Expand Up @@ -94,6 +96,7 @@ mod tests {

use tx_indexer_primitives::{
UnifiedStorage,
handle::SpendableTxConstituent,
loose::LooseIndexBuilder,
loose::{TxId, TxOutId},
test_utils::{DummyTxData, DummyTxOut, DummyTxOutData},
Expand Down Expand Up @@ -126,7 +129,9 @@ mod tests {
containing_tx: DummyTxData::new_with_amounts(vec![100]),
};
assert_eq!(
NaiveChangeIdentificationHueristic::is_change(txout),
NaiveChangeIdentificationHueristic::is_change(
SpendableTxConstituent::try_new(txout).unwrap()
),
TxOutChangeAnnotation::Change
);
}
Expand All @@ -139,7 +144,10 @@ mod tests {
};
let spending_tx = DummyTxData::new_with_amounts(vec![100]);
assert_eq!(
NLockTimeChangeIdentification::is_change(tx_out, spending_tx),
NLockTimeChangeIdentification::is_change(
SpendableTxConstituent::try_new(tx_out).unwrap(),
spending_tx
),
TxOutChangeAnnotation::NotChange
);

Expand All @@ -150,7 +158,10 @@ mod tests {
};
let spending_tx = DummyTxData::new(vec![DummyTxOutData::new(100, 0)], vec![], 1);
assert_eq!(
NLockTimeChangeIdentification::is_change(tx_out, spending_tx),
NLockTimeChangeIdentification::is_change(
SpendableTxConstituent::try_new(tx_out).unwrap(),
spending_tx
),
TxOutChangeAnnotation::Change
);
}
Expand Down Expand Up @@ -195,11 +206,15 @@ mod tests {
let change = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);

assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(payment),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(payment).unwrap_or_else(|_| unreachable!())
),
TxOutChangeAnnotation::NotChange
);
assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(change),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(change).unwrap_or_else(|_| unreachable!())
),
TxOutChangeAnnotation::Change
);
}
Expand Down Expand Up @@ -244,11 +259,15 @@ mod tests {
let change = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);

assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(payment),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(payment).unwrap_or_else(|_| unreachable!())
),
TxOutChangeAnnotation::NotChange
);
assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(change),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(change).unwrap_or_else(|_| unreachable!())
),
TxOutChangeAnnotation::NotChange
);
}
Expand Down Expand Up @@ -290,11 +309,15 @@ mod tests {
let output1 = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);

assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(output0),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(output0).unwrap_or_else(|_| unreachable!())
),
TxOutChangeAnnotation::NotChange
);
assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(output1),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(output1).unwrap_or_else(|_| unreachable!())
),
TxOutChangeAnnotation::NotChange
);
}
Expand Down
Loading
Loading