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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `BlockId` constructor from height and hash string
- `EvictedTx` type for pairing a transaction ID with an eviction timestamp
- `BdkErrorCode::CannotConnect` and `BdkErrorCode::UnexpectedConnectedToHash` error codes for block application errors
- Expand TxBuilder API for hardware wallet support and advanced transaction construction ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21)):
- `TxBuilder::add_data` for embedding OP_RETURN data in transactions (up to 80 bytes)
- `TxBuilder::only_spend_change` shorthand for `ChangeSpendPolicy::OnlyChange`
- `TxBuilder::current_height` for coinbase maturity checks and anti-fee-sniping locktime
- `TxBuilder::only_witness_utxo` to reduce PSBT size for segwit-only wallets
- `TxBuilder::include_output_redeem_witness_script` for ColdCard/BitBox hardware wallet compatibility
- `TxBuilder::add_global_xpubs` for multisig hardware wallet workflows
- `TxBuilder::set_exact_sequence` for fine-grained nSequence control
- `TxIn::sequence` getter for reading the nSequence value of transaction inputs

## [0.3.0] - 2026-03-16

Expand Down
136 changes: 136 additions & 0 deletions src/bitcoin/tx_builder.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{cell::RefCell, rc::Rc};

use bdk_wallet::{
bitcoin::script::PushBytesBuf,
error::{BuildFeeBumpError, CreateTxError},
AddUtxoError, ChangeSpendPolicy as BdkChangeSpendPolicy, TxOrdering as BdkTxOrdering, Wallet as BdkWallet,
};
Expand Down Expand Up @@ -38,6 +39,12 @@ pub struct TxBuilder {
only_spend_from: bool,
nlocktime: Option<u32>,
version: Option<i32>,
current_height: Option<u32>,
only_witness_utxo: bool,
include_output_redeem_witness_script: bool,
add_global_xpubs: bool,
exact_sequence: Option<u32>,
data: Option<Vec<u8>>,
is_fee_bump: bool,
fee_bump_txid: Option<bdk_wallet::bitcoin::Txid>,
}
Expand All @@ -61,6 +68,12 @@ impl TxBuilder {
only_spend_from: false,
nlocktime: None,
version: None,
current_height: None,
only_witness_utxo: false,
include_output_redeem_witness_script: false,
add_global_xpubs: false,
exact_sequence: None,
data: None,
is_fee_bump: false,
fee_bump_txid: None,
}
Expand Down Expand Up @@ -257,6 +270,79 @@ impl TxBuilder {
self
}

/// Shorthand to set the change policy to [`ChangeSpendPolicy::OnlyChange`].
///
/// This effectively only allows the wallet to spend change outputs.
pub fn only_spend_change(mut self) -> Self {
self.change_policy = Some(ChangeSpendPolicy::OnlyChange);
self
}

/// Set the current blockchain height.
///
/// This will be used to:
/// 1. Set the nLockTime for preventing fee sniping.
/// **Note**: This will be ignored if you manually specify a locktime using [`nlocktime`](Self::nlocktime).
/// 2. Decide whether coinbase outputs are mature or not. If the coinbase outputs are not
/// mature at spending height (`current_height + 1`), they are ignored in coin selection.
/// To spend immature coinbase inputs, manually add them using [`add_utxo`](Self::add_utxo).
///
/// If not provided, the last sync height is used.
pub fn current_height(mut self, height: u32) -> Self {
self.current_height = Some(height);
self
}

/// Only fill in the `witness_utxo` field in PSBT inputs, and remove the `non_witness_utxo`.
///
/// This reduces the PSBT size and is acceptable for segwit-only wallets. Some hardware
/// wallets may prefer or require this.
pub fn only_witness_utxo(mut self) -> Self {
self.only_witness_utxo = true;
self
}

/// Fill in the `redeem_script` and `witness_script` fields of PSBT outputs.
///
/// This is useful for signers that always require these fields, such as ColdCard
/// and BitBox hardware wallets.
pub fn include_output_redeem_witness_script(mut self) -> Self {
self.include_output_redeem_witness_script = true;
self
}

/// Fill in the `PSBT_GLOBAL_XPUB` field with extended keys from both the external
/// and internal descriptors.
///
/// This is useful for offline signers that participate in multisig. Some hardware
/// wallets like BitBox and ColdCard require this.
pub fn add_global_xpubs(mut self) -> Self {
self.add_global_xpubs = true;
self
}

