Skip to content

Commit f88d27f

Browse files
committed
generalize spendability check via is_spendable() trait method
1 parent 486f60d commit f88d27f

6 files changed

Lines changed: 113 additions & 117 deletions

File tree

src/crates/heuristics/src/ast/uih.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
77
use std::collections::HashSet;
88

9+
use bitcoin::Amount;
910
use tx_indexer_pipeline::{
1011
engine::EvalContext,
1112
expr::Expr,
1213
node::{Node, NodeId},
1314
value::{TxMask, TxOutSet, TxSet},
1415
};
1516
use tx_indexer_primitives::{
16-
traits::abstract_types::HasScriptPubkey,
17+
traits::abstract_types::{EnumerateInputValueInArbitraryOrder, HasScriptPubkey},
1718
unified::{AnyOutId, AnyTxId},
1819
};
1920

@@ -50,14 +51,18 @@ impl Node for UnnecessaryInputHeuristic1Node {
5051

5152
let outputs: Vec<_> = tx
5253
.outputs()
53-
.filter(|o| !o.is_op_return())
54+
.filter(|o| o.is_spendable())
5455
.map(|o| (o.id(), o.value()))
5556
.collect();
56-
if outputs.is_empty() {
57+
let Some(min_out) = outputs.iter().map(|(_, v)| *v).min() else {
5758
continue;
58-
}
59+
};
60+
let Some(min_in) = tx.input_values().min() else {
61+
continue;
62+
};
5963

60-
if let Some(min_out) = UnnecessaryInputHeuristic::uih1_min_output_value(&tx) {
64+
if let Some(min_out) = UnnecessaryInputHeuristic::uih1_min_output_value(min_in, min_out)
65+
{
6166
for (out_id, v) in &outputs {
6267
if *v == min_out {
6368
result.insert(*out_id);
@@ -115,7 +120,24 @@ impl Node for UnnecessaryInputHeuristic2Node {
115120
for tx_id in &tx_ids {
116121
let tx = tx_id.with(ctx.unified_storage());
117122

118-
result.insert(*tx_id, UnnecessaryInputHeuristic::is_uih2(&tx));
123+
let input_values: Vec<Amount> = tx.input_values().collect();
124+
let output_values: Vec<Amount> = tx
125+
.outputs()
126+
.filter(|o| o.is_spendable())
127+
.map(|o| o.value())
128+
.collect();
129+
130+
let flagged = if input_values.len() < 2 || output_values.is_empty() {
131+
false
132+
} else {
133+
let sum_in = input_values.iter().copied().sum();
134+
let min_in = input_values.iter().copied().min().expect("len >= 2");
135+
let sum_out = output_values.iter().copied().sum();
136+
let min_out = output_values.iter().copied().min().expect("non-empty");
137+
UnnecessaryInputHeuristic::is_uih2(sum_in, min_in, sum_out, min_out)
138+
};
139+
140+
result.insert(*tx_id, flagged);
119141
}
120142

121143
result

src/crates/heuristics/src/change_identification.rs

Lines changed: 25 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,18 @@ impl ScriptTypesMatchingChangeIdentification {
8686
return TxOutChangeAnnotation::NotChange;
8787
}
8888

89-
let mut matching = tx
89+
let matching_indices: Vec<usize> = tx
9090
.outputs()
91-
.filter_map(|o| SpendableTxConstituent::try_new(o).ok())
92-
.filter(|spendable| spendable.output_type() == input_type);
91+
.enumerate()
92+
.filter_map(|(index, output)| (output.output_type() == input_type).then_some(index))
93+
.collect();
9394

94-
let Some(only) = matching.next() else {
95-
return TxOutChangeAnnotation::NotChange;
96-
};
97-
if matching.next().is_some() {
95+
// There must be exactly one matching output for it to be considered change
96+
if matching_indices.len() != 1 {
9897
return TxOutChangeAnnotation::NotChange;
9998
}
10099

101-
if only.vout() as usize == tx_out.vout() {
100+
if matching_indices[0] == tx_out.vout() {
102101
TxOutChangeAnnotation::Change
103102
} else {
104103
TxOutChangeAnnotation::NotChange
@@ -114,7 +113,7 @@ mod tests {
114113
UnifiedStorage,
115114
loose::LooseIndexBuilder,
116115
loose::{TxId, TxOutId},
117-
test_utils::{DummyTxData, DummyTxOut, DummyTxOutData},
116+
test_utils::{DUMMY_UNSPENDABLE_SCRIPT, DummyTxData, DummyTxOut, DummyTxOutData},
118117
unified::AnyOutId,
119118
};
120119

@@ -143,7 +142,7 @@ mod tests {
143142
vout: 0,
144143
containing_tx: DummyTxData::new_with_amounts(vec![100]),
145144
};
146-
let spendable = SpendableTxConstituent::try_new(txout).ok().unwrap();
145+
let spendable: SpendableTxConstituent<_> = txout.try_into().unwrap();
147146
assert_eq!(
148147
NaiveChangeIdentificationHeuristic::is_change(spendable),
149148
TxOutChangeAnnotation::Change
@@ -157,7 +156,7 @@ mod tests {
157156
containing_tx: DummyTxData::new_with_amounts(vec![100]),
158157
};
159158
let spending_tx = DummyTxData::new_with_amounts(vec![100]);
160-
let spendable = SpendableTxConstituent::try_new(tx_out).ok().unwrap();
159+
let spendable: SpendableTxConstituent<_> = tx_out.try_into().unwrap();
161160
assert_eq!(
162161
NLockTimeChangeIdentification::is_change(spendable, spending_tx),
163162
TxOutChangeAnnotation::NotChange
@@ -169,7 +168,7 @@ mod tests {
169168
containing_tx: DummyTxData::new(vec![DummyTxOutData::new(100, 0)], vec![], 1),
170169
};
171170
let spending_tx = DummyTxData::new(vec![DummyTxOutData::new(100, 0)], vec![], 1);
172-
let spendable = SpendableTxConstituent::try_new(tx_out).ok().unwrap();
171+
let spendable: SpendableTxConstituent<_> = tx_out.try_into().unwrap();
173172
assert_eq!(
174173
NLockTimeChangeIdentification::is_change(spendable, spending_tx),
175174
TxOutChangeAnnotation::Change
@@ -216,15 +215,11 @@ mod tests {
216215
let change = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);
217216

218217
assert_eq!(
219-
ScriptTypesMatchingChangeIdentification::is_change(
220-
SpendableTxConstituent::try_new(payment).ok().unwrap()
221-
),
218+
ScriptTypesMatchingChangeIdentification::is_change(payment.try_into().unwrap()),
222219
TxOutChangeAnnotation::NotChange
223220
);
224221
assert_eq!(
225-
ScriptTypesMatchingChangeIdentification::is_change(
226-
SpendableTxConstituent::try_new(change).ok().unwrap()
227-
),
222+
ScriptTypesMatchingChangeIdentification::is_change(change.try_into().unwrap()),
228223
TxOutChangeAnnotation::Change
229224
);
230225
}
@@ -269,15 +264,11 @@ mod tests {
269264
let change = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);
270265

271266
assert_eq!(
272-
ScriptTypesMatchingChangeIdentification::is_change(
273-
SpendableTxConstituent::try_new(payment).ok().unwrap()
274-
),
267+
ScriptTypesMatchingChangeIdentification::is_change(payment.try_into().unwrap()),
275268
TxOutChangeAnnotation::NotChange
276269
);
277270
assert_eq!(
278-
ScriptTypesMatchingChangeIdentification::is_change(
279-
SpendableTxConstituent::try_new(change).ok().unwrap()
280-
),
271+
ScriptTypesMatchingChangeIdentification::is_change(change.try_into().unwrap()),
281272
TxOutChangeAnnotation::NotChange
282273
);
283274
}
@@ -299,11 +290,7 @@ mod tests {
299290
DummyTxData::new(
300291
vec![
301292
// OP_RETURN output - should never be considered change
302-
DummyTxOutData::new_with_script(
303-
0,
304-
0,
305-
vec![0x6a, 0x04, 0x48, 0x65, 0x6c, 0x6c], // OP_RETURN "Hell"
306-
),
293+
DummyTxOutData::new_with_script(0, 0, vec![0x6a, 0x04, 0xde, 0xad, 0xbe, 0xef]),
307294
// P2PKH output - the only spendable P2PKH, so it's change
308295
DummyTxOutData::new_with_script(
309296
249,
@@ -320,23 +307,20 @@ mod tests {
320307
let p2pkh_output = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);
321308

322309
// OP_RETURN cannot be wrapped in SpendableTxConstituent -- type system rejects it
323-
assert!(SpendableTxConstituent::try_new(op_return_output).is_err());
310+
assert!(TryInto::<SpendableTxConstituent<_>>::try_into(op_return_output).is_err());
324311

325312
// P2PKH output is change since it's the only spendable output matching input type
326313
assert_eq!(
327-
ScriptTypesMatchingChangeIdentification::is_change(
328-
SpendableTxConstituent::try_new(p2pkh_output).ok().unwrap()
329-
),
314+
ScriptTypesMatchingChangeIdentification::is_change(p2pkh_output.try_into().unwrap()),
330315
TxOutChangeAnnotation::Change
331316
);
332317
}
333318

334319
#[test]
335-
fn test_op_return_cannot_be_wrapped() {
336-
// The type system enforces OP_RETURN exclusion at the wrapper boundary,
320+
fn test_unspendable_cannot_be_wrapped() {
321+
// The type system enforces unspendable output exclusion at the wrapper boundary,
337322
// so heuristics never have to check for it themselves.
338-
let op_return_script = vec![0x6a, 0x04, 0x48, 0x65, 0x6c, 0x6c]; // OP_RETURN "Hell"
339-
let txout_op_return = DummyTxOut {
323+
let txout_unspendable = DummyTxOut {
340324
vout: 1,
341325
containing_tx: DummyTxData::new(
342326
vec![
@@ -345,13 +329,13 @@ mod tests {
345329
0,
346330
script_from_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
347331
),
348-
DummyTxOutData::new_with_script(0, 1, op_return_script),
332+
DummyTxOutData::new_with_script(0, 1, DUMMY_UNSPENDABLE_SCRIPT),
349333
],
350334
vec![],
351335
0,
352336
),
353337
};
354-
assert!(SpendableTxConstituent::try_new(txout_op_return).is_err());
338+
assert!(TryInto::<SpendableTxConstituent<_>>::try_into(txout_unspendable).is_err());
355339
}
356340

357341
#[test]
@@ -391,15 +375,11 @@ mod tests {
391375
let output1 = AnyOutId::from(TxOutId::new(TxId(3), 1)).with(&storage);
392376

393377
assert_eq!(
394-
ScriptTypesMatchingChangeIdentification::is_change(
395-
SpendableTxConstituent::try_new(output0).ok().unwrap()
396-
),
378+
ScriptTypesMatchingChangeIdentification::is_change(output0.try_into().unwrap()),
397379
TxOutChangeAnnotation::NotChange
398380
);
399381
assert_eq!(
400-
ScriptTypesMatchingChangeIdentification::is_change(
401-
SpendableTxConstituent::try_new(output1).ok().unwrap()
402-
),
382+
ScriptTypesMatchingChangeIdentification::is_change(output1.try_into().unwrap()),
403383
TxOutChangeAnnotation::NotChange
404384
);
405385
}

src/crates/heuristics/src/uih.rs

Lines changed: 10 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,20 @@
11
use bitcoin::Amount;
2-
use tx_indexer_primitives::traits::abstract_types::{
3-
AbstractTransaction, EnumerateInputValueInArbitraryOrder,
4-
};
52

63
pub struct UnnecessaryInputHeuristic;
74

85
impl UnnecessaryInputHeuristic {
9-
/// Minimum output value among spendable outputs, when it is less than the
10-
/// minimum input value. OP_RETURN outputs are excluded since they cannot be
11-
/// spent and including them (often value=0) would always trip UIH1.
12-
pub fn uih1_min_output_value<T>(tx: &T) -> Option<Amount>
13-
where
14-
T: EnumerateInputValueInArbitraryOrder + AbstractTransaction,
15-
{
16-
let input_values: Vec<Amount> = tx.input_values().collect();
17-
let output_values: Vec<Amount> = tx
18-
.outputs()
19-
.filter(|o| !o.is_op_return())
20-
.map(|o| o.value())
21-
.collect();
22-
23-
if input_values.is_empty() || output_values.is_empty() {
24-
return None;
25-
}
26-
27-
let min_in = input_values
28-
.iter()
29-
.min()
30-
.copied()
31-
.expect("non-empty inputs");
32-
let min_out = output_values
33-
.iter()
34-
.min()
35-
.copied()
36-
.expect("non-empty outputs");
37-
38-
if min_out < min_in {
39-
Some(min_out)
40-
} else {
41-
None
42-
}
6+
/// UIH1 (Optimal change): the smallest output is likely change when it is
7+
/// strictly less than the smallest input. Returns `Some(min_out)` in that
8+
/// case. Caller is responsible for excluding unspendable outputs from
9+
/// `min_out` — including them (often value=0) would always trip UIH1.
10+
pub fn uih1_min_output_value(min_in: Amount, min_out: Amount) -> Option<Amount> {
11+
(min_out < min_in).then_some(min_out)
4312
}
4413

45-
pub fn is_uih2<T>(tx: &T) -> bool
46-
where
47-
T: EnumerateInputValueInArbitraryOrder + AbstractTransaction,
48-
{
49-
let input_values: Vec<Amount> = tx.input_values().collect();
50-
let output_values: Vec<Amount> = tx
51-
.outputs()
52-
.filter(|o| !o.is_op_return())
53-
.map(|o| o.value())
54-
.collect();
55-
56-
if input_values.len() < 2 || output_values.is_empty() {
57-
return false;
58-
}
59-
60-
let sum_in = input_values.iter().fold(Amount::from_sat(0), |a, b| a + *b);
61-
let min_in = input_values.iter().min().copied().expect("len >= 2");
62-
let sum_out = output_values
63-
.iter()
64-
.fold(Amount::from_sat(0), |a, b| a + *b);
65-
let min_out = output_values.iter().min().copied().expect("non-empty");
66-
14+
/// UIH2 (Unnecessary input): the largest output could be paid without the
15+
/// smallest input. Caller is responsible for excluding unspendable outputs
16+
/// from the sum/min and ensuring there are at least two inputs.
17+
pub fn is_uih2(sum_in: Amount, min_in: Amount, sum_out: Amount, min_out: Amount) -> bool {
6718
(sum_in - min_in) >= (sum_out - min_out)
6819
}
6920
}

src/crates/primitives/src/handle.rs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ pub struct TxOutHandle<'a> {
6565
pub(crate) index: &'a dyn IndexedGraph,
6666
}
6767

68+
impl<'a> std::fmt::Debug for TxOutHandle<'a> {
69+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70+
f.debug_struct("TxOutHandle")
71+
.field("out_id", &self.out_id)
72+
.finish()
73+
}
74+
}
75+
6876
impl<'a> TxOutHandle<'a> {
6977
pub fn id(&self) -> AnyOutId {
7078
self.out_id
@@ -316,22 +324,28 @@ impl<'a> TxConstituent for TxOutHandle<'a> {
316324
///
317325
/// Construct via [`SpendableTxConstituent::try_new`]: `Ok` for spendable outputs,
318326
/// `Err` returns the original value back so callers can handle the unspendable
319-
/// case explicitly. Heuristics that consume `SpendableTxConstituent<T>` no
320-
/// longer need their own OP_RETURN check — the type system enforces it at the
321-
/// call boundary.
327+
/// case explicitly.
322328
pub struct SpendableTxConstituent<T>(T);
323329

324330
impl<T: HasScriptPubkey> SpendableTxConstituent<T> {
325-
/// Wraps `value` if it is spendable; returns it back as `Err` if OP_RETURN.
331+
/// Wraps `value` if it is spendable; returns it back as `Err` if unspendable.
326332
pub fn try_new(value: T) -> Result<Self, T> {
327-
if value.is_op_return() {
328-
Err(value)
329-
} else {
333+
if value.is_spendable() {
330334
Ok(Self(value))
335+
} else {
336+
Err(value)
331337
}
332338
}
333339
}
334340

341+
impl<'a> TryFrom<TxOutHandle<'a>> for SpendableTxConstituent<TxOutHandle<'a>> {
342+
type Error = TxOutHandle<'a>;
343+
344+
fn try_from(value: TxOutHandle<'a>) -> Result<Self, Self::Error> {
345+
Self::try_new(value)
346+
}
347+
}
348+
335349
impl<T> SpendableTxConstituent<T> {
336350
pub fn into_inner(self) -> T {
337351
self.0

0 commit comments

Comments
 (0)