Skip to content

Commit ad19250

Browse files
committed
feat(chain): variable block confirmation for confirmed balances
1 parent a48c97a commit ad19250

File tree

4 files changed

+228
-7
lines changed

4 files changed

+228
-7
lines changed

crates/chain/src/canonical_iter.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,35 @@ type CanonicalMap<A> = HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>;
1212
type NotCanonicalSet = HashSet<Txid>;
1313

1414
/// Modifies the canonicalization algorithm.
15-
#[derive(Debug, Default, Clone)]
15+
#[derive(Debug, Clone)]
1616
pub struct CanonicalizationParams {
1717
/// Transactions that will supercede all other transactions.
1818
///
1919
/// In case of conflicting transactions within `assume_canonical`, transactions that appear
2020
/// later in the list (have higher index) have precedence.
2121
pub assume_canonical: Vec<Txid>,
22+
23+
/// Minimum confirmations for a transaction to count as confirmed.
24+
///
25+
/// A value of `Some(6)` means only transactions with 6+ confirmations will be included in the
26+
/// `ChainPosition::Confirmed` bucket of the `Balance`. `None` means default to 1-confirmation
27+
/// behavior.
28+
pub min_confirmations: Option<u32>,
29+
}
30+
31+
impl Default for CanonicalizationParams {
32+
fn default() -> Self {
33+
Self {
34+
assume_canonical: vec![],
35+
min_confirmations: Some(1),
36+
}
37+
}
2238
}
2339