/// Set the exact nSequence value for all transaction inputs.
///
/// This can be used for fine-grained control over time-lock behavior (BIP 68),
/// Replace-By-Fee signaling, and other sequence-dependent features.
pub fn set_exact_sequence(mut self, n_sequence: u32) -> Self {
self.exact_sequence = Some(n_sequence);
self
}

/// Add an OP_RETURN output with arbitrary data to the transaction.
///
/// The data is embedded in an `OP_RETURN` output with a zero-value amount.
/// This is commonly used for timestamping, anchoring data on-chain, or
/// protocol-specific metadata (e.g. Omni, OpenTimestamps).
///
/// The data must be at most 80 bytes (the standard OP_RETURN limit).
/// If the data exceeds 80 bytes, the error will occur when calling `finish()`.
pub fn add_data(mut self, data: &[u8]) -> Self {
self.data = Some(data.to_vec());
self
}

/// Finish building the transaction.
///
/// Returns a new [`Psbt`] per [`BIP174`].
Expand All @@ -281,6 +367,18 @@ impl TxBuilder {
// RBF is enabled by default in BDK 2.x (nSequence = 0xFFFFFFFD).
// No explicit enable_rbf call needed.

if self.only_witness_utxo {
builder.only_witness_utxo();
}

if self.include_output_redeem_witness_script {
builder.include_output_redeem_witness_script();
}

if self.add_global_xpubs {
builder.add_global_xpubs();
}

let psbt = builder.finish()?;
return Ok(psbt.into());
}
Expand Down Expand Up @@ -338,6 +436,44 @@ impl TxBuilder {
builder.version(version);
}

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

if self.only_witness_utxo {
builder.only_witness_utxo();
}

if self.include_output_redeem_witness_script {
builder.include_output_redeem_witness_script();
}

if self.add_global_xpubs {
builder.add_global_xpubs();
}

if let Some(n_sequence) = self.exact_sequence {
builder.set_exact_sequence(bdk_wallet::bitcoin::Sequence(n_sequence));
}

if let Some(data) = &self.data {
if data.len() > 80 {
return Err(BdkError::new(
BdkErrorCode::Unexpected,
format!("OP_RETURN data exceeds 80 bytes (got {})", data.len()),
(),
));
}
let push_bytes = PushBytesBuf::try_from(data.clone()).map_err(|_| {
BdkError::new(
BdkErrorCode::Unexpected,
"OP_RETURN data exceeds script push limit".to_string(),
(),
)
})?;
builder.add_data(&push_bytes);
}

let psbt = builder.finish()?;
Ok(psbt.into())
}
Expand Down
9 changes: 9 additions & 0 deletions src/types/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ impl TxIn {
self.0.total_size()
}

/// The sequence number, which suggests to miners which of two
/// conflicting transactions should be preferred, or 0xFFFFFFFF
/// to ignore this feature. This is generally never used since
/// the miner behaviour cannot be enforced.
#[wasm_bindgen(getter)]
pub fn sequence(&self) -> u32 {
self.0.sequence.0
}

/// Returns true if this input enables the [`absolute::LockTime`] (aka `nLockTime`) of its
/// [`Transaction`].
///
Expand Down
183 changes: 183 additions & 0 deletions tests/node/integration/esplora.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,189 @@ describe(`Esplora client (${network})`, () => {
);
});

