Skip to content

Commit 4250de1

Browse files
committed
feat!: More progress
* Added `CoinControl` which handles our canonical set, filtering and grouping. * Added `InputCandidates` which contains inputs available for coin selection, with the ability to mark inputs as "must select". * Added `tests/synopsis` to see what it will look like with everything put together. * Changed `Input` so that it can be constructed from a PSBT Input. Also added more helper methods so that data such as "is spendable now" can be obtained from the input. * Added `Selection` which represents the final coin selection result. * Added `Selector` which contains the `bdk_coin_select::CoinSelector`. This allows flexible selections. * Added `RbfSet` to contain RBF logic. * Updated `tests/synopsis.rs` to show mempool-policy-conforming tx-cancellation.
1 parent 3a5a67e commit 4250de1

File tree

11 files changed

+1904
-258
lines changed

11 files changed

+1904
-258
lines changed

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ readme = "README.md"
1111
[dependencies]
1212
miniscript = { version = "12", default-features = false }
1313
bdk_coin_select = "0.4.0"
14+
bdk_chain = { version = "0.21" }
1415

1516
[dev-dependencies]
1617
anyhow = "1"
17-
bdk_chain = { version = "0.21" }
1818
bdk_tx = { path = "." }
1919
bitcoin = { version = "0.32", features = ["rand-std"] }
20+
bdk_testenv = "0.11.1"
21+
bdk_bitcoind_rpc = "0.18.0"
2022

2123
[features]
2224
default = ["std"]

src/coin_control.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use core::{convert::Infallible, fmt::Display};
2+
3+
use crate::{
4+
collections::{BTreeMap, HashMap, HashSet},
5+
Input, InputCandidates, InputGroup, InputStatus,
6+
};
7+
use alloc::vec::Vec;
8+
use bdk_chain::{BlockId, ChainOracle, ConfirmationBlockTime, TxGraph};
9+
use bitcoin::{absolute, OutPoint, Txid};
10+
use miniscript::{bitcoin, plan::Plan};
11+
12+
/// Coin control.
13+
///
14+
/// Builds the set of input candidates.
15+
/// Tries to ensure that all candidates are part of a consistent view of history.
16+
///
17+
/// Does not check ownership of coins before placing them in candidate set.
18+
#[must_use]
19+
pub struct CoinControl<'g, C> {
20+
/// Chain Oracle.
21+
chain: &'g C,
22+
/// Consistent chain tip.
23+
chain_tip: BlockId,
24+
25+
/// Tx graph.
26+
tx_graph: &'g TxGraph<ConfirmationBlockTime>,
27+
/// Stops the caller from adding inputs (local or foreign) that are definitely not canonical.
28+
///
29+
/// This is not a perfect check for callers that add foreign inputs, or if the caller's
30+
/// `TxGraph` has incomplete information. However, this will stop most unintended double-spends
31+
/// and/or money-printing-txs.
32+
canonical: HashSet<Txid>,
33+
34+
/// All candidates.
35+
candidate_inputs: HashMap<OutPoint, Input>,
36+
///// Maintains candidate order.
37+
//pub order: VecDeque<OutPoint>,
38+
/// Excluded stuff goes here.
39+
excluded_inputs: HashMap<OutPoint, ExcludeInputReason>,
40+
}
41+
42+
/// ExcludedReason.
43+
#[derive(Debug, Clone)]
44+
pub enum ExcludeInputReason {
45+
/// Cannot find outpoint in the graph.
46+
DoesNotExist,
47+
/// Input already spent.
48+
AlreadySpent,
49+
/// Input spends from an output that is not canonical.
50+
NotCanonical,
51+
}
52+
53+
impl Display for ExcludeInputReason {
54+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
55+
match self {
56+
ExcludeInputReason::DoesNotExist => {
57+
write!(f, "outpoint does not exist")
58+
}
59+
ExcludeInputReason::AlreadySpent => {
60+
write!(f, "including this input is a double spend")
61+
}
62+
ExcludeInputReason::NotCanonical => {
63+
write!(f, "outpoint is in tx that is not canonical")
64+
}
65+
}
66+
}
67+
}
68+
69+
#[cfg(feature = "std")]
70+
impl std::error::Error for ExcludeInputReason {}
71+
72+
impl<'g, C: ChainOracle<Error = Infallible>> CoinControl<'g, C> {
73+
/// New
74+
///
75+
/// TODO: Replace `to_exclude` with `CanonicalizationParams` when that is available.
76+
pub fn new(
77+
tx_graph: &'g TxGraph<ConfirmationBlockTime>,
78+
chain: &'g C,
79+
chain_tip: BlockId,
80+
replace: impl IntoIterator<Item = Txid>,
81+
) -> Self {
82+
let mut canonical = tx_graph
83+
.canonical_iter(chain, chain_tip)
84+
.map(|r| r.expect("infallible"))
85+
.flat_map(|(txid, tx, _)| {
86+
tx.input
87+
.iter()
88+
.map(|txin| txin.previous_output.txid)
89+
.chain([txid])
90+
.collect::<Vec<_>>()
91+
})
92+
.collect::<HashSet<Txid>>();
93+
let exclude = replace
94+
.into_iter()
95+
.filter_map(|txid| tx_graph.get_tx(txid))
96+
.flat_map(|tx| {
97+
let txid = tx.compute_txid();
98+
tx_graph
99+
.walk_descendants(txid, move |_, txid| Some(txid))
100+
.chain(core::iter::once(txid))
101+
.collect::<Vec<_>>()
102+
});
103+
for txid in exclude {
104+
canonical.remove(&txid);
105+
}
106+
Self {
107+
tx_graph,
108+
chain,
109+
canonical,
110+
chain_tip,
111+
candidate_inputs: HashMap::new(),
112+
excluded_inputs: HashMap::new(),
113+
}
114+
}
115+
116+
/// Try include the given input.
117+
pub fn try_include_input(&mut self, outpoint: OutPoint, plan: Plan) -> &mut Self {
118+
match self._try_include_input(outpoint, plan) {
119+
Ok(_) => self.excluded_inputs.remove(&outpoint),
120+
Err(err) => self.excluded_inputs.insert(outpoint, err),
121+
};
122+
self
123+
}
124+
125+
/// Try include the given inputs.
126+
pub fn try_include_inputs<I>(&mut self, inputs: I) -> &mut Self
127+
where
128+
I: IntoIterator<Item = (OutPoint, Plan)>,
129+
{
130+
for (outpoint, plan) in inputs {
131+
self.try_include_input(outpoint, plan);
132+
}
133+
self
134+
}
135+
136+
fn _try_include_input(
137+
&mut self,
138+
outpoint: OutPoint,
139+
plan: Plan,
140+
) -> Result<(), ExcludeInputReason> {
141+
let tx_node = self
142+
.tx_graph
143+
.get_tx_node(outpoint.txid)
144+
.ok_or(ExcludeInputReason::DoesNotExist)?;
145+
if !self.canonical.contains(&tx_node.txid) {
146+
return Err(ExcludeInputReason::NotCanonical);
147+
}
148+
if self.is_spent(outpoint) {
149+
return Err(ExcludeInputReason::AlreadySpent);
150+
}
151+
152+
let status = tx_node
153+
.anchors
154+
.iter()
155+
.find(|anchor| {
156+
self.chain
157+
.is_block_in_chain(anchor.block_id, self.chain_tip)
158+
.expect("infallible")
159+
.unwrap_or(false)
160+
})
161+
.map(|anchor| InputStatus {
162+
height: absolute::Height::from_consensus(anchor.block_id.height)
163+
.expect("height must not overflow"),
164+
time: absolute::Time::from_consensus(anchor.confirmation_time as u32)
165+
.expect("time must not overflow"),
166+
});
167+
168+
let input = Input::from_prev_tx(
169+
plan,
170+
tx_node.tx,
171+
outpoint.vout.try_into().expect("u32 must fit into usize"),
172+
status,
173+
)
174+
.map_err(|_| ExcludeInputReason::DoesNotExist)?;
175+
176+
self.candidate_inputs.insert(outpoint, input);
177+
Ok(())
178+
}
179+
180+
/// Whether the outpoint is spent already.
181+
///
182+
/// Spent outputs cannot be candidates for coin selection.
183+
fn is_spent(&self, outpoint: OutPoint) -> bool {
184+
self.tx_graph
185+
.outspends(outpoint)
186+
.iter()
187+
.any(|txid| self.canonical.contains(txid))
188+
}
189+
190+
/// Map of excluded inputs and their exclusion reasons.
191+
pub fn excluded(&self) -> &HashMap<OutPoint, ExcludeInputReason> {
192+
&self.excluded_inputs
193+
}
194+
195+
/// Into candidates.
196+
pub fn into_candidates<G: Ord>(
197+
self,
198+
group_policy: impl Fn(&Input) -> G,
199+
filter_policy: impl Fn(&InputGroup) -> bool,
200+
) -> InputCandidates {
201+
let mut group_map = BTreeMap::<G, InputGroup>::new();
202+
for input in self.candidate_inputs.into_values() {
203+
let group_key = group_policy(&input);
204+
use std::collections::btree_map::Entry;
205+
match group_map.entry(group_key) {
206+
Entry::Vacant(entry) => {
207+
entry.insert(InputGroup::from_input(input));
208+
}
209+
Entry::Occupied(mut entry) => entry.get_mut().push(input),
210+
};
211+
}
212+
InputCandidates::new(None, group_map.into_values().filter(filter_policy))
213+
}
214+
}
215+
216+
/// Default group policy.
217+
pub fn group_by_spk() -> impl Fn(&Input) -> bitcoin::ScriptBuf {
218+
|input| input.prev_txout().script_pubkey.clone()
219+
}
220+
221+
/// No grouping.
222+
pub fn no_grouping() -> impl Fn(&Input) -> OutPoint {
223+
|input| input.prev_outpoint()
224+
}
225+
226+
/// Filter out inputs that cannot be spent now.
227+
pub fn filter_unspendable_now(
228+
tip_height: absolute::Height,
229+
tip_time: absolute::Time,
230+
) -> impl Fn(&InputGroup) -> bool {
231+
move |group| group.is_spendable_now(tip_height, tip_time)
232+
}
233+
234+
/// No filtering.
235+
pub fn no_filtering() -> impl Fn(&InputGroup) -> bool {
236+
|_| true
237+
}

src/finalizer.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ pub struct Finalizer {
99
}
1010

1111
impl Finalizer {
12-
/// Finalize a PSBT input and return whether finalization was successful.
12+
/// Create.
13+
pub fn new(plans: impl IntoIterator<Item = (OutPoint, Plan)>) -> Self {
14+
Self {
15+
plans: plans.into_iter().collect(),
16+
}
17+
}
18+
19+
/// Finalize a PSBT input and return whether finalization was successful or input was already
20+
/// finalized.
1321
///
1422
/// # Errors
1523
///
@@ -24,6 +32,16 @@ impl Finalizer {
2432
psbt: &mut Psbt,
2533
input_index: usize,
2634
) -> Result<bool, miniscript::Error> {
35+
// return true if already finalized.
36+
{
37+
let psbt_input = &psbt.inputs[input_index];
38+
if psbt_input.final_script_witness.is_some()
39+
|| psbt_input.final_script_witness.is_some()
40+
{
41+
return Ok(true);
42+
}
43+
}
44+
2745
let mut finalized = false;
2846
let outpoint = psbt
2947
.unsigned_tx

0 commit comments

Comments
 (0)