From 9908d0b009070055dc3b278a30d059aadbcb63f2 Mon Sep 17 00:00:00 2001 From: Toshi Date: Tue, 31 Mar 2026 10:12:30 +0000 Subject: [PATCH] feat(tx_builder): add hardware wallet PSBT options, OP_RETURN data, and sequence control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the TxBuilder API with options important for hardware wallet workflows and advanced transaction construction: - add_data: embed OP_RETURN data (up to 80 bytes) for timestamping, protocol anchoring, and metadata - only_witness_utxo: reduce PSBT size for segwit-only wallets - include_output_redeem_witness_script: ColdCard/BitBox compatibility - add_global_xpubs: multisig hardware wallet support (PSBT_GLOBAL_XPUB) - current_height: coinbase maturity checks and anti-fee-sniping - set_exact_sequence: fine-grained nSequence control for RBF/CSV - only_spend_change: shorthand for OnlyChange spend policy Also adds TxIn::sequence getter for reading input nSequence values, enabling callers to verify sequence values on built transactions. All new options are applied in both regular and fee-bump builder paths where applicable. The PSBT-related options (only_witness_utxo, include_output_redeem_witness_script, add_global_xpubs) are forwarded to the fee-bump builder as well. Includes unit tests (wallet.test.ts) for all new options including chaining verification and add_data validation, plus integration tests (esplora.test.ts) that exercise the options with real funded wallets in regtest. Closes #21 (partial — TxBuilder area) --- CHANGELOG.md | 9 ++ src/bitcoin/tx_builder.rs | 136 ++++++++++++++++++ src/types/input.rs | 9 ++ tests/node/integration/esplora.test.ts | 183 +++++++++++++++++++++++++ tests/node/integration/wallet.test.ts | 143 +++++++++++++++++++ 5 files changed, 480 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a102a..43f5a56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/bitcoin/tx_builder.rs b/src/bitcoin/tx_builder.rs index 37b1b29..e92a031 100644 --- a/src/bitcoin/tx_builder.rs +++ b/src/bitcoin/tx_builder.rs @@ -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, }; @@ -38,6 +39,12 @@ pub struct TxBuilder { only_spend_from: bool, nlocktime: Option, version: Option, + current_height: Option, + only_witness_utxo: bool, + include_output_redeem_witness_script: bool, + add_global_xpubs: bool, + exact_sequence: Option, + data: Option>, is_fee_bump: bool, fee_bump_txid: Option, } @@ -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, } @@ -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`]. @@ -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()); } @@ -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()) } diff --git a/src/types/input.rs b/src/types/input.rs index 769a762..1f95357 100644 --- a/src/types/input.rs +++ b/src/types/input.rs @@ -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`]. /// diff --git a/tests/node/integration/esplora.test.ts b/tests/node/integration/esplora.test.ts index 1deb6ea..bf46f66 100644 --- a/tests/node/integration/esplora.test.ts +++ b/tests/node/integration/esplora.test.ts @@ -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)); diff --git a/tests/node/integration/wallet.test.ts b/tests/node/integration/wallet.test.ts index 7728496..dc276f5 100644 --- a/tests/node/integration/wallet.test.ts +++ b/tests/node/integration/wallet.test.ts @@ -234,6 +234,144 @@ describe("Wallet", () => { }).toThrow(); }); + it("builds a tx with only_spend_change shorthand", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .only_spend_change() + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds + }); + + it("sets current_height on the builder", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .current_height(850000) + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds, but current_height chains correctly + }); + + it("chains only_witness_utxo without error", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .only_witness_utxo() + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds, but option chains correctly + }); + + it("chains include_output_redeem_witness_script without error", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .include_output_redeem_witness_script() + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds, but option chains correctly + }); + + it("chains add_global_xpubs without error", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .add_global_xpubs() + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds, but option chains correctly + }); + + it("sets set_exact_sequence on the builder", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .set_exact_sequence(0xfffffffd) + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds, but option chains correctly + }); + + it("add_data rejects data exceeding 80 bytes at finish()", () => { + const oversizedData = new Uint8Array(81).fill(0xff); + + // add_data chains normally; the >80 byte validation happens in finish() + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .add_data(oversizedData) + .add_recipient( + new Recipient(recipientAddress.script_pubkey, Amount.from_sat(BigInt(50000))) + ) + .finish(); + }).toThrow(); + }); + + it("add_data accepts valid data up to 80 bytes", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + const data = new TextEncoder().encode("hello bdk-wasm"); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .add_data(data) + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds, but add_data chained successfully + }); + + it("add_data accepts empty data", () => { + const sendAmount = Amount.from_sat(BigInt(50000)); + const emptyData = new Uint8Array(0); + + expect(() => { + wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .add_data(emptyData) + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) + .finish(); + }).toThrow(); // No funds + }); + it("chains all builder options together", () => { const sendAmount = Amount.from_sat(BigInt(50000)); @@ -245,6 +383,11 @@ describe("Wallet", () => { .enable_rbf() .nlocktime(800000) .version(2) + .current_height(850000) + .only_witness_utxo() + .include_output_redeem_witness_script() + .add_global_xpubs() + .set_exact_sequence(0xfffffffd) .change_policy(ChangeSpendPolicy.ChangeAllowed) .add_recipient( new Recipient(recipientAddress.script_pubkey, sendAmount)