describe("TxBuilder advanced options (funded wallet)", () => {
// Note: FeeRate is consumed by wasm-bindgen when passed to a builder method,
// so we create fresh instances for each test instead of using the shared feeRate.
const minFeeRate = () => new FeeRate(BigInt(1));

it("builds a tx with add_data embedding OP_RETURN", () => {
const data = new TextEncoder().encode("bdk-wasm test data");
const recipientAddress = wallet.peek_address("external", 8);
const sendAmount = Amount.from_sat(BigInt(800));

const psbt = wallet
.build_tx()
.fee_rate(minFeeRate())
.add_data(data)
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

// The PSBT should have the recipient output + potentially change + the OP_RETURN output
const outputs = psbt.unsigned_tx.output;
expect(outputs.length).toBeGreaterThanOrEqual(2);

// Find the OP_RETURN output (value = 0, script starts with 0x6a = OP_RETURN)
const opReturnOutput = outputs.find(
(out) =>
out.value.to_sat() === BigInt(0) &&
out.script_pubkey.to_hex_string().startsWith("6a")
);
expect(opReturnOutput).toBeDefined();

// The OP_RETURN script should contain our data
const scriptHex = opReturnOutput!.script_pubkey.to_hex_string();
const dataHex = Buffer.from(data).toString("hex");
expect(scriptHex).toContain(dataHex);
});

it("builds a tx with only_witness_utxo (PSBT has no non_witness_utxo)", () => {
const recipientAddress = wallet.peek_address("external", 9);
const sendAmount = Amount.from_sat(BigInt(800));

const psbt = wallet
.build_tx()
.fee_rate(minFeeRate())
.only_witness_utxo()
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

// The PSBT should be constructable — only_witness_utxo strips the full
// previous transaction from inputs, keeping only the witness_utxo field.
// This is useful for external signers (hardware wallets) that only need
// the witness UTXO. Wallet::sign() requires non_witness_utxo, so we
// verify the PSBT was built correctly without attempting to sign.
expect(psbt.unsigned_tx.input.length).toBeGreaterThan(0);
expect(psbt.unsigned_tx.output.length).toBeGreaterThan(0);
expect(psbt.fee().to_sat()).toBeGreaterThan(BigInt(0));
});

it("builds a tx with include_output_redeem_witness_script", () => {
const recipientAddress = wallet.peek_address("external", 10);
const sendAmount = Amount.from_sat(BigInt(800));

const psbt = wallet
.build_tx()
.fee_rate(minFeeRate())
.include_output_redeem_witness_script()
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

const signed = wallet.sign(psbt, new SignOptions());
expect(signed).toBe(true);

const tx = psbt.extract_tx();
expect(tx.compute_txid()).toBeDefined();
});

it("builds a tx with add_global_xpubs", () => {
const recipientAddress = wallet.peek_address("external", 11);
const sendAmount = Amount.from_sat(BigInt(800));

const psbt = wallet
.build_tx()
.fee_rate(minFeeRate())
.add_global_xpubs()
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

// The PSBT should be constructable and signable with xpubs included
const signed = wallet.sign(psbt, new SignOptions());
expect(signed).toBe(true);
});

it("builds a tx with current_height affecting locktime", () => {
const recipientAddress = wallet.peek_address("external", 12);
const sendAmount = Amount.from_sat(BigInt(800));
const currentBlockHeight = wallet.latest_checkpoint.height;

const psbt = wallet
.build_tx()
.fee_rate(minFeeRate())
.current_height(currentBlockHeight)
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

// The locktime should be set relative to current_height (anti-fee-sniping)
const tx = psbt.unsigned_tx;
expect(tx).toBeDefined();

const signed = wallet.sign(psbt, new SignOptions());
expect(signed).toBe(true);
});

it("builds a tx with set_exact_sequence", () => {
const recipientAddress = wallet.peek_address("external", 13);
const sendAmount = Amount.from_sat(BigInt(800));
const rbfSequence = 0xfffffffd;

const psbt = wallet
.build_tx()
.fee_rate(minFeeRate())
.set_exact_sequence(rbfSequence)
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

// All inputs should have the exact sequence we set
const inputs = psbt.unsigned_tx.input;
expect(inputs.length).toBeGreaterThan(0);
for (const input of inputs) {
expect(input.sequence).toBe(rbfSequence);
}

const signed = wallet.sign(psbt, new SignOptions());
expect(signed).toBe(true);
});

it("combines multiple new options in a single transaction", () => {
const recipientAddress = wallet.peek_address("external", 14);
const sendAmount = Amount.from_sat(BigInt(800));
const data = new TextEncoder().encode("multi-option test");

const psbt = wallet
.build_tx()
.fee_rate(minFeeRate())
.add_data(data)
.include_output_redeem_witness_script()
.add_global_xpubs()
.current_height(wallet.latest_checkpoint.height)
.set_exact_sequence(0xfffffffd)
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

// Verify OP_RETURN is present
const outputs = psbt.unsigned_tx.output;
const hasOpReturn = outputs.some(
(out) =>
out.value.to_sat() === BigInt(0) &&
out.script_pubkey.to_hex_string().startsWith("6a")
);
expect(hasOpReturn).toBe(true);

// Verify sequence is set correctly
for (const input of psbt.unsigned_tx.input) {
expect(input.sequence).toBe(0xfffffffd);
}

// Should still be signable (no only_witness_utxo, so non_witness_utxo is present)
const signed = wallet.sign(psbt, new SignOptions());
expect(signed).toBe(true);
});
});

it("signs and finalizes a PSBT separately", () => {
const recipientAddress = wallet.peek_address("external", 6);
const sendAmount = Amount.from_sat(BigInt(800));
Expand Down
Loading
Loading