feat: Add Wallet::create_psbt#516
Conversation
`TxOrdering` is made generic by exposing the generic from `TxSort` function. This means we're not limited to ordering lists of only `TxIn` and `TxOut`, which will be useful for sorting inputs/outputs of a `bdk_tx::Selection`. We use bitcoin `TxIn` and `TxOut` as the default type parameter to maintain backward compatibility.
We add the `psbt::params` module along with new types including `PsbtParams` and `SelectionStrategy`. `PsbtParams` is mostly inspired by `TxParams` from `tx_builder.rs`, except that we've removed support for `policy_path` in favor of `add_assets` API. `PsbtParams<C>` contains a type parameter `C` indicating the context in which the parameters can be used. Methods related to PSBT creation exist within the `CreateTx` context, and methods related to replacements (RBF) exist within the `ReplaceTx` context. In `lib.rs` re-export everything under `psbt` module. - deps: Add `bdk_tx` 0.2.0 to Cargo.toml
We use the new `PsbtParams` to add methods on `Wallet` for creating PSBTs, including RBF transactions. `Wallet::create_psbt` and `Wallet::replace_by_fee` each have no-std counterparts that take an additional `impl RngCore` parameter. Also adds a convenience method `replace_by_fee_and_recipients` that exposes the minimum information needed to create an RBF. This commit re-introduces the `Wallet::insert_tx` API for adding newly created transactions to the wallet. Added `Wallet::transactions_with_params` that allows customizing the internal canonicalization logic. Added errors to `wallet::errors` module: - `CreatePsbtError` - `ReplaceByFeeError`
Added unit test to `psbt/params.rs` - `test_replace_params` To tests/add_foreign_utxo.rs added - `test_add_planned_psbt_input` To tests/psbt.rs added - `test_create_psbt` - `test_create_psbt_insufficient_funds_error` - `test_create_psbt_maturity_height` - `test_create_psbt_cltv` - `test_create_psbt_cltv_timestamp` - `test_create_psbt_csv` - `test_replace_by_fee_and_recpients` - `test_replace_by_fee_replaces_descendant_fees` - `test_replace_by_fee_confirmed_tx_error` - `test_replace_by_fee_no_inputs_from_original` - `test_create_psbt_utxo_filter` Plus several Sequence fallback and override scenarios - `test_create_psbt_fallback_sequence_applied_to_coin_selected_input` - `test_create_psbt_fallback_sequence_skipped_for_csv_input` - `test_create_psbt_sequence_override_manually_selected_input` - `test_create_psbt_sequence_override_takes_precedence_over_fallback` - `test_create_psbt_sequence_override_csv_conflict_returns_error` To tests/wallet.rs added - `test_spend_non_canonical_txout` - `test-utils`: Add `insert_tx_anchor` test helper for adding a transaction to the wallet with associated anchor block.
…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.
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`.
Shuffling the input candidates now occurs only in the case that SingleRandomDraw is the chosen selection strategy. As such, the extra calls to `shuffle_slice` can be removed.
`bdk_tx` is now an optional dependency controlled by the new `bdk-tx` feature. All `create_psbt`/`replace_by_fee` surface area is gated behind `cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))`. Enabling the `bdk-tx` feature without also setting `--cfg bdk_wallet_unstable` in RUSTFLAGS is a compile error with a useful error message. The justfile and CI are updated to pass the `bdk_wallet_unstable` flag in order to test all features. All `create_psbt` and `replace_by_fee` tests are consolidated into a new `tests/create_psbt.rs` gated by `bdk_wallet_unstable`. `.gitignore` is updated to exclude a local `.cargo/config.toml`, and an Unreleased CHANGELOG entry documents the opt-in requirement for users.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #516 +/- ##
==========================================
+ Coverage 81.15% 81.71% +0.55%
==========================================
Files 24 25 +1
Lines 5552 6438 +886
Branches 247 290 +43
==========================================
+ Hits 4506 5261 +755
- Misses 970 1082 +112
- Partials 76 95 +19
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
oleonardolima
left a comment
There was a problem hiding this comment.
I left a few comments, and questions. I'd like to do some manual testing too, not only with the examples in regtest.
The commit history could use some work, by rewriting and restructuring it, it'll help other reviewers.
|
|
||
| [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.
nit: shouldn't this be optional ?
| pub type Rbf = ReplaceTx; | ||
|
|
||
| /// Parameters to create a PSBT. | ||
| // TODO: Can we derive `Clone` for this? |
There was a problem hiding this comment.
nit: are you looking forward to address this in a follow-up ?
| /// Marker type representing the PSBT creation state. | ||
| #[derive(Debug)] | ||
| pub struct CreateTx; | ||
|
|
||
| /// Marker type representing the Replace-By-Fee (RBF) state. | ||
| #[derive(Debug)] | ||
| pub struct ReplaceTx; |
There was a problem hiding this comment.
question: food for thought, do you think having these structs is better than having an enum (as it's representing a state), for example ?
| assert!( | ||
| !txs.is_empty(), | ||
| "replace_txs requires at least one transaction" | ||
| ); |
There was a problem hiding this comment.
fix: it's way better if we return an error instead of panicking here.
There was a problem hiding this comment.
I've been reviewing commit-by commit, so you can ignore this comment now.
| /// Filter [`FullTxOut`]s by the provided closure. | ||
| /// | ||
| /// This option can be used to mark specific outputs unspendable or apply custom UTXO | ||
| /// filtering logic. | ||
| /// | ||
| /// Any txouts for which the `predicate` returns `false` will be excluded from coin selection, | ||
| /// otherwise any coin in the wallet that is mature and spendable will be eligible for | ||
| /// selection. | ||
| pub fn filter_utxos<F>(&mut self, predicate: F) -> &mut Self | ||
| where | ||
| F: Fn(&FullTxOut<ConfirmationBlockTime>) -> bool + Send + Sync + 'static, | ||
| { | ||
| self.utxo_filter = UtxoFilter(Arc::new(predicate)); | ||
| self | ||
| } |
There was a problem hiding this comment.
nit: the method name is a bit odd, it's not filtering anything, it's setting the utxo_filter, updating it to utxo_filter is better.
There was a problem hiding this comment.
I agree, utxo_filter is a clearer name and matches the internal field.
| I: IntoIterator<Item = FullTxOut<ConfirmationBlockTime>> + 'a, | ||
| F: Fn(&FullTxOut<ConfirmationBlockTime>) -> bool + 'a, | ||
| { | ||
| let current_height = params.maturity_height.unwrap_or(self.chain.tip().height()); |
There was a problem hiding this comment.
nit: it'd be clear if you stick to maturity_height, or just height.
| txos.into_iter().filter(move |txo| { | ||
| // Exclude outputs that are manually selected. | ||
| if params.set.contains(&txo.outpoint) { | ||
| return false; | ||
| } | ||
| // Filter outputs according to `policy` fn. | ||
| if !policy(txo) { | ||
| return false; | ||
| } | ||
| // Exclude locked UTXOs. | ||
| if self.is_outpoint_locked(txo.outpoint) { | ||
| return false; | ||
| } | ||
| // Exclude immature outputs. | ||
| if !txo.is_mature(current_height) { | ||
| return false; | ||
| } | ||
| // Exclude spent outputs. | ||
| if txo.spent_by.is_some() { | ||
| return false; | ||
| } | ||
| true | ||
| }) |
There was a problem hiding this comment.
nit: IIRC you could do it lazily by chaining the .filter's instead.
| pub struct PsbtParams<C> { | ||
| /// Set of selected UTXO outpoints. | ||
| /// Set of selected UTXO outpoints, `HashSet` ensures uniqueness | ||
| pub(crate) set: HashSet<OutPoint>, |
There was a problem hiding this comment.
question: woulndn't be using something as select be clearer ?
| SelectionStrategy::SingleRandomDraw => { | ||
| // We should have shuffled candidates earlier, so just select | ||
| // until the target is met. | ||
| // TODO: Can we shuffle candidates here instead using select_with_algo? |
There was a problem hiding this comment.
question: is this something you'd like to address in this PR or in a follow-up ?
There was a problem hiding this comment.
Got it in the next commit :)
| RUSTFLAGS="" cargo check --no-default-features --features miniscript/no-std,bdk_chain/hashbrown --all-targets | ||
| RUSTFLAGS="" cargo check --features {{FEATURES}} --all-targets | ||
| RUSTFLAGS="--cfg bdk_wallet_unstable" cargo clippy --all-features --all-targets -- -D warnings |
There was a problem hiding this comment.
question: why not add add a check-unstable instead ?
evanlinjin
left a comment
There was a problem hiding this comment.
Outstanding review comments from #297.
| /// One of the transactions to be replaced is already confirmed | ||
| TransactionConfirmed(Txid), |
There was a problem hiding this comment.
I don't think this error variant earns it's keep.
-
Most callers would want to check whether the transaction is still unconfirmed before trying to replace it. A caller that doesn't do that obviously wants to create a transaction that conflicts with a confirmed one.
-
The guard is partial. It only catches "already confirmed at build time". The original transaction can be confirmed before the replacement is ready to broadcast.
| // For each txid being replaced, verify that at least one of its original inputs | ||
| // remains in the selected set. A replacement must conflict with every transaction it | ||
| // replaces — two transactions cannot spend the same UTXO. |
There was a problem hiding this comment.
| // For each txid being replaced, verify that at least one of its original inputs | |
| // remains in the selected set. A replacement must conflict with every transaction it | |
| // replaces — two transactions cannot spend the same UTXO. | |
| // A replacement must conflict (double-spend) with each tx it replaces - so require at least | |
| // one of each original's inputs to remain selected. |
wdyt?
| .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.
Do you think we should return an error if an original's descendant cannot have it's fee calculated? The current implementation has a risk of undershooting the RBF fee floor.
| let descendant_fee: Amount = descendants | ||
| .iter() | ||
| .filter_map(|&txid| { | ||
| let tx = self.tx_graph.graph().get_tx(txid)?; |
There was a problem hiding this comment.
| let tx = self.tx_graph.graph().get_tx(txid)?; | |
| let tx = self.tx_graph.graph().get_tx(txid).expect("tx must exist as they were obtained via the TxGraph"); |
We obtained descendants directly from TxGraph. So they must exist.
| /// 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.
notmandatory
left a comment
There was a problem hiding this comment.
I did an initial review (with LLM help) and new changes look good.
One other suggestion, but doesn't need to be done in this PR, is figure out how we want to document experimental and possibly unstable APIs in the rust docs.
The LLM suggested adding:
/// **Unstable**: requires the `bdk-tx` Cargo feature and the
/// `--cfg bdk_wallet_unstable` rustc flag; may change in any minor release.
///
| /// Filter [`FullTxOut`]s by the provided closure. | ||
| /// | ||
| /// This option can be used to mark specific outputs unspendable or apply custom UTXO | ||
| /// filtering logic. | ||
| /// | ||
| /// Any txouts for which the `predicate` returns `false` will be excluded from coin selection, | ||
| /// otherwise any coin in the wallet that is mature and spendable will be eligible for | ||
| /// selection. | ||
| pub fn filter_utxos<F>(&mut self, predicate: F) -> &mut Self | ||
| where | ||
| F: Fn(&FullTxOut<ConfirmationBlockTime>) -> bool + Send + Sync + 'static, | ||
| { | ||
| self.utxo_filter = UtxoFilter(Arc::new(predicate)); | ||
| self | ||
| } |
There was a problem hiding this comment.
I agree, utxo_filter is a clearer name and matches the internal field.
| Custom { | ||
| /// Selection algorithm | ||
| #[allow(clippy::type_complexity)] | ||
| algorithm: Arc<dyn Fn(&mut Selector) -> Result<(), SelectorError>>, |
There was a problem hiding this comment.
I think this custom algo function also needs to be Send + Sync to be consistent with the param type used above in the coin_selection_algorithm function.
| algorithm: Arc<dyn Fn(&mut Selector) -> Result<(), SelectorError>>, | |
| algorithm: Arc<dyn Fn(&mut Selector) -> Result<(), SelectorError> + Send + Sync>, |
| pub struct ReplaceTx; | ||
|
|
||
| /// Alias for [`ReplaceTx`] context marker. | ||
| pub type Rbf = ReplaceTx; |
There was a problem hiding this comment.
nit: do we really need this alias type? In the code Rbf is used everywhere instead of the ReplaceTx marker type. But the docs for ReplaceTx already describe it as used for doing a RBF. My preference would be to remove Rbf and just use ReplaceTx everywhere.
| let err = bdk_coin_select::InsufficientFunds { | ||
| missing: target_amount.to_sat(), | ||
| }; | ||
| return Err(CreatePsbtError::InsufficientFunds(err))?; |
There was a problem hiding this comment.
nit: it looks strange to throw an error while returning an error. The below should be more idiomatic.
| return Err(CreatePsbtError::InsufficientFunds(err))?; | |
| return Err(CreatePsbtError::InsufficientFunds(err).into()); |
Description
This PR introduces
Wallet::create_psbtandWallet::replace_by_fee- a new PSBT construction API built on top ofbdk-tx. This is a second attempt at integrating the planning module that started with #297. The scope is mostly the same and incorporates a number of improvements noted on #297. Notably the entire surface area is gated behind two compile flags.flags:
bdk-txCargo feature--cfg bdk_wallet_unstablerustc flagEnabling
bdk-txwithout also passingbdk_wallet_unstableis a compile error with a clear diagnostic. This allows us to ship the API for early adopters while reserving the right to break it in a minor release.Users can opt in by adding the following to your project's
.cargo/config.toml:and enable the feature in
Cargo.toml:New public API
Wallet::create_psbtWallet::create_psbt_with_rngWallet::replace_by_feeWallet::replace_by_fee_with_rngPsbtParams<C>CisCreateTxorReplaceTx/RbfSelectionStrategyAll,SingleRandomDraw,LowestFee,CustomCreatePsbtErrorcreate_psbtReplaceByFeeErrorreplace_by_feebdk_txre-exportbdk_txTxOrderingis also generalized toTxOrdering<In, Out>to accommodatebdk-tx's typed input/output types.TxOrdering<In, Out>adds default type parameters(
TxIn,TxOut) that are identical to the previous concrete type, so remainssemver-compatible.
Existing callers that don't use the
Customvariant are unaffected.Differences from #297
create_psbtfor Wallet #297 were trimmed; the public API is narrower and more focused.bdk_txre-export -bdk_wallet::bdk_txis re-exported so callers who needbdk-txtypes directly don't need a separatebdk_txdependency.SelectionStrategy::Allreplacesdrain_wallet- modeling "sweep everything" as a strategy is cleaner than aPsbtParamsoption.SelectionStrategy::Custom- users can supply their own coin-selection algorithm as a closure without having to implement a trait.SingleRandomDrawcorrectness fix - shuffling now only runs whenSingleRandomDrawis active; the extrashuffle_slicecalls are removed.bdk_wallet_unstablecfg gate +bdk-txCargo feature - the fullcreate_psbt/replace_by_feesurface is hidden unless explicitly opted into.Changelog notice
Before submitting