Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
25 changes: 16 additions & 9 deletions src/crates/heuristics/src/ast/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ use tx_indexer_pipeline::{
};
use tx_indexer_primitives::{
AbstractTxIn,
handle::SpendableTxConstituent,
unified::{AnyOutId, AnyTxId},
};

use crate::change_identification::{
NLockTimeChangeIdentification, NaiveChangeIdentificationHueristic, TxOutChangeAnnotation,
NLockTimeChangeIdentification, NaiveChangeIdentificationHeuristic, TxOutChangeAnnotation,
};

/// Node that identifies change outputs in transactions.
Expand Down Expand Up @@ -44,10 +45,13 @@ impl Node for ChangeIdentificationNode {

for output_id in txouts.iter() {
let output = output_id.with(ctx.unified_storage());
let is_change = matches!(
NaiveChangeIdentificationHueristic::is_change(output),
TxOutChangeAnnotation::Change
);
let is_change = match SpendableTxConstituent::try_new(output) {
Ok(spendable) => matches!(
NaiveChangeIdentificationHeuristic::is_change(spendable),
TxOutChangeAnnotation::Change
),
Err(_) => false, // OP_RETURN outputs cannot be change
};
result.insert(*output_id, is_change);
}

Expand Down Expand Up @@ -114,10 +118,13 @@ impl Node for FingerPrintChangeIdentificationNode {
let is_change = match output.spender_txin() {
Some(spending_txin) => {
let spending_tx = spending_txin.containing_tx();
matches!(
NLockTimeChangeIdentification::is_change(output, spending_tx),
TxOutChangeAnnotation::Change
)
match SpendableTxConstituent::try_new(output) {
Ok(spendable) => matches!(
NLockTimeChangeIdentification::is_change(spendable, spending_tx),
TxOutChangeAnnotation::Change
),
Err(_) => false, // OP_RETURN outputs cannot be change
}
}
None => false, // Unspent output: not change by fingerprint
};
Expand Down
45 changes: 42 additions & 3 deletions src/crates/heuristics/src/ast/uih.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ use tx_indexer_pipeline::{
node::{Node, NodeId},
value::{TxMask, TxOutSet, TxSet},
};
use tx_indexer_primitives::unified::{AnyOutId, AnyTxId};
use tx_indexer_primitives::{
traits::abstract_types::HasScriptPubkey,
unified::{AnyOutId, AnyTxId},
};

use crate::uih::UnnecessaryInputHeuristic;

Expand Down Expand Up @@ -45,7 +48,11 @@ impl Node for UnnecessaryInputHeuristic1Node {
for tx_id in &tx_ids {
let tx = tx_id.with(ctx.unified_storage());

let outputs: Vec<_> = tx.outputs().map(|o| (o.id(), o.value())).collect();
let outputs: Vec<_> = tx
.outputs()
.filter(|o| !o.is_op_return())
Comment thread
0xZaddyy marked this conversation as resolved.
Outdated
.map(|o| (o.id(), o.value()))
.collect();
if outputs.is_empty() {
continue;
}
Expand Down Expand Up @@ -139,7 +146,7 @@ mod tests {
use tx_indexer_primitives::{
UnifiedStorage,
loose::{LooseIndexBuilder, TxId, TxOutId},
test_utils::DummyTxData,
test_utils::{DummyTxData, DummyTxOutData},
traits::abstract_types::AbstractTransaction,
unified::{AnyOutId, AnyTxId},
};
Expand Down Expand Up @@ -200,6 +207,22 @@ mod tests {
]
}

fn setup_uih1_op_return_value_zero_fixture() -> Vec<Arc<dyn AbstractTransaction + Send + Sync>>
{
vec![
Arc::new(DummyTxData::new_with_amounts(vec![100])),
Arc::new(DummyTxData::new_with_amounts(vec![200])),
Arc::new(DummyTxData::new(
vec![
DummyTxOutData::new(150, 0),
DummyTxOutData::new_with_script(0, 1, vec![0x6a, 0x04, 0xde, 0xad, 0xbe, 0xef]),
],
vec![TxOutId::new(TxId(1), 0), TxOutId::new(TxId(2), 0)],
0,
)),
]
}

fn setup_uih2_no_unnecessary_fixture() -> Vec<Arc<dyn AbstractTransaction + Send + Sync>> {
vec![
Arc::new(DummyTxData::new_with_amounts(vec![100])),
Expand Down Expand Up @@ -273,6 +296,22 @@ mod tests {
);
}

#[test]
fn test_uih1_ignores_op_return() {
let all_txs = setup_uih1_op_return_value_zero_fixture();
let ctx = Arc::new(PipelineContext::new());
let mut engine = engine_with_loose(ctx.clone(), all_txs);

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

assert!(
result.is_empty(),
"UIH1 must not classify an OP_RETURN value=0 as a change candidate"
);
}

#[test]
fn test_uih1_tie_smallest_outputs() {
let all_txs = setup_uih1_tie_fixture();
Expand Down
153 changes: 129 additions & 24 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 @@ -9,11 +9,15 @@ pub enum TxOutChangeAnnotation {
NotChange,
}

pub struct NaiveChangeIdentificationHueristic;
pub struct NaiveChangeIdentificationHeuristic;

impl NaiveChangeIdentificationHueristic {
impl NaiveChangeIdentificationHeuristic {
/// Check if a txout is change based on its containing transaction.
pub fn is_change(txout: impl TxConstituent<Handle: OutputCount>) -> TxOutChangeAnnotation {
/// Accepts only spendable outputs; the type system rules OP_RETURN out.
pub fn is_change<T>(txout: SpendableTxConstituent<T>) -> TxOutChangeAnnotation
where
T: TxConstituent<Handle: OutputCount>,
{
let tx = txout.containing_tx();
if tx.output_count() > 0 && txout.vout() == tx.output_count() - 1 {
TxOutChangeAnnotation::Change
Expand All @@ -26,10 +30,15 @@ impl NaiveChangeIdentificationHueristic {
pub struct NLockTimeChangeIdentification;

impl NLockTimeChangeIdentification {
pub fn is_change(
tx_out: impl TxConstituent<Handle: HasNLockTime>,
/// Check if a txout is change based on nLockTime comparison.
/// Accepts only spendable outputs; the type system rules OP_RETURN out.
pub fn is_change<T>(
tx_out: SpendableTxConstituent<T>,
spending_tx: impl HasNLockTime,
) -> TxOutChangeAnnotation {
) -> TxOutChangeAnnotation
where
T: TxConstituent<Handle: HasNLockTime>,
{
let containing_tx_n_locktime = tx_out.containing_tx().n_locktime();
let child_tx_n_locktime = spending_tx.n_locktime();
if containing_tx_n_locktime == 0 && child_tx_n_locktime == 0 {
Expand All @@ -55,9 +64,12 @@ impl ScriptTypesMatchingChangeIdentification {
/// This applies the address-type heuristic conservatively: mixed input
/// 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>>,
) -> TxOutChangeAnnotation {
///
/// Accepts only spendable outputs; the type system rules OP_RETURN out.
pub fn is_change<'a, T>(tx_out: SpendableTxConstituent<T>) -> TxOutChangeAnnotation
where
T: TxConstituent<Handle = TxHandle<'a>>,
{
let tx = tx_out.containing_tx();
let mut input_types = tx.inputs().map(|input| input.output_type());

Expand All @@ -74,13 +86,19 @@ impl ScriptTypesMatchingChangeIdentification {
return TxOutChangeAnnotation::NotChange;
}

let matching_outputs: Vec<usize> = tx
let mut matching = tx
.outputs()
.enumerate()
.filter_map(|(index, output)| (output.output_type() == input_type).then_some(index))
.collect();
.filter_map(|o| SpendableTxConstituent::try_new(o).ok())
Comment thread
0xZaddyy marked this conversation as resolved.
Outdated
.filter(|spendable| spendable.output_type() == input_type);

let Some(only) = matching.next() else {
return TxOutChangeAnnotation::NotChange;
};
if matching.next().is_some() {
return TxOutChangeAnnotation::NotChange;
}

if matching_outputs.len() == 1 && matching_outputs[0] == tx_out.vout() {
if only.vout() as usize == tx_out.vout() {
TxOutChangeAnnotation::Change
} else {
TxOutChangeAnnotation::NotChange
Expand Down Expand Up @@ -125,8 +143,9 @@ mod tests {
vout: 0,
containing_tx: DummyTxData::new_with_amounts(vec![100]),
};
let spendable = SpendableTxConstituent::try_new(txout).ok().unwrap();
assert_eq!(
NaiveChangeIdentificationHueristic::is_change(txout),
NaiveChangeIdentificationHeuristic::is_change(spendable),
TxOutChangeAnnotation::Change
);
}
Expand All @@ -138,8 +157,9 @@ mod tests {
containing_tx: DummyTxData::new_with_amounts(vec![100]),
};
let spending_tx = DummyTxData::new_with_amounts(vec![100]);
let spendable = SpendableTxConstituent::try_new(tx_out).ok().unwrap();
assert_eq!(
NLockTimeChangeIdentification::is_change(tx_out, spending_tx),
NLockTimeChangeIdentification::is_change(spendable, spending_tx),
TxOutChangeAnnotation::NotChange
);

Expand All @@ -149,8 +169,9 @@ mod tests {
containing_tx: DummyTxData::new(vec![DummyTxOutData::new(100, 0)], vec![], 1),
};
let spending_tx = DummyTxData::new(vec![DummyTxOutData::new(100, 0)], vec![], 1);
let spendable = SpendableTxConstituent::try_new(tx_out).ok().unwrap();
assert_eq!(
NLockTimeChangeIdentification::is_change(tx_out, spending_tx),
NLockTimeChangeIdentification::is_change(spendable, spending_tx),
TxOutChangeAnnotation::Change
);
}
Expand Down Expand Up @@ -195,11 +216,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).ok().unwrap()
Comment thread
0xZaddyy marked this conversation as resolved.
Outdated
),
TxOutChangeAnnotation::NotChange
);
assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(change),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(change).ok().unwrap()
),
TxOutChangeAnnotation::Change
);
}
Expand Down Expand Up @@ -244,15 +269,91 @@ 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).ok().unwrap()
),
TxOutChangeAnnotation::NotChange
);
assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(change),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(change).ok().unwrap()
),
TxOutChangeAnnotation::NotChange
);
}

#[test]
fn test_script_types_matching_excludes_op_return() {
let storage = storage_from_loose_txs(vec![
// inputs: P2PKH
DummyTxData::new_with_outputs(vec![DummyTxOutData::new_with_script(
100,
0,
script_from_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
)]),
DummyTxData::new_with_outputs(vec![DummyTxOutData::new_with_script(
150,
0,
script_from_address("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"),
)]),
DummyTxData::new(
vec![
// OP_RETURN output - should never be considered change
DummyTxOutData::new_with_script(
0,
0,
vec![0x6a, 0x04, 0x48, 0x65, 0x6c, 0x6c], // OP_RETURN "Hell"
),
// P2PKH output - the only spendable P2PKH, so it's change
DummyTxOutData::new_with_script(
249,
1,
script_from_address("1BoatSLRHtKNngkdXEeobR76b53LETtpyT"),
),
],
vec![TxOutId::new(TxId(1), 0), TxOutId::new(TxId(2), 0)],
0,
),
]);

let op_return_output = AnyOutId::from(TxOutId::new(TxId(3), 0)).with(&storage);
let p2pkh_output = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);

// OP_RETURN cannot be wrapped in SpendableTxConstituent -- type system rejects it
assert!(SpendableTxConstituent::try_new(op_return_output).is_err());

// P2PKH output is change since it's the only spendable output matching input type
assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(p2pkh_output).ok().unwrap()
),
TxOutChangeAnnotation::Change
);
}

#[test]
fn test_op_return_cannot_be_wrapped() {
// The type system enforces OP_RETURN exclusion at the wrapper boundary,
// so heuristics never have to check for it themselves.
let op_return_script = vec![0x6a, 0x04, 0x48, 0x65, 0x6c, 0x6c]; // OP_RETURN "Hell"
let txout_op_return = DummyTxOut {
vout: 1,
containing_tx: DummyTxData::new(
vec![
DummyTxOutData::new_with_script(
100,
0,
script_from_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
),
DummyTxOutData::new_with_script(0, 1, op_return_script),
],
vec![],
0,
),
};
assert!(SpendableTxConstituent::try_new(txout_op_return).is_err());
}

#[test]
fn test_script_types_matching_requires_unique_output_match() {
let storage = storage_from_loose_txs(vec![
Expand Down Expand Up @@ -290,11 +391,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).ok().unwrap()
),
TxOutChangeAnnotation::NotChange
);
assert_eq!(
ScriptTypesMatchingChangeIdentification::is_change(output1),
ScriptTypesMatchingChangeIdentification::is_change(
SpendableTxConstituent::try_new(output1).ok().unwrap()
),
TxOutChangeAnnotation::NotChange
);
}
Expand Down
Loading
Loading