Implement create_psbt for Wallet#297
Conversation
673d602 to
77e1d20
Compare
Pull Request Test Coverage Report for Build 19016316365Warning: This coverage report may be inaccurate.This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.
Details
💛 - Coveralls |
|
I like the What do you envision the API for replace-by-fee transactions look like in this new bdk-tx world? I'm picturing something like |
2c54be1 to
6f525d1
Compare
6f525d1 to
4493cbd
Compare
create_psbt for Walletcreate_psbt for Wallet
thunderbiscuit
left a comment
There was a problem hiding this comment.
So this is more of a conceptual review/question set, as I'm getting acquainted with the PR, and it also requires knowledge and understanding of the bdk-tx crate/workflow.
One general question I have is do you think is missing in functionality between this and the current TxBuilder? Can we make a todo list that compares functionality with the current TxBuilder to better visualize how close of a replacement this is, or if it only provides part of the functionality for now (and if so which parts)?
I haven't had time to look/test the examples and my day is over, but I'll come back to this on Monday.
oleonardolima
left a comment
There was a problem hiding this comment.
I did an initial review, with simple comments/questions. Still need to take a deeper look into bdk-tx and do a more thorough review here.
| /// No Bnb solution. | ||
| Bnb(bdk_coin_select::NoBnbSolution), | ||
| /// Non-sufficient funds | ||
| InsufficientFunds(bdk_coin_select::InsufficientFunds), |
There was a problem hiding this comment.
Can't we have a generic CS error instead ?
There was a problem hiding this comment.
Can you elaborate on what you mean by generic CS error?
|
Thank you for the quick review @thunderbiscuit @oleonardolima. |
TxBuilder vs PsbtParams feature comparisonsubject to change
N/A: |
4493cbd to
a7afecb
Compare
|
3d7bd2e to
5285937
Compare
I think a straightforward approach is to set a target feerate of I can see it being relevant to CPFP, since there we're less concerned about the feerate of an individual tx and more so with the fee needed to bump a package to a target feerate. |
notmandatory
left a comment
There was a problem hiding this comment.
There's a lot of great work here, I mainly focused my review on the examples, APIs and confirmed no breaking changes, I only have a couple questions and a small nit to fix. I also ran a couple different LLM models at it and they gave it high marks without finding any significant issues.
| /// | ||
| /// Panics if `txs` is empty. | ||
| pub fn replace_txs(self, txs: &[Arc<Transaction>]) -> PsbtParams<Rbf> { | ||
| assert!( |
There was a problem hiding this comment.
Can we return a result here instead of this assert? Would rather not have a panic caused by user input validation.
| // Test that replacing two unconfirmed txs A, B results in a transaction | ||
| // that spends the inputs of both A and B. | ||
| #[test] | ||
| fn test_replace_by_fee_and_recpients() { |
There was a problem hiding this comment.
nit ⛏️
| fn test_replace_by_fee_and_recpients() { | |
| fn test_replace_by_fee_and_recipients() { |
| impl PsbtParams<Rbf> { | ||
| /// Replace spends of the provided `txs`. This will internally set the list of UTXOs | ||
| /// to be spent. | ||
| fn replace(&mut self, txs: &[Arc<Transaction>]) { |
There was a problem hiding this comment.
Why does the txs param need to be an &[Arc<Transaction>] ? can it be &[Transaction] since the code below on 528 is cloning the inner ref and not the Arc so there is no additional efficiency.
Same comment for the callers PsbtParams<>::replace_txs() and Wallet::replace_by_fee_and_recipients().
|
Thanks @notmandatory |
…ansaction>> The empty txs case is now caught in `replace_by_fee_with_rng`, which returns `ReplaceByFeeError::NoOriginalTransactions` when `params.replace` is empty. Adds a test in `tests/psbt.rs` covering the error path.
| pub fn add_planned_input(&mut self, input: Input) -> &mut Self { | ||
| if self.set.insert(input.prev_outpoint()) { | ||
| self.inputs.push(input); | ||
| } | ||
| self | ||
| } |
There was a problem hiding this comment.
I got a bit confused on the usage of both self.utxos and self.set, shouldn't the input outpoint be addded to self.utxos here too ?
There was a problem hiding this comment.
If I understand correctly then, utxos corresponds to local UTXOs and inputs corresponds to foreign UTXOs in the old TxBuilder and set is a hashset of all manually selected utxos (local + foreign) for quick membership checks.
But then do we plan to provide the old invariants? By old invariants I mean,
"Foreign UTXOs are not prioritized over local UTXOs. If a local UTXO is added to the manually selected list, it will replace any conflicting foreign UTXOs. However, a foreign UTXO cannot replace a conflicting local UTXO."
| /// | ||
| /// [relevance]: Indexer::is_tx_relevant | ||
| /// [indexer]: Self::spk_index | ||
| pub fn insert_tx<T>(&mut self, tx: T) |
There was a problem hiding this comment.
Is this a MUST to go together with create_psbt ? I'd move this to another PR instead.
Does it also need a matching (or existing) WalletEvent (by using apply_update_events) ?
Also, having the .expect() in a public API feels a bit odd.
| match params.coin_selection { | ||
| SelectionStrategy::SingleRandomDraw => { | ||
| // We should have shuffled candidates earlier, so just select | ||
| // until the target is met. | ||
| selector | ||
| .select_until_target_met() | ||
| .map_err(CreatePsbtError::InsufficientFunds)?; | ||
| } | ||
| SelectionStrategy::LowestFee { | ||
| longterm_feerate, | ||
| max_rounds, | ||
| } => { | ||
| selector | ||
| .select_with_algorithm(selection_algorithm_lowest_fee_bnb( | ||
| longterm_feerate, | ||
| max_rounds, | ||
| )) | ||
| .map_err(CreatePsbtError::Bnb)?; | ||
| } |
There was a problem hiding this comment.
question: the user does not have the option of using a customized CS algorithm anymore ?
| /// | ||
| /// [`Height`]: bitcoin::absolute::Height | ||
| /// [`Time`]: bitcoin::absolute::Time | ||
| fn status_from_position(pos: ChainPosition<ConfirmationBlockTime>) -> Option<ConfirmationStatus> { |
There was a problem hiding this comment.
nit: confirmation_status() would be just fine too, also it could live in utils.rs instead
| } | ||
|
|
||
| /// List indexed [`FullTxOut`]s. | ||
| fn list_indexed_txouts( |
There was a problem hiding this comment.
nit: I'd suggest renaming it to list_txouts and reuse it in the already existing list_output method.
nymius
left a comment
There was a problem hiding this comment.
I need another round of review yet, but these are the things I've found so far.
There was a problem hiding this comment.
What's the feature unlocked by this change?
| /// Parameters to create a PSBT. | ||
| // TODO: Can we derive `Clone` for this? | ||
| #[derive(Debug)] | ||
| pub struct PsbtParams<C> { |
There was a problem hiding this comment.
Looking in perspective to the motivation for bdk-tx, why this is better than TxBuilder? What's the difference?
There was a problem hiding this comment.
The two advantages I see are:
bdk-txuses therust-miniscriptplanning module instead of the hard to usePolicyapproach in TxBuilder.- By extracting
bdk-txinto a new crate it can be used by any projects usingrust-bitcoinandrust-miniscript.
There was a problem hiding this comment.
Thanks Steve, In the change to bdk-tx, I initially assigned the same weight to the "code complexity" and lack of "flexibility" issues than to Policy, I might have been shortsighted.
Rephrasing my question: how this approach is not going to lead us to the same level of complexity of the old TxBuilder? Or have we assumed the complexity is insurmountable? (that's a valid answer too).
There was a problem hiding this comment.
@nymius I guess what you mean by "complexity" is "invalid state representation"?
There was a problem hiding this comment.
No, I meant code complexity: the numerous fields, the setters, the long create_tx method and all that.
Or the shape of the code didn't matter that much and the main concern were these invalid state representations.
There was a problem hiding this comment.
Note that some complexity in create_tx cannot be avoided right now. I'm assuming it can be simplified once CanonicalView, it's extract subgraph method and the bdk_tx_core glue lands.
| impl PsbtParams<Rbf> { | ||
| /// Replace spends of the provided `txs`. This will internally set the list of UTXOs | ||
| /// to be spent. | ||
| fn replace(&mut self, txs: &[Arc<Transaction>]) { |
There was a problem hiding this comment.
Why we cannot create the PsbtParams here, i.e., fn replace(txs: &[Arc<Transaction>]) -> PsbtParams<Rbf>? Is there any parameter that we would like to keep from whatever we had before? I've checked the example and we are not reusing parameters from psbt1, we should?
There was a problem hiding this comment.
test_replace_params repeats the pattern, it doesn't reuse the old PsbtParams.
| } | ||
|
|
||
| #[test] | ||
| fn test_create_psbt_maturity_height() { |
There was a problem hiding this comment.
A test with no assertions? Shouldn't we check at least the outpoint used belongs to the coinbase tx?
| } | ||
|
|
||
| #[test] | ||
| fn test_add_planned_psbt_input() -> anyhow::Result<()> { |
There was a problem hiding this comment.
Is anyhow::Result<()> really needed here? It is first introduced by this test. Why not replacing the ? operators by .expect("...")?
| pub(crate) set: HashSet<OutPoint>, | ||
| /// List of UTXO outpoints to spend. | ||
| pub(crate) utxos: Vec<OutPoint>, | ||
| /// List of planned transaction [`Input`]s. |
| fn test_create_psbt_cltv_timestamp() { | ||
| use absolute::LockTime; | ||
|
|
||
| let lock_time = LockTime::from_consensus(1734230218); |
There was a problem hiding this comment.
Here, I would use a constant relative to the unix timepstamp threshold, e.g., LOCKTIME_TIMESTAMP_FLOOR = 500 millions then LockTime::from_consensus(LOCKTIME_TIMESTAMP_FLOOR + 1)
|
|
||
| // Locktime greater than required | ||
| { | ||
| let new_lock_time = 1772167108; |
There was a problem hiding this comment.
Same here than above comment.
| ); | ||
|
|
||
| let addr = wallet.next_unused_address(KeychainKind::External); | ||
| let rel_locktime = relative::LockTime::from_consensus(6).unwrap(); |
There was a problem hiding this comment.
I would use a different value here, as 6 is the default if nothing set, as line 381 above shows
| .add_utxos(&[op]) | ||
| .add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]); | ||
| let (psbt, _) = wallet.create_psbt(params).unwrap(); | ||
| assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6)); |
There was a problem hiding this comment.
I would use a local named constant to point to the place where this value is set.
| // The replacement must pay at least the combined fee of all three transactions | ||
| // (Bitcoin Core RBF Rule 3). | ||
| let total_original_fee = fee_a + fee_b + fee_c; | ||
| assert_eq!(total_original_fee.to_sat(), 90_000); |
There was a problem hiding this comment.
Why not checking replacement_fee >= fee_a + fee_b + fee_c instead of checking they equal 90_000? Is there any value on doing this? Could the fee be different than 90_000 after how we have set up the txs above?
| params.change_script(change_script); | ||
| params.drain_wallet(); | ||
| let (psbt, _) = wallet.create_psbt(params).unwrap(); | ||
| assert_eq!(psbt.unsigned_tx.input.len(), 2); |
There was a problem hiding this comment.
We can check the inputs used are 600 and 1000 sats each.
| let params = PsbtParams { | ||
| fee_rate, | ||
| recipients, | ||
| ..Default::default() | ||
| } | ||
| .replace_txs(txs); | ||
| self.replace_by_fee_with_rng(params, &mut rand::thread_rng()) |
There was a problem hiding this comment.
Is this really an ergonomic improvement?
| insert_checkpoint(&mut wallet, confirm_block); | ||
|
|
||
| // Attempting to replace the now-confirmed tx should return TransactionConfirmed. | ||
| let result = wallet.replace_by_fee_and_recipients( |
There was a problem hiding this comment.
Why not reusing the old params here?
params.replace(&[Arc::new(unconfirmed_tx)]).fee_rate(fee_rate)?
This goes in hand with https://github.com/bitcoindevkit/bdk_wallet/pull/297/changes#r3383792188.
|
|
||
| // Test we can select and spend an indexed but not-yet-canonical utxo | ||
| #[test] | ||
| fn test_spend_non_canonical_txout() -> anyhow::Result<()> { |
There was a problem hiding this comment.
I disagree with this, where it's said that unconfirmed txs are non canonical? I would consider this different if there existed a conflicting tx, or even a confirmed conflicting tx, but in this case, the tests is checking unconfirmed tx2 and parents belong to the canonical view.
Adds two `CreatePsbtError` variants to cover distinct failure modes
when building a PSBT:
- `NoRecipients`: no recipients were added, or `drain_wallet` was set
without an explicit `change_script`.
- `AllOutputsBelowDust`: after coin selection the only output fell
below dust and was dropped to fees, leaving the transaction with
zero outputs.
Early-exit guards in `create_psbt_with_rng` and `replace_by_fee_with_rng`
return `NoRecipients`. The post-selection guard in `create_psbt_from_selector`
returns `AllOutputsBelowDust`.
Tests:
- `test_create_psbt_no_recipients_error`
- `test_create_psbt_drain_wallet_change_below_dust_error`
- `test_replace_by_fee_drain_wallet_change_below_dust_error`
- Move `add_planned_input` to `impl PsbtParams<CreateTx>`. It was previously available on `PsbtParams<C>` (all contexts), allowing inputs to be erroneously registered after the state transition. - Fix `replace()` to preserve pre-registered planned inputs. A `Transaction` cannot reconstruct `Input` metadata (psbt fields, satisfaction weight, etc.), so planned inputs must be added before calling `replace_txs`. We remove any planned input whose `prev_txid` is directly in the replacement set. - Add `ReplaceByFeeError::ConflictingInput(OutPoint)` in `replace_by_fee_with_rng` after computing the full `to_replace` set, and validate every manually-selected input against it. - Add `test_replace_tx_with_planned_input`, `test_replace_strips_conflicting_planned_input`, and `test_replace_by_fee_conflicting_input_descendant`.
|
Thanks @110CodingP Moved to 3.2.0 as it's still under testing. |
| /// Drain wallet. | ||
| /// | ||
| /// This will force selection of the available input candidates. As such, the option is only | ||
| /// applied to inputs that meet the spending criteria. | ||
| pub fn drain_wallet(&mut self) -> &mut Self { |
There was a problem hiding this comment.
Perhaps select_all would be a better name for describing what it actually does.
evanlinjin
left a comment
There was a problem hiding this comment.
Also, what do you think about a staged API?
// Stage 1 — wallet resolves a snapshot of spendable inputs
let coins = wallet.candidates() // -> InputCandidates
.with_assets(assets)
.maturity_height(h)
.filter(|txo| ...)
.add_foreign(input) // planned/foreign inputs
.replace(txs)?; // RBF seeds forced inputs here
// Stage 2 — wallet builds the tx from that set
let (psbt, finalizer) = wallet.create_psbt(coins, CreateTxParams)?;
let (psbt, finalizer) = wallet.create_rbf(coins, RbfParams)?;| /// Target fee rate. | ||
| pub(crate) fee_rate: FeeRate, | ||
| /// Whether to spend all available coins. | ||
| pub(crate) drain_wallet: bool, |
There was a problem hiding this comment.
Looking at how drain_wallet functions, I think DrainAll should be a SelectionStrategy (or as you suggested, SelectAll).
| /// List of UTXO outpoints to spend. | ||
| pub(crate) utxos: Vec<OutPoint>, | ||
| /// List of planned transaction [`Input`]s. | ||
| pub(crate) inputs: Vec<Input>, |
There was a problem hiding this comment.
Instead of having both utxos (resolved by wallet) and inputs (external UTXOs), I think there should be an unified enum:
enum InputSpec {
Owned(OutPoint), // to be resolved by wallet
Planned(Input), // taken as-is
}This will fix an ordering bug with Untouched - where the order does not respect the order the UTXOs were introduced if we have both wallet-resolved and external inputs.
|
|
||
| [dependencies] | ||
| bdk_chain = { version = "0.23.3", features = ["miniscript", "serde"], default-features = false } | ||
| bdk_tx = { version = "0.2.0", default-features = false } |
There was a problem hiding this comment.
Not sure if this is related to this PR, but we should pin bitcoin-units to 0.1.3 or higher. Any lower, and FeeRate::from_sat_per_vb_u32 would not be available.
| #[non_exhaustive] | ||
| pub enum SelectionStrategy { | ||
| /// Single random draw. | ||
| #[default] |
There was a problem hiding this comment.
I would argue that LowestFee should be the default strategy - as it creates better results.
| /// | ||
| /// [`UnknownUtxo`]: crate::wallet::error::CreatePsbtError::UnknownUtxo | ||
| /// [`Wallet::create_psbt`]: crate::Wallet::create_psbt | ||
| pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { |
There was a problem hiding this comment.
I think that a consuming builder pattern has better ergonomics. The current (non-consuming) version, forces the caller to instantiate on a separate line and keep a mutable variable around.
| pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self { | |
| pub fn add_utxos(mut self, outpoints: &[OutPoint]) -> Self { |
PsbtParams will then have #[must_use].
There was a problem hiding this comment.
I guess a non-consuming builder is slightly better API for someone who needs conditional construction. I'll be happy either way.
| .iter() | ||
| .filter_map(|&txid| { | ||
| let tx = self.tx_graph.graph().get_tx(txid)?; | ||
| self.calculate_fee(&tx).ok() |
There was a problem hiding this comment.
If the fee cannot be calculated, we may undershoot the RBF fee floor. Maybe we should return an error?
| self | ||
| } | ||
|
|
||
| /// Set the [`Descriptor`] or raw [`Script`] to be used for generating the change output. |
There was a problem hiding this comment.
We should probably note that if this is not explicitly specified, the wallet will still derive a change address.
|
Thanks for pushing this forward. The implementation is solid and surfaced a lot of the tricky cases. Before we commit to an API shape, I want to put an alternative on the table. I built a PoC of it in #502. I don't think a single
So Concretely, because the candidate set and template are real values you hold, the staged design unlocks things a single opaque call can't: input grouping, post-resolution filtering, a direct If anything, the staged design is more ergonomic — and more transparent. The single-call approach hands you one large params struct where it's hard to see how the options relate; staging scopes each set of options to its stage, so what's valid where is obvious. The one thing it gives up is the single-call one-liner for the trivial "send X to Y" case — and that convenience can always be layered on top of composable stages, whereas composability can't be retrofitted onto a sealed call. #502 is a draft PoC for this comparison; I'd love your eyes on it, and to hear where the staged shape falls short. |
Description
This PR introduces a new PSBT construction flow and associated wallet APIs.
Primary additions:
PsbtParams<Ctx>API with typed contexts (CreateTxandReplaceTx) for transaction creation and replacement.Wallet::create_psbtandWallet::create_psbt_with_rngWallet::replace_by_fee,Wallet::replace_by_fee_with_rng, andWallet::replace_by_fee_and_recipientsTxOrdering<In, Out>updates used by the new selection/PSBT pathThis work is part of the broader migration of transaction construction to
bdk_tx, which uses miniscript'splanmodule under the hood. A key long-term goal is the move away from legacy policy-based transaction construction internals (#4).fix #204
Notes to the reviewers
Suggested review order for this PR:
examples/psbt.rsandexamples/replace_by_fee.rsfor a high-level API walkthrough.src/psbt/params.rsin detail (types, state transitions, params semantics, and so on).src/wallet/mod.rs(create-PSBT and RBF execution paths plus shared helpers).tests/psbt.rs,tests/wallet.rsto ensure expected behavior and edge cases are covered.Changelog notice
Changed
TxOrdering::Customis generic over the input and output types.Added
psbt::paramsmodulePsbtParamsstructCoinSelectionStrategyenumUtxoFilterstructAdded
pubmethods onWalletWallet::create_psbt{_with_rng}Wallet::replace_by_fee{_with_rng}Wallet::replace_by_fee_and_recipientsAdded examples
examples/psbt.rsexamples/replace_by_fee.rsChecklists
This pull request breaks the existing API