Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Expand TxBuilder API ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21)):
- `fee_absolute` for setting absolute fee amounts
- `add_utxo` and `add_utxos` for must-spend UTXOs
- `only_spend_from` to restrict to manually added UTXOs
- `enable_rbf` and `enable_rbf_with_sequence` for BIP 125 signaling
- `nlocktime` for setting absolute locktime
- `version` for setting transaction version
- `change_policy` and `do_not_spend_change` for controlling change output usage
- `ChangeSpendPolicy` enum (`ChangeAllowed`, `OnlyChange`, `ChangeForbidden`)
- `Wallet::build_fee_bump` for creating RBF fee-bump transactions ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21))
- `Wallet::create_single` for single-descriptor wallets ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21))
- `Wallet::mark_used` and `Wallet::unmark_used` for address usage management ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21))
- `Wallet::insert_txout` for providing external TxOut values for fee calculation ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21))
- `BuildFeeBumpError` variants: `TransactionNotFound`, `TransactionConfirmed`, `IrreplaceableTransaction`, `FeeRateUnavailable`, `InvalidOutputIndex`
- `WalletEvent` type and `Wallet::apply_update_events` for reacting to wallet state changes ([#19](https://github.com/bitcoindevkit/bdk-wasm/issues/19))
- Upgrade BDK to 2.3.0 with new API wrappers ([#14](https://github.com/bitcoindevkit/bdk-wasm/pull/14)):
- `Wallet::create_from_two_path_descriptor` (BIP-389 multipath descriptors)
Expand Down
229 changes: 224 additions & 5 deletions src/bitcoin/tx_builder.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
use std::{cell::RefCell, rc::Rc};

use bdk_wallet::{error::CreateTxError, TxOrdering as BdkTxOrdering, Wallet as BdkWallet};
use bdk_wallet::{
error::{BuildFeeBumpError, CreateTxError},
AddUtxoError, ChangeSpendPolicy as BdkChangeSpendPolicy, TxOrdering as BdkTxOrdering, Wallet as BdkWallet,
};
use serde::Serialize;
use wasm_bindgen::prelude::wasm_bindgen;

use crate::types::{Amount, BdkError, BdkErrorCode, FeeRate, OutPoint, Psbt, Recipient, ScriptBuf};

/// Fee policy: either a rate (sat/vB) or an absolute amount.
enum FeePolicy {
Rate(FeeRate),
Absolute(Amount),
}

/// A transaction builder.
///
/// A `TxBuilder` is created by calling [`build_tx`] or [`build_fee_bump`] on a wallet. After
Expand All @@ -17,13 +26,20 @@ use crate::types::{Amount, BdkError, BdkErrorCode, FeeRate, OutPoint, Psbt, Reci
pub struct TxBuilder {
wallet: Rc<RefCell<BdkWallet>>,
recipients: Vec<Recipient>,
utxos: Vec<OutPoint>,
unspendable: Vec<OutPoint>,
fee_rate: FeeRate,
fee_policy: FeePolicy,
drain_wallet: bool,
drain_to: Option<ScriptBuf>,
allow_dust: bool,
ordering: TxOrdering,
min_confirmations: Option<u32>,
change_policy: Option<ChangeSpendPolicy>,
only_spend_from: bool,
nlocktime: Option<u32>,
version: Option<i32>,
is_fee_bump: bool,
fee_bump_txid: Option<bdk_wallet::bitcoin::Txid>,
}

#[wasm_bindgen]
Expand All @@ -33,16 +49,30 @@ impl TxBuilder {
TxBuilder {
wallet,
recipients: vec![],
utxos: vec![],
unspendable: vec![],
fee_rate: FeeRate::new(1),
fee_policy: FeePolicy::Rate(FeeRate::new(1)),
drain_wallet: false,
allow_dust: false,
drain_to: None,
ordering: BdkTxOrdering::default().into(),
min_confirmations: None,
change_policy: None,
only_spend_from: false,
nlocktime: None,
version: None,
is_fee_bump: false,
fee_bump_txid: None,
}
}

pub(crate) fn new_fee_bump(wallet: Rc<RefCell<BdkWallet>>, txid: bdk_wallet::bitcoin::Txid) -> TxBuilder {
let mut builder = TxBuilder::new(wallet);
builder.is_fee_bump = true;
builder.fee_bump_txid = Some(txid);
builder
}

/// Replace the recipients already added with a new list
pub fn set_recipients(mut self, recipients: Vec<Recipient>) -> Self {
self.recipients = recipients;
Expand All @@ -55,6 +85,30 @@ impl TxBuilder {
self
}

/// Add a UTXO to the internal list of UTXOs that **must** be spent.
///
/// These have priority over the "unspendable" UTXOs, meaning that if a UTXO is present both
/// in the "UTXOs" and the "unspendable" list, it will be spent.
pub fn add_utxo(mut self, outpoint: OutPoint) -> Self {
self.utxos.push(outpoint);
self
}

/// Add a list of UTXOs to the internal list of UTXOs that **must** be spent.
pub fn add_utxos(mut self, outpoints: Vec<OutPoint>) -> Self {
self.utxos.extend(outpoints);
self
}

/// Only spend UTXOs added by [`add_utxo`](Self::add_utxo).
///
/// The wallet will **not** add additional UTXOs to the transaction even if they are needed
/// to make the transaction valid.
pub fn only_spend_from(mut self) -> Self {
self.only_spend_from = true;
self
}

/// Replace the internal list of unspendable utxos with a new list
pub fn unspendable(mut self, unspendable: Vec<OutPoint>) -> Self {
self.unspendable = unspendable;
Expand All @@ -78,7 +132,20 @@ impl TxBuilder {
/// overshoot it slightly since adding a change output to drain the remaining
/// excess might not be viable.
pub fn fee_rate(mut self, fee_rate: FeeRate) -> Self {
self.fee_rate = fee_rate;
self.fee_policy = FeePolicy::Rate(fee_rate);
self
}

/// Set an absolute fee.
///
/// The `fee_absolute` method refers to the absolute transaction fee in satoshis.
/// If both `fee_absolute` and `fee_rate` are set, whichever is called last takes precedence.
///
/// Note that this is really a minimum absolute fee -- it's possible to
/// overshoot it slightly since adding a change output to drain the remaining
/// excess might not be viable.
pub fn fee_absolute(mut self, fee: Amount) -> Self {
self.fee_policy = FeePolicy::Absolute(fee);
self
}

Expand Down Expand Up @@ -137,24 +204,121 @@ impl TxBuilder {
self
}

/// Set the change spending policy.
///
/// Controls whether change outputs from previous transactions can be spent.
pub fn change_policy(mut self, change_policy: ChangeSpendPolicy) -> Self {
self.change_policy = Some(change_policy);
self
}

/// Shorthand to set the change policy to [`ChangeSpendPolicy::ChangeForbidden`].
///
/// This effectively forbids the wallet from spending change outputs.
pub fn do_not_spend_change(mut self) -> Self {
self.change_policy = Some(ChangeSpendPolicy::ChangeForbidden);
self
}

/// Enable Replace-By-Fee (BIP 125) signaling.
///
/// **Note:** RBF is enabled by default in BDK 2.x (nSequence = `0xFFFFFFFD`).
/// This method is kept for API compatibility but is effectively a no-op.
pub fn enable_rbf(self) -> Self {
// RBF is enabled by default in BDK 2.x
self
}

/// Enable Replace-By-Fee (BIP 125) with a specific nSequence value.
///
/// **Note:** RBF is enabled by default in BDK 2.x. Custom nSequence values
/// are not currently supported through this builder. This method is kept for
/// API compatibility but is effectively a no-op.
pub fn enable_rbf_with_sequence(self, _nsequence: u32) -> Self {
// RBF is enabled by default in BDK 2.x; custom sequence not supported
self
}

/// Set an absolute locktime for the transaction.
///
/// This is used to set a specific block height or timestamp before which
/// the transaction cannot be mined.
pub fn nlocktime(mut self, locktime: u32) -> Self {
self.nlocktime = Some(locktime);
self
}

/// Set the transaction version.
///
/// By default, transactions are created with version 1. Set to 2 if you need
/// features like OP_CSV (BIP 68/112/113).
pub fn version(mut self, version: i32) -> Self {
self.version = Some(version);
self
}

/// Finish building the transaction.
///
/// Returns a new [`Psbt`] per [`BIP174`].
pub fn finish(self) -> Result<Psbt, BdkError> {
let mut wallet = self.wallet.borrow_mut();

if self.is_fee_bump {
let txid = self.fee_bump_txid.expect("fee bump txid must be set");
let mut builder = wallet.build_fee_bump(txid)?;

match self.fee_policy {
FeePolicy::Rate(rate) => {
builder.fee_rate(rate.into());
}
FeePolicy::Absolute(amount) => {
builder.fee_absolute(amount.into());
}
}

builder.ordering(self.ordering.into()).allow_dust(self.allow_dust);

// RBF is enabled by default in BDK 2.x (nSequence = 0xFFFFFFFD).
// No explicit enable_rbf call needed.

let psbt = builder.finish()?;
return Ok(psbt.into());
}

let mut builder = wallet.build_tx();

builder
.ordering(self.ordering.into())
.set_recipients(self.recipients.into_iter().map(Into::into).collect())
.unspendable(self.unspendable.into_iter().map(Into::into).collect())
.fee_rate(self.fee_rate.into())
.allow_dust(self.allow_dust);

match self.fee_policy {
FeePolicy::Rate(rate) => {
builder.fee_rate(rate.into());
}
FeePolicy::Absolute(amount) => {
builder.fee_absolute(amount.into());
}
}

if !self.utxos.is_empty() {
let outpoints: Vec<_> = self.utxos.into_iter().map(Into::into).collect();
builder.add_utxos(&outpoints).map_err(BdkError::from)?;
}

if self.only_spend_from {
builder.manually_selected_only();
}

if let Some(min_confirms) = self.min_confirmations {
builder.exclude_below_confirmations(min_confirms);
}

if let Some(policy) = self.change_policy {
builder.change_policy(policy.into());
}

if self.drain_wallet {
builder.drain_wallet();
}
Expand All @@ -163,6 +327,17 @@ impl TxBuilder {
builder.drain_to(drain_recipient.into());
}

// RBF is enabled by default in BDK 2.x (nSequence = 0xFFFFFFFD).
// No explicit enable_rbf call needed.

if let Some(locktime) = self.nlocktime {
builder.nlocktime(bdk_wallet::bitcoin::absolute::LockTime::from_consensus(locktime));
}

if let Some(version) = self.version {
builder.version(version);
}

let psbt = builder.finish()?;
Ok(psbt.into())
}
Expand Down Expand Up @@ -198,6 +373,28 @@ impl From<TxOrdering> for BdkTxOrdering {
}
}

/// Policy regarding the use of change outputs when creating a transaction.
#[derive(Clone)]
#[wasm_bindgen]
pub enum ChangeSpendPolicy {
/// Use both change and non-change outputs (default)
ChangeAllowed,
/// Only use change outputs
OnlyChange,
/// Do not use any change outputs
ChangeForbidden,
}

impl From<ChangeSpendPolicy> for BdkChangeSpendPolicy {
fn from(policy: ChangeSpendPolicy) -> Self {
match policy {
ChangeSpendPolicy::ChangeAllowed => BdkChangeSpendPolicy::ChangeAllowed,
ChangeSpendPolicy::OnlyChange => BdkChangeSpendPolicy::OnlyChange,
ChangeSpendPolicy::ChangeForbidden => BdkChangeSpendPolicy::ChangeForbidden,
}
}
}

/// Wallet's UTXO set is not enough to cover recipient's requested plus fee.
#[wasm_bindgen]
#[derive(Clone, Serialize)]
Expand All @@ -208,6 +405,28 @@ pub struct InsufficientFunds {
pub available: Amount,
}

impl From<AddUtxoError> for BdkError {
fn from(e: AddUtxoError) -> Self {
BdkError::new(BdkErrorCode::UnknownUtxo, e.to_string(), ())
}
}

impl From<BuildFeeBumpError> for BdkError {
fn from(e: BuildFeeBumpError) -> Self {
use BuildFeeBumpError::*;
match &e {
UnknownUtxo(_) => BdkError::new(BdkErrorCode::UnknownUtxo, e.to_string(), ()),
TransactionNotFound(txid) => BdkError::new(BdkErrorCode::TransactionNotFound, e.to_string(), txid),
TransactionConfirmed(txid) => BdkError::new(BdkErrorCode::TransactionConfirmed, e.to_string(), txid),
IrreplaceableTransaction(txid) => {
BdkError::new(BdkErrorCode::IrreplaceableTransaction, e.to_string(), txid)
}
FeeRateUnavailable => BdkError::new(BdkErrorCode::FeeRateUnavailable, e.to_string(), ()),
InvalidOutputIndex(outpoint) => BdkError::new(BdkErrorCode::InvalidOutputIndex, e.to_string(), outpoint),
}
}
}

impl From<CreateTxError> for BdkError {
fn from(e: CreateTxError) -> Self {
use CreateTxError::*;
Expand Down
Loading