Skip to content

Commit 2b056a1

Browse files
author
bitcoin-rs
committed
feat(wallet): descriptors + PSBT v2 + bdk_coin_select + fee bump + NO signing surface
Op: extend
1 parent 24b1b89 commit 2b056a1

16 files changed

Lines changed: 1231 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ env:
1414
PORTABLE_FEATURES: "rocksdb,fjall,redb"
1515

1616
jobs:
17+
wallet-no-seckey:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
- run: |
22+
! grep -r 'SecretKey\|secp256k1::Secret\|seckey' crates/wallet/src
23+
1724
fmt:
1825
runs-on: ubuntu-latest
1926
steps:

crates/wallet/Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,32 @@ description = "bitcoin-rs :: wallet"
1212
workspace = true
1313

1414
[dependencies]
15+
bitcoin-rs-primitives.workspace = true
16+
bitcoin-rs-script.workspace = true
17+
bitcoin-rs-index.workspace = true
18+
bitcoin-rs-storage.workspace = true
19+
bitcoin.workspace = true
20+
miniscript.workspace = true
21+
bdk_coin_select.workspace = true
22+
secp256k1 = { workspace = true, default-features = false, features = ["std", "alloc"] }
23+
parking_lot.workspace = true
24+
arc-swap.workspace = true
25+
hashbrown.workspace = true
26+
tinyvec.workspace = true
27+
smallvec.workspace = true
28+
thiserror.workspace = true
29+
tracing.workspace = true
30+
serde.workspace = true
31+
serde_json.workspace = true
32+
33+
[dev-dependencies]
34+
bitcoin-rs-consensus.workspace = true
35+
proptest.workspace = true
36+
tempfile = "3"
37+
38+
[features]
39+
default = []
40+
rocksdb = ["bitcoin-rs-storage/rocksdb"]
41+
fjall = ["bitcoin-rs-storage/fjall"]
42+
redb = ["bitcoin-rs-storage/redb"]
43+
mdbx = ["bitcoin-rs-storage/mdbx"]
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
use bdk_coin_select::metrics::{Changeless, LowestFee};
2+
use bdk_coin_select::{
3+
Candidate as BdkCandidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate,
4+
Target as BdkTarget, TargetFee, TargetOutputs,
5+
};
6+
use serde::{Deserialize, Serialize};
7+
8+
use crate::WalletError;
9+
10+
/// Candidate input for wallet coin selection.
11+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
12+
pub struct Candidate {
13+
/// Candidate value in satoshis.
14+
pub value: u64,
15+
/// Estimated satisfaction weight in weight units.
16+
pub satisfaction_weight: u64,
17+
/// Whether spending this candidate uses segwit witness data.
18+
pub is_segwit: bool,
19+
}
20+
21+
impl Candidate {
22+
/// Converts into the upstream selector candidate type.
23+
#[must_use]
24+
pub fn to_bdk(self) -> BdkCandidate {
25+
BdkCandidate::new(self.value, self.satisfaction_weight, self.is_segwit)
26+
}
27+
}
28+
29+
/// Funding target for coin selection.
30+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31+
pub struct Target {
32+
/// Output value that must be funded.
33+
pub value: u64,
34+
/// Minimum absolute fee in satoshis.
35+
pub minimum_fee: u64,
36+
/// Target feerate for input-weight-aware selection.
37+
pub fee_rate: FeeRate,
38+
}
39+
40+
impl Target {
41+
/// Creates a target from value, minimum fee, and feerate.
42+
#[must_use]
43+
pub const fn new(value: u64, minimum_fee: u64, fee_rate: FeeRate) -> Self {
44+
Self {
45+
value,
46+
minimum_fee,
47+
fee_rate,
48+
}
49+
}
50+
51+
fn to_bdk(self) -> Result<BdkTarget, WalletError> {
52+
let value_sum = self
53+
.value
54+
.checked_add(self.minimum_fee)
55+
.ok_or_else(|| WalletError::Psbt("target value overflow".to_owned()))?;
56+
Ok(BdkTarget {
57+
fee: TargetFee::from_feerate(self.fee_rate),
58+
outputs: TargetOutputs {
59+
value_sum,
60+
weight_sum: 0,
61+
n_outputs: 1,
62+
},
63+
})
64+
}
65+
}
66+
67+
/// Coin selection strategy.
68+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
69+
pub enum SelectStrategy {
70+
/// Branch-and-bound changeless-first selection.
71+
BnB,
72+
/// Greedy knapsack-style selection.
73+
Knapsack,
74+
/// Waste metric selection using long-term feerate accounting.
75+
WasteMetric,
76+
}
77+
78+
/// Selected input set.
79+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80+
pub struct Selection {
81+
/// Indices selected from the candidate slice.
82+
pub selected_indices: Vec<usize>,
83+
/// Sum of selected candidate values.
84+
pub selected_value: u64,
85+
/// Fee implied by selected value minus target output value.
86+
pub fee: u64,
87+
}
88+
89+
/// Selects coins with the requested strategy.
90+
pub fn select_coins(
91+
target: &Target,
92+
candidates: &[Candidate],
93+
strategy: SelectStrategy,
94+
) -> Result<Selection, WalletError> {
95+
let bdk_candidates: Vec<BdkCandidate> =
96+
candidates.iter().copied().map(Candidate::to_bdk).collect();
97+
let bdk_target = target.to_bdk()?;
98+
let mut selector = CoinSelector::new(&bdk_candidates);
99+
100+
match strategy {
101+
SelectStrategy::BnB => select_bnb(&mut selector, bdk_target)?,
102+
SelectStrategy::Knapsack => select_knapsack(&mut selector, bdk_target)?,
103+
SelectStrategy::WasteMetric => select_waste(&mut selector, bdk_target)?,
104+
}
105+
106+
to_selection(&selector, target)
107+
}
108+
109+
fn select_bnb(selector: &mut CoinSelector<'_>, target: BdkTarget) -> Result<(), WalletError> {
110+
let change_policy = ChangePolicy::min_value(DrainWeights::TR_KEYSPEND, 0);
111+
selector.sort_candidates_by_descending_value_pwu();
112+
let metric = Changeless {
113+
target,
114+
change_policy,
115+
};
116+
if selector.run_bnb(metric, 100_000).is_ok() {
117+
return Ok(());
118+
}
119+
selector
120+
.select_until_target_met(target)
121+
.map_err(|error| WalletError::InsufficientFunds {
122+
missing: error.missing,
123+
})
124+
}
125+
126+
fn select_knapsack(selector: &mut CoinSelector<'_>, target: BdkTarget) -> Result<(), WalletError> {
127+
selector.sort_candidates_by_key(|(_index, candidate)| core::cmp::Reverse(candidate.value));
128+
selector
129+
.select_until_target_met(target)
130+
.map_err(|error| WalletError::InsufficientFunds {
131+
missing: error.missing,
132+
})
133+
}
134+
135+
fn select_waste(selector: &mut CoinSelector<'_>, target: BdkTarget) -> Result<(), WalletError> {
136+
selector.sort_candidates_by_descending_value_pwu();
137+
let change_policy = ChangePolicy::min_value_and_waste(
138+
DrainWeights::TR_KEYSPEND,
139+
0,
140+
target.fee.rate,
141+
FeeRate::DEFAULT_MIN_RELAY,
142+
);
143+
let metric = LowestFee {
144+
target,
145+
long_term_feerate: FeeRate::DEFAULT_MIN_RELAY,
146+
change_policy,
147+
};
148+
if let Err(error) = selector.run_bnb(metric, 100_000) {
149+
selector.select_until_target_met(target).map_err(|funds| {
150+
WalletError::InsufficientFunds {
151+
missing: funds.missing,
152+
}
153+
})?;
154+
if !selector.is_target_met(target) {
155+
return Err(WalletError::NoBnbSolution {
156+
rounds: error.rounds,
157+
max_rounds: error.max_rounds,
158+
});
159+
}
160+
}
161+
Ok(())
162+
}
163+
164+
fn to_selection(selector: &CoinSelector<'_>, target: &Target) -> Result<Selection, WalletError> {
165+
let selected_value = selector.selected_value();
166+
let target_with_fee = target
167+
.value
168+
.checked_add(target.minimum_fee)
169+
.ok_or_else(|| WalletError::Psbt("target value overflow".to_owned()))?;
170+
if selected_value < target_with_fee {
171+
return Err(WalletError::InsufficientFunds {
172+
missing: target_with_fee - selected_value,
173+
});
174+
}
175+
Ok(Selection {
176+
selected_indices: selector.selected_indices().iter().copied().collect(),
177+
selected_value,
178+
fee: selected_value - target.value,
179+
})
180+
}

