Skip to content

Commit 6dae003

Browse files
evanlinjinclaude
andcommitted
feat: add bdk_wallet_tx bridge crate
Bridge between bdk_wallet and bdk_tx, exposed as a `WalletTxExt` extension trait on `bdk_wallet::Wallet`. Keeping it in its own crate means neither base crate depends on the other: `bdk_wallet` stays stable, `bdk_tx` stays free to move, and the bridge absorbs the coupling (the decoupling argument from bitcoindevkit/bdk_wallet#297). Three-stage pipeline: - `candidates`/`candidates_with`/`rbf_candidates` -> `CandidateSet` (stage 1). - `select` -> `(TxTemplate, Option<AddressInfo>)` (stage 2): a pure read over a borrowed `&CandidateSet` (re-runnable) that peeks the auto-derived change address; commit it with `commit_change` (reveal + mark used) when you use the tx, or ignore it on abandon. The caller supplies the RNG. Emit the PSBT directly via `bdk_tx::TxTemplate::build_psbt`. - `add_global_xpubs` fills the PSBT's global xpubs (the only emission step that needs the wallet). Ported from the bdk_wallet#502 PoC, re-expressed over the integrated bdk_tx API and the wallet's public accessors. MTP is faked from the confirmation block time; UTXO locking is honored; `longterm_feerate` lives on `SelectParams` and feeds the waste-aware change policy for every strategy. End-to-end tests cover the pipeline, candidate filtering, change commit, and error paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0010fe4 commit 6dae003

10 files changed

Lines changed: 1709 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[workspace]
2-
members = ["tx"]
2+
members = ["tx", "wallet_tx"]
33
resolver = "2"

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# bdk-tx workspace
2+
3+
A Cargo workspace with two crates:
4+
5+
- [`tx/`](tx) -- **`bdk_tx`**, a low-level Bitcoin transaction-building library (coin selection,
6+
tx-template shaping, PSBT emission and finalization). See [`tx/README.md`](tx/README.md).
7+
- [`wallet_tx/`](wallet_tx) -- **`bdk_wallet_tx`**, a bridge crate that drives `bdk_tx`'s multi-stage
8+
transaction building from a `bdk_wallet::Wallet` via the `WalletTxExt` extension trait. See
9+
[`wallet_tx/README.md`](wallet_tx/README.md).
10+
11+
`wallet_tx` depends on both `bdk_wallet` and `bdk_tx`, so neither base crate depends on the other:
12+
`bdk_wallet` stays stable, `bdk_tx` stays free to move, and the bridge absorbs the coupling.
13+
14+
## Building
15+
16+
```sh
17+
cargo build --workspace
18+
cargo test --workspace
19+
```
20+
21+
`bdk_tx` additionally supports `no_std`:
22+
23+
```sh
24+
cargo check -p bdk_tx --no-default-features --features miniscript/no-std
25+
```

wallet_tx/Cargo.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "bdk_wallet_tx"
3+
version = "0.1.0"
4+
edition = "2021"
5+
rust-version = "1.85.0"
6+
homepage = "https://bitcoindevkit.org"
7+
repository = "https://github.com/bitcoindevkit/bdk-tx"
8+
description = "Bridge between bdk_wallet and bdk_tx: drive multi-stage transaction building from a bdk_wallet::Wallet."
9+
license = "MIT OR Apache-2.0"
10+
11+
[dependencies]
12+
bdk_tx = { path = "../tx" }
13+
bdk_wallet = { git = "https://github.com/bitcoindevkit/bdk_wallet", rev = "58fe631177047ef1c49947ff453110f47138adcd" }
14+
bitcoin = { version = "0.32" }
15+
miniscript = { version = "12.3" }
16+
17+
[dev-dependencies]
18+
anyhow = "1"
19+
bdk_wallet = { git = "https://github.com/bitcoindevkit/bdk_wallet", rev = "58fe631177047ef1c49947ff453110f47138adcd", features = ["test-utils"] }

wallet_tx/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# bdk_wallet_tx
2+
3+
A bridge crate between [`bdk_wallet`](https://github.com/bitcoindevkit/bdk_wallet) and
4+
[`bdk_tx`](https://github.com/bitcoindevkit/bdk-tx).
5+
6+
`bdk_wallet` is the stable, batteries-included wallet; `bdk_tx` is a fast-moving, low-level
7+
transaction-building library. This crate depends on **both** and exposes their integration as an
8+
extension trait, `WalletTxExt`, implemented for `bdk_wallet::Wallet`.
9+
10+
Keeping the integration in a separate crate means neither base crate depends on the other:
11+
`bdk_wallet` stays stable, `bdk_tx` stays free to move, and every breaking `bdk_tx` release is
12+
absorbed here rather than forcing a `bdk_wallet` major. (See
13+
[bitcoindevkit/bdk_wallet#297](https://github.com/bitcoindevkit/bdk_wallet/pull/297#issuecomment-4810411011).)
14+
15+
## Three-stage pipeline
16+
17+
```rust,ignore
18+
use bdk_tx::BuildPsbtParams;
19+
use bdk_wallet_tx::{WalletTxExt, SelectParams, SelectionStrategy};
20+
21+
// 1. Candidates -- resolve the wallet's spendable inputs.
22+
let coins = wallet.candidates()?;
23+
24+
// 2. Select -- coin selection (a pure read), yielding a `bdk_tx::TxTemplate` and the auto-derived
25+
// change address. The template is unshuffled with no anti-fee-sniping; shape it here if desired.
26+
let (template, change) = wallet.select(&coins, SelectParams {
27+
recipients: vec![(recipient_spk, amount)],
28+
coin_selection: SelectionStrategy::SingleRandomDraw,
29+
feerate,
30+
longterm_feerate: None,
31+
change_script: None,
32+
}, &mut rng)?;
33+
// Reserve the change address so a later `select` won't reuse it (reveal + mark used; then persist
34+
// the change set). Skip it to leave the wallet untouched; release later with `unmark_used`.
35+
if let Some(change) = &change { wallet.reserve_change(change); }
36+
37+
// 3. Emit the PSBT directly via bdk_tx (no wallet needed)...
38+
let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?;
39+
// ...optionally fill the wallet's global xpubs (the only emission step that needs the wallet).
40+
wallet.add_global_xpubs(&mut psbt)?;
41+
```
42+
43+
Sign the PSBT however you like, then `finalizer.finalize(&mut psbt)`.
44+
45+
See `examples/three_stage.rs` for a complete, runnable flow.
46+
47+
## Notes
48+
49+
- **Anti-fee-sniping / MTP.** `select` returns an *unshuffled* template with *no* anti-fee-sniping --
50+
apply `template.apply_anti_fee_sniping(tip_height, rng)` and `template.shuffle_outputs(rng)`
51+
yourself before `build_psbt`. `bdk_wallet` checkpoints carry no median-time-past, so per-input
52+
`prev_mtp` is left unset (never fabricated); supply `CandidateParams::tip_mtp` / `fetch_mtp` if
53+
you need time-based (CSV/CLTV-time) timelock filtering.
54+
- **Change address.** `select` is a pure read: when no change script is supplied it *peeks* the
55+
next unused internal address and returns it, without mutating the wallet. Reserve it with
56+
`reserve_change` (reveal + mark used; then persist the change set) so a later `select` won't
57+
reuse it -- handy across several maybe-broadcast txs. Release an unused one with `unmark_used`;
58+
reserve nothing and the wallet is left untouched.

wallet_tx/examples/three_stage.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! Build, sign and finalize a transaction from a `bdk_wallet::Wallet` using the three-stage
2+
//! `bdk_wallet_tx` bridge: candidates -> select -> build_psbt.
3+
//!
4+
//! Run with: `cargo run -p bdk_wallet_tx --example three_stage`
5+
//!
6+
//! The wallet here is funded deterministically via `bdk_wallet`'s `test-utils` so the example
7+
//! needs no network or `bitcoind`. A real application would sync the wallet from its chain source
8+
//! instead; everything from `candidates()` onward is identical.
9+
10+
use bdk_tx::BuildPsbtParams;
11+
use bdk_wallet::test_utils::{get_funded_wallet, get_test_tr_single_sig_xprv_and_change_desc};
12+
use bdk_wallet::{KeychainKind, SignOptions};
13+
use bdk_wallet_tx::{SelectParams, SelectionStrategy, WalletTxExt};
14+
use bitcoin::secp256k1::rand;
15+
use bitcoin::{absolute, Amount, FeeRate};
16+
17+
fn main() -> anyhow::Result<()> {
18+
let (descriptor, change_descriptor) = get_test_tr_single_sig_xprv_and_change_desc();
19+
let (mut wallet, _funding_txid) = get_funded_wallet(descriptor, change_descriptor);
20+
println!("balance: {}", wallet.balance().total());
21+
22+
// A destination (here, a far-future address of our own wallet just for demonstration).
23+
let recipient = wallet
24+
.peek_address(KeychainKind::External, 42)
25+
.script_pubkey();
26+
27+
// Stage 1 -- resolve the spendable candidate set.
28+
let coins = wallet.candidates()?;
29+
println!("candidates: {}", coins.inputs().count());
30+
31+
// The caller supplies the RNG (used by SingleRandomDraw selection and the shuffling below).
32+
let mut rng = rand::thread_rng();
33+
34+
// Stage 2 -- run coin selection (a pure read), yielding a `bdk_tx::TxTemplate` and the
35+
// auto-derived change address (peeked, not yet revealed).
36+
let (template, change) = wallet.select(
37+
&coins,
38+
SelectParams {
39+
recipients: vec![(recipient.clone(), Amount::from_sat(10_000))],
40+
coin_selection: SelectionStrategy::LowestFee {
41+
max_rounds: 100_000,
42+
},
43+
feerate: FeeRate::from_sat_per_vb(4).expect("valid feerate"),
44+
longterm_feerate: Some(FeeRate::from_sat_per_vb(1).expect("valid feerate")),
45+
change_script: None,
46+
},
47+
&mut rng,
48+
)?;
49+
50+
// Reserve this selection's change address: reveal + mark it used (then persist the change set),
51+
// so a later `select` (e.g. when batching several txs before broadcasting) won't hand out the
52+
// same change address. Skip it to leave the wallet untouched; if you reserve but then drop this
53+
// tx, release the address with `unmark_used`.
54+
if let Some(change) = &change {
55+
wallet.reserve_change(change);
56+
}
57+
58+
// Stage 3 -- shape the template and emit, all in one chain: shuffle inputs/outputs (so the
59+
// change output isn't in a predictable position), apply anti-fee-sniping to bind the tx to the
60+
// chain tip (this seals the template), then build the PSBT + finalizer directly via `bdk_tx`
61+
// (no wallet needed for emission).
62+
let tip_height = absolute::Height::from_consensus(wallet.latest_checkpoint().height())?;
63+
let (mut psbt, finalizer) = template
64+
.shuffle_inputs(&mut rng)
65+
.shuffle_outputs(&mut rng)
66+
.apply_anti_fee_sniping(tip_height, &mut rng)?
67+
.build_psbt(BuildPsbtParams::default())?;
68+
// ...then optionally fill the wallet's global xpubs (the one emission step that needs it).
69+
wallet.add_global_xpubs(&mut psbt)?;
70+
71+
// Sign with the wallet's keys, then finalize with the `bdk_tx` finalizer.
72+
let _ = wallet.sign(
73+
&mut psbt,
74+
SignOptions {
75+
try_finalize: false,
76+
..Default::default()
77+
},
78+
)?;
79+
assert!(
80+
finalizer.finalize(&mut psbt).is_finalized(),
81+
"must finalize"
82+
);
83+
84+
let tx = psbt.extract_tx()?;
85+
println!(
86+
"built tx {}: {} input(s), {} output(s)",
87+
tx.compute_txid(),
88+
tx.input.len(),
89+
tx.output.len(),
90+
);
91+
Ok(())
92+
}

wallet_tx/src/candidates.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! The resolved spendable candidate set (PSBT-building stage 1 output).
2+
3+
use bdk_tx::{Input, InputCandidates, RbfParams};
4+
use bdk_wallet::LocalOutput;
5+
use bitcoin::Txid;
6+
use std::collections::HashSet;
7+
8+
use crate::CandidatesError;
9+
10+
/// A resolved set of spendable input candidates (output of PSBT-building stage 1).
11+
///
12+
/// Produced by [`WalletTxExt::candidates_with`] from [`CandidateParams`]: every owned UTXO has been
13+
/// planned against the wallet's descriptors and spendability filters applied. It owns its inputs
14+
/// (no wallet borrow), so it can be held as a snapshot and used to build one or more PSBTs via
15+
/// [`WalletTxExt::select`].
16+
///
17+
/// Add foreign (non-wallet) inputs with [`push_must_select`](Self::push_must_select) /
18+
/// [`push_can_select`](Self::push_can_select), and apply your own post-resolution filters with
19+
/// [`filter`](Self::filter) / [`regroup`](Self::regroup).
20+
///
21+
/// If the [`CandidateParams`] had a non-empty [`replace`](crate::CandidateParams::replace) list,
22+
/// the set carries the [`RbfParams`] (replaced-tx fee statistics) forward so stage 2 applies the
23+
/// correct fee floor, and exposes the wallet-owned outputs being stripped by the replacement via
24+
/// [`replaced_unspent`](Self::replaced_unspent).
25+
///
26+
/// [`WalletTxExt::candidates_with`]: crate::WalletTxExt::candidates_with
27+
/// [`WalletTxExt::select`]: crate::WalletTxExt::select
28+
/// [`CandidateParams`]: crate::CandidateParams
29+
/// [`CandidateParams::replace`]: crate::CandidateParams::replace
30+
#[derive(Debug, Clone)]
31+
pub struct CandidateSet {
32+
pub(crate) candidates: InputCandidates,
33+
pub(crate) rbf: Option<RbfParams>,
34+
/// Txids being replaced/evicted (direct conflicts + descendants). A pushed input may not spend
35+
/// an output of any of these.
36+
pub(crate) replaced: HashSet<Txid>,
37+
/// Wallet-owned UTXOs stripped from the canonical view by the replacement.
38+
pub(crate) replaced_unspent: Vec<LocalOutput>,
39+
}
40+
41+
impl CandidateSet {
42+
/// Iterate over all resolved input candidates (both must-select and optional).
43+
pub fn inputs(&self) -> impl Iterator<Item = &Input> + '_ {
44+
self.candidates.inputs()
45+
}
46+
47+
/// Whether the set contains no candidates at all.
48+
pub fn is_empty(&self) -> bool {
49+
self.candidates.inputs().next().is_none()
50+
}
51+
52+
/// Whether this set is a Replace-By-Fee set (built from a non-empty
53+
/// [`CandidateParams::replace`](crate::CandidateParams::replace) list).
54+
pub fn is_rbf(&self) -> bool {
55+
self.rbf.is_some()
56+
}
57+
58+
/// Wallet-owned UTXOs that the replacement strips out of the canonical view -- the outputs of
59+
/// the replaced (and descendant) txs that were unspent in the wallet's view before the replace.
60+
///
61+
/// These are the still-live payments of the txs being replaced; a caller batching several txs
62+
/// into one replacement can use them to decide which payments to re-create. Empty for a
63+
/// non-Replace-By-Fee set.
64+
pub fn replaced_unspent(&self) -> &[LocalOutput] {
65+
&self.replaced_unspent
66+
}
67+
68+
/// Add a foreign [`Input`] to the must-select group (always spent).
69+
///
70+
/// Use this for a UTXO that did not originate from the wallet, supplied with a pre-built
71+
/// plan -- its validity (UTXO existence, satisfaction weight, ...) relies on the
72+
/// caller-supplied values, so only push inputs you trust.
73+
///
74+
/// # Errors
75+
///
76+
/// Returns [`ConflictingInput`](CandidatesError::ConflictingInput) if the input spends an
77+
/// output of a transaction being replaced (RBF).
78+
pub fn push_must_select(mut self, input: Input) -> Result<Self, CandidatesError> {
79+
self.ensure_not_replaced(&input)?;
80+
self.candidates = self.candidates.push_must_select(input);
81+
Ok(self)
82+
}
83+
84+
/// Add a foreign [`Input`] as an optional (can-select) candidate.
85+
///
86+
/// # Errors
87+
///
88+
/// Returns [`ConflictingInput`](CandidatesError::ConflictingInput) if the input spends an
89+
/// output of a transaction being replaced (RBF).
90+
pub fn push_can_select(mut self, input: Input) -> Result<Self, CandidatesError> {
91+
self.ensure_not_replaced(&input)?;
92+
self.candidates = self.candidates.push_can_select(input);
93+
Ok(self)
94+
}
95+
96+
/// Reject an input that spends an output of a replaced (evicted) transaction.
97+
fn ensure_not_replaced(&self, input: &Input) -> Result<(), CandidatesError> {
98+
let op = input.prev_outpoint();
99+
if self.replaced.contains(&op.txid) {
100+
return Err(CandidatesError::ConflictingInput(op));
101+
}
102+
Ok(())
103+
}
104+
105+
/// Keep only the candidates for which `policy` returns `true`.
106+
///
107+
/// Forwards to [`bdk_tx::InputCandidates::filter`].
108+
pub fn filter<P>(mut self, policy: P) -> Self
109+
where
110+
P: FnMut(&Input) -> bool,
111+
{
112+
self.candidates = self.candidates.filter(policy);
113+
self
114+
}
115+
116+
/// Regroup the candidates by the group key returned by `policy`.
117+
///
118+
/// Forwards to [`bdk_tx::InputCandidates::regroup`].
119+
pub fn regroup<P, G>(mut self, policy: P) -> Self
120+
where
121+
P: FnMut(&Input) -> G,
122+
G: Ord + Clone,
123+
{
124+
self.candidates = self.candidates.regroup(policy);
125+
self
126+
}
127+
128+
/// Consume into the underlying `bdk_tx` parts: the [`InputCandidates`] and, if this is a
129+
/// Replace-By-Fee set (see [`is_rbf`](Self::is_rbf)), the [`RbfParams`] carrying the
130+
/// replaced-tx fee floor.
131+
///
132+
/// Pass both on to `bdk_tx` (e.g. via
133+
/// [`SelectionParams::replace`](bdk_tx::SelectionParams::replace)) to build a `TxTemplate`
134+
/// directly while still enforcing the RBF minimum fee.
135+
pub fn into_parts(self) -> (InputCandidates, Option<RbfParams>) {
136+
(self.candidates, self.rbf)
137+
}
138+
}

0 commit comments

Comments
 (0)