2440
/// Iterates over canonical txs.
2541
pub struct CanonicalIter<'g, A, C> {
42+
params: CanonicalizationParams,
43+
2644
tx_graph: &'g TxGraph<A>,
2745
chain: &'g C,
2846
chain_tip: BlockId,
@@ -50,6 +68,7 @@ impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> {
5068
let anchors = tx_graph.all_anchors();
5169
let unprocessed_assumed_txs = Box::new(
5270
params
71+
.clone()
5372
.assume_canonical
5473
.into_iter()
5574
.rev()
@@ -66,6 +85,7 @@ impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> {
6685
.filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))),
6786
);
6887
Self {
88+
params,
6989
tx_graph,
7090
chain,
7191
chain_tip,
@@ -96,6 +116,16 @@ impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> {
96116
.chain
97117
.is_block_in_chain(anchor.anchor_block(), self.chain_tip)?;
98118
if in_chain_opt == Some(true) {
119+
if let Some(min_conf) = self.params.min_confirmations {
120+
let confirmed_depth = self
121+
.chain_tip
122+
.height
123+
.saturating_sub(anchor.anchor_block().height)
124+
+ 1;
125+
if confirmed_depth < min_conf {
126+
return Ok(());
127+
}
128+
}
99129
self.mark_canonical(txid, tx, CanonicalReason::from_anchor(anchor.clone()));
100130
return Ok(());
101131
}

crates/chain/src/tx_graph.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,13 +1284,23 @@ impl<A: Anchor> TxGraph<A> {
12841284
let mut untrusted_pending = Amount::ZERO;
12851285
let mut confirmed = Amount::ZERO;
12861286

1287-
for (spk_i, txout) in self.try_filter_chain_unspents(chain, chain_tip, params, outpoints)? {
1287+
for (spk_i, txout) in
1288+
self.try_filter_chain_unspents(chain, chain_tip, params.clone(), outpoints)?
1289+
{
12881290
match &txout.chain_position {
1289-
ChainPosition::Confirmed { .. } => {
1290-
if txout.is_confirmed_and_spendable(chain_tip.height) {
1291-
confirmed += txout.txout.value;
1292-
} else if !txout.is_mature(chain_tip.height) {
1293-
immature += txout.txout.value;
1291+
ChainPosition::Confirmed { anchor, .. } => {
1292+
let min_confs = params.min_confirmations.unwrap_or(1);
1293+
let confirmed_depth = chain_tip
1294+
.height
1295+
.saturating_sub(anchor.anchor_block().height)
1296+
+ 1;
1297+
1298+
if confirmed_depth >= min_confs {
1299+
if txout.is_confirmed_and_spendable(chain_tip.height) {
1300+
confirmed += txout.txout.value;
1301+
} else if !txout.is_mature(chain_tip.height) {
1302+
immature += txout.txout.value;
1303+
}
12941304
}
12951305
}
12961306
ChainPosition::Unconfirmed { .. } => {

crates/chain/tests/test_indexed_tx_graph.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,93 @@ fn test_get_chain_position() {
705705
.into_iter()
706706
.for_each(|t| run(&chain, &mut graph, t));
707707
}
708+
709+
#[test]
710+
fn test_balance_min_confirmations() {
711+
use bdk_chain::spk_txout::SpkTxOutIndex;
712+
713+
let spk = ScriptBuf::from_hex("0014c692ecf13534982a9a2834565cbd37add8027140").unwrap();
714+
let mut graph = IndexedTxGraph::new({
715+
let mut index = SpkTxOutIndex::default();
716+
let _ = index.insert_spk(0u32, spk.clone());
717+
index
718+
});
719+
720+
let chain =
721+
LocalChain::from_blocks((0..=15).map(|i| (i as u32, hash!("h"))).collect()).unwrap();
722+
723+
let coinbase_tx = Transaction {
724+
input: vec![TxIn {
725+
previous_output: OutPoint::null(),
726+
..Default::default()
727+
}],
728+
output: vec![TxOut {
729+
value: Amount::from_sat(70000),
730+
script_pubkey: spk.clone(),
731+
}],
732+
..new_tx(0)
733+
};
734+
735+
// Create a confirmed transaction with 6 confirmations.
736+
let tx = Transaction {
737+
input: vec![TxIn {
738+
previous_output: OutPoint::new(coinbase_tx.compute_txid(), 0),
739+
..Default::default()
740+
}],
741+
output: vec![TxOut {
742+
value: Amount::from_sat(42_000),
743+
script_pubkey: spk.clone(),
744+
}],
745+
..new_tx(1)
746+
};
747+
let txid = tx.compute_txid();
748+
749+
let _ = graph.insert_tx(tx.clone());
750+
let _ = graph.insert_anchor(
751+
txid,
752+
ConfirmationBlockTime {
753+
block_id: chain.get(10).unwrap().block_id(),
754+
confirmation_time: 123456,
755+
},
756+
);
757+
758+
let tip = chain.get(15).unwrap().block_id();
759+
let trust_predicate = |_: &(), s: ScriptBuf| s == spk;
760+
761+
let balance_at_1 = graph.graph().balance(
762+
&chain,
763+
tip,
764+
CanonicalizationParams {
765+
min_confirmations: Some(1),
766+
..Default::default()
767+
},
768+
std::iter::once(((), OutPoint::new(txid, 0))),
769+
trust_predicate,
770+
);
771+
772+
let balance_at_6 = graph.graph().balance(
773+
&chain,
774+
tip,
775+
CanonicalizationParams {
776+
min_confirmations: Some(6),
777+
..Default::default()
778+
},
779+
std::iter::once(((), OutPoint::new(txid, 0))),
780+
trust_predicate,
781+
);
782+
783+
let balance_at_7 = graph.graph().balance(
784+
&chain,
785+
tip,
786+
CanonicalizationParams {
787+
min_confirmations: Some(7),
788+
..Default::default()
789+
},
790+
std::iter::once(((), OutPoint::new(txid, 0))),
791+
trust_predicate,
792+
);
793+
794+
assert_eq!(balance_at_1.confirmed, Amount::from_sat(42_000));
795+
assert_eq!(balance_at_6.confirmed, Amount::from_sat(42_000));
796+
assert_eq!(balance_at_7.confirmed, Amount::from_sat(0));
797+
}

crates/chain/tests/test_tx_graph.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,3 +1540,94 @@ fn test_get_first_seen_of_a_tx() {
15401540
let first_seen = graph.get_tx_node(txid).unwrap().first_seen;
15411541
assert_eq!(first_seen, Some(seen_at));
15421542
}
1543+
1544+
#[test]
1545+
fn test_min_confirmations() {
1546+
// Create LocalChain with height 100.
1547+
let local_chain = LocalChain::from_blocks(
1548+
(0..=100)
1549+
.map(|ht| (ht, BlockHash::hash(format!("Block Hash {ht}").as_bytes())))
1550+
.collect(),
1551+
)
1552+
.expect("must have genesis hash");
1553+
let tip = local_chain.tip();
1554+
1555+
let mut graph = TxGraph::<ConfirmationBlockTime>::default();
1556+
1557+
// Create tx with six confirmations at height 95.
1558+
let tx_6conf = Transaction {
1559+
input: vec![],
1560+
output: vec![TxOut {
1561+
value: Amount::from_sat(10_000),
1562+
script_pubkey: ScriptBuf::new(),
1563+
}],
1564+
..new_tx(0)
1565+
};
1566+
let txid_6conf = tx_6conf.compute_txid();
1567+
let _ = graph.insert_tx(tx_6conf.clone());
1568+
let _ = graph.insert_anchor(
1569+
txid_6conf,
1570+
ConfirmationBlockTime {
1571+
block_id: tip.get(95).unwrap().block_id(),
1572+
confirmation_time: 123456,
1573+
},
1574+
);
1575+
1576+
// Create tx with two confirmations at height 99.
1577+
let tx_2conf = Transaction {
1578+
input: vec![],
1579+
output: vec![TxOut {
1580+
value: Amount::from_sat(20_000),
1581+
script_pubkey: ScriptBuf::new(),
1582+
}],
1583+
..new_tx(1)
1584+
};
1585+
let txid_2conf = tx_2conf.compute_txid();
1586+
let _ = graph.insert_tx(tx_2conf.clone());
1587+
let _ = graph.insert_anchor(
1588+
txid_2conf,
1589+
ConfirmationBlockTime {
1590+
block_id: tip.get(99).unwrap().block_id(),
1591+
confirmation_time: 123457,
1592+
},
1593+
);
1594+
1595+
let build_confirmed_txouts = |min_conf: Option<u32>| -> HashMap<OutPoint, Amount> {
1596+
graph
1597+
.filter_chain_txouts(
1598+
&local_chain,
1599+
tip.block_id(),
1600+
CanonicalizationParams {
1601+
min_confirmations: min_conf,
1602+
..Default::default()
1603+
},
1604+
graph.all_txouts().map(|(op, _)| ((), op)),
1605+
)
1606+
.filter_map(|(_, full)| {
1607+
if full.spent_by.is_none() {
1608+
Some((full.outpoint, full.txout.value))
1609+
} else {
1610+
None
1611+
}
1612+
})
1613+
.collect()
1614+
};
1615+
1616+
// Test case 1: require the default 1 confirmation.
1617+
let all = build_confirmed_txouts(None);
1618+
assert_eq!(all.len(), 2);
1619+
assert_eq!(all[&OutPoint::new(txid_6conf, 0)], Amount::from_sat(10_000));
1620+
assert_eq!(all[&OutPoint::new(txid_2conf, 0)], Amount::from_sat(20_000));
1621+
1622+
// Test case 2: require 3 confirmations, with only tx_6conf surviving.
1623+
let filtered = build_confirmed_txouts(Some(3));
1624+
assert_eq!(filtered.len(), 1);
1625+
assert_eq!(
1626+
filtered[&OutPoint::new(txid_6conf, 0)],
1627+
Amount::from_sat(10_000)
1628+
);
1629+
1630+
// Test case 3: require 7 confirmations, with no tx surviving.
1631+
let none = build_confirmed_txouts(Some(7));
1632+
assert!(none.is_empty());
1633+
}

0 commit comments

Comments
 (0)