crates/wallet/src/descriptor.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
use core::str::FromStr;
2+
3+
use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint};
4+
use bitcoin::{Address, Network, PublicKey};
5+
use miniscript::Descriptor as MiniscriptDescriptor;
6+
use miniscript::descriptor::DescriptorType;
7+
use serde::{Deserialize, Serialize};
8+
9+
use crate::WalletError;
10+
11+
/// Public BIP32 origin metadata attached to descriptor keys.
12+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13+
pub struct BIP32Derivation {
14+
/// Master key fingerprint for the origin key, when known.
15+
pub fingerprint: Option<Fingerprint>,
16+
/// Non-hardened public derivation path, when known.
17+
pub path: DerivationPath,
18+
}
19+
20+
impl BIP32Derivation {
21+
/// Returns a copy with `index` appended as a normal child number.
22+
pub fn with_child(&self, index: u32) -> Result<Self, WalletError> {
23+
let child = ChildNumber::from_normal_idx(index)
24+
.map_err(|error| WalletError::Descriptor(error.to_string()))?;
25+
let mut children: Vec<ChildNumber> = self.path.into_iter().copied().collect();
26+
children.push(child);
27+
Ok(Self {
28+
fingerprint: self.fingerprint,
29+
path: DerivationPath::from(children),
30+
})
31+
}
32+
}
33+
34+
/// Public, watch-only output descriptor.
35+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36+
pub struct Descriptor {
37+
/// Parsed miniscript descriptor with public keys only.
38+
pub inner: MiniscriptDescriptor<PublicKey>,
39+
/// Public BIP32 derivation metadata.
40+
pub derivation: BIP32Derivation,
41+
}
42+
43+
impl Descriptor {
44+
/// Parses one supported public descriptor form.
45+
pub fn parse(text: &str) -> Result<Self, WalletError> {
46+
let inner = MiniscriptDescriptor::<PublicKey>::from_str(text)
47+
.map_err(|error| WalletError::Descriptor(error.to_string()))?;
48+
ensure_supported(&inner)?;
49+
Ok(Self {
50+
inner,
51+
derivation: BIP32Derivation::default(),
52+
})
53+
}
54+
55+
/// Derives the receive address for a descriptor index.
56+
pub fn derive_address(&self, network: Network, index: u32) -> Result<Address, WalletError> {
57+
let _derivation = self.derivation.with_child(index)?;
58+
self.inner
59+
.address(network)
60+
.map_err(|error| WalletError::Descriptor(error.to_string()))
61+
}
62+
63+
/// Returns the descriptor script pubkey.
64+
#[must_use]
65+
pub fn script_pubkey(&self) -> bitcoin::ScriptBuf {
66+
self.inner.script_pubkey()
67+
}
68+
}
69+
70+
impl FromStr for Descriptor {
71+
type Err = WalletError;
72+
73+
fn from_str(text: &str) -> Result<Self, Self::Err> {
74+
Self::parse(text)
75+
}
76+
}
77+
78+
fn ensure_supported(descriptor: &MiniscriptDescriptor<PublicKey>) -> Result<(), WalletError> {
79+
match descriptor.desc_type() {
80+
DescriptorType::Pkh
81+
| DescriptorType::Wpkh
82+
| DescriptorType::ShWpkh
83+
| DescriptorType::Wsh
84+
| DescriptorType::Tr => Ok(()),
85+
other => Err(WalletError::Descriptor(format!(
86+
"unsupported descriptor type {other:?}"
87+
))),
88+
}
89+
}

0 commit comments

Comments
 (0)