From aaa9e2c17c1ee2f91ce497d85109216a3135452c Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 6 Aug 2025 18:13:28 +0200 Subject: [PATCH 1/3] done --- src/bitcoin/tx_builder.rs | 51 ++++++++++++++++-- src/types/address.rs | 28 +++++++++- src/types/psbt.rs | 66 +++++++++++++++++++++--- tests/node/integration/errors.test.ts | 51 ++++++++++++++++++ tests/node/integration/esplora.test.ts | 30 ++++++++++- tests/node/integration/utilities.test.ts | 30 ++++++++++- tests/node/integration/wallet.test.ts | 40 -------------- 7 files changed, 239 insertions(+), 57 deletions(-) create mode 100644 tests/node/integration/errors.test.ts diff --git a/src/bitcoin/tx_builder.rs b/src/bitcoin/tx_builder.rs index 4865ea5..8437713 100644 --- a/src/bitcoin/tx_builder.rs +++ b/src/bitcoin/tx_builder.rs @@ -1,10 +1,12 @@ use std::{cell::RefCell, rc::Rc}; -use bdk_wallet::{bitcoin::ScriptBuf, error::CreateTxError, Wallet as BdkWallet}; +use bdk_wallet::{ + bitcoin::ScriptBuf as BdkScriptBuf, error::CreateTxError, TxOrdering as BdkTxOrdering, Wallet as BdkWallet, +}; use serde::Serialize; use wasm_bindgen::prelude::wasm_bindgen; -use crate::types::{Address, Amount, BdkError, BdkErrorCode, FeeRate, OutPoint, Psbt, Recipient}; +use crate::types::{Amount, BdkError, BdkErrorCode, FeeRate, OutPoint, Psbt, Recipient, ScriptBuf}; /// A transaction builder. /// @@ -20,8 +22,9 @@ pub struct TxBuilder { unspendable: Vec, fee_rate: FeeRate, drain_wallet: bool, - drain_to: Option, + drain_to: Option, allow_dust: bool, + ordering: BdkTxOrdering, } #[wasm_bindgen] @@ -36,6 +39,7 @@ impl TxBuilder { drain_wallet: false, allow_dust: false, drain_to: None, + ordering: BdkTxOrdering::default(), } } @@ -95,8 +99,8 @@ impl TxBuilder { /// /// If you choose not to set any recipients, you should provide the utxos that the /// transaction should spend via [`add_utxos`]. - pub fn drain_to(mut self, address: Address) -> Self { - self.drain_to = Some(address.script_pubkey()); + pub fn drain_to(mut self, script_pubkey: ScriptBuf) -> Self { + self.drain_to = Some(script_pubkey.into()); self } @@ -108,6 +112,12 @@ impl TxBuilder { self } + /// Choose the ordering for inputs and outputs of the transaction + pub fn ordering(mut self, ordering: TxOrdering) -> Self { + self.ordering = ordering.into(); + self + } + /// Finish building the transaction. /// /// Returns a new [`Psbt`] per [`BIP174`]. @@ -116,6 +126,7 @@ impl TxBuilder { 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()) @@ -134,6 +145,36 @@ impl TxBuilder { } } +/// Ordering of the transaction's inputs and outputs +#[derive(Clone, Default)] +#[wasm_bindgen] +pub enum TxOrdering { + /// Randomized (default) + #[default] + Shuffle, + /// Unchanged + Untouched, +} + +impl From for TxOrdering { + fn from(ordering: BdkTxOrdering) -> Self { + match ordering { + BdkTxOrdering::Shuffle => TxOrdering::Shuffle, + BdkTxOrdering::Untouched => TxOrdering::Untouched, + _ => panic!("Unsupported ordering"), + } + } +} + +impl From for BdkTxOrdering { + fn from(ordering: TxOrdering) -> Self { + match ordering { + TxOrdering::Shuffle => BdkTxOrdering::Shuffle, + TxOrdering::Untouched => BdkTxOrdering::Untouched, + } + } +} + /// Wallet's UTXO set is not enough to cover recipient's requested plus fee. #[wasm_bindgen] #[derive(Clone, Serialize)] diff --git a/src/types/address.rs b/src/types/address.rs index 48bbeeb..05d93f2 100644 --- a/src/types/address.rs +++ b/src/types/address.rs @@ -4,7 +4,10 @@ use bdk_wallet::{ bitcoin::{Address as BdkAddress, AddressType as BdkAddressType, Network as BdkNetwork, ScriptBuf as BdkScriptBuf}, AddressInfo as BdkAddressInfo, }; -use bitcoin::address::ParseError; +use bitcoin::{ + address::ParseError, + hashes::{sha256, Hash}, +}; use wasm_bindgen::prelude::wasm_bindgen; use crate::{ @@ -96,6 +99,16 @@ impl Address { pub fn to_string(&self) -> String { self.0.to_string() } + + #[wasm_bindgen(getter)] + pub fn script_pubkey(&self) -> ScriptBuf { + self.0.script_pubkey().into() + } + + #[wasm_bindgen(getter)] + pub fn scripthash(&self) -> String { + sha256::Hash::hash(self.0.script_pubkey().as_bytes()).to_string() + } } impl From for Address { @@ -133,6 +146,7 @@ impl From for BdkError { /// `ScriptBuf` is the most common script type that has the ownership over the contents of the /// script. It has a close relationship with its borrowed counterpart, [`Script`]. #[wasm_bindgen] +#[derive(Clone)] pub struct ScriptBuf(BdkScriptBuf); impl Deref for ScriptBuf { @@ -154,6 +168,18 @@ impl ScriptBuf { pub fn as_bytes(&self) -> Vec { self.0.as_bytes().to_vec() } + + pub fn to_asm_string(&self) -> String { + self.0.to_asm_string() + } + + pub fn to_hex_string(&self) -> String { + self.0.to_hex_string() + } + + pub fn is_op_return(&self) -> bool { + self.0.is_op_return() + } } impl From for ScriptBuf { diff --git a/src/types/psbt.rs b/src/types/psbt.rs index 6f03cfc..b0a241f 100644 --- a/src/types/psbt.rs +++ b/src/types/psbt.rs @@ -10,6 +10,7 @@ use bdk_wallet::{ use wasm_bindgen::prelude::wasm_bindgen; use crate::result::JsResult; +use crate::types::ScriptBuf; use super::{Address, Amount, FeeRate, Transaction}; @@ -33,11 +34,25 @@ impl DerefMut for Psbt { #[wasm_bindgen] impl Psbt { + /// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information. + /// + /// ## Errors + /// + /// [`ExtractTxError`] variants will contain either the [`Psbt`] itself or the [`Transaction`] + /// that was extracted. These can be extracted from the Errors in order to recover. + /// See the error documentation for info on the variants. In general, it covers large fees. + pub fn extract_tx_fee_rate_limit(self) -> JsResult { + let tx = self.0.extract_tx_fee_rate_limit()?; + Ok(tx.into()) + } + + /// An alias for [`extract_tx_fee_rate_limit`]. pub fn extract_tx(self) -> JsResult { let tx = self.0.extract_tx()?; Ok(tx.into()) } + /// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information. pub fn extract_tx_with_fee_rate_limit(self, max_fee_rate: FeeRate) -> JsResult { let tx = self.0.extract_tx_with_fee_rate_limit(max_fee_rate.into())?; Ok(tx.into()) @@ -48,16 +63,41 @@ impl Psbt { Ok(fee.into()) } + /// The total transaction fee amount, sum of input amounts minus sum of output amounts, in sats. + /// If the PSBT is missing a TxOut for an input returns None. pub fn fee_amount(&self) -> Option { let fee_amount = self.0.fee_amount(); fee_amount.map(Into::into) } + /// The transaction's fee rate. This value will only be accurate if calculated AFTER the + /// `Psbt` is finalized and all witness/signature data is added to the transaction. + /// If the PSBT is missing a TxOut for an input returns None. pub fn fee_rate(&self) -> Option { let fee_rate = self.0.fee_rate(); fee_rate.map(Into::into) } + /// The version number of this PSBT. If omitted, the version number is 0. + #[wasm_bindgen(getter)] + pub fn version(&self) -> u32 { + self.0.version + } + + /// Combines this [`Psbt`] with `other` PSBT as described by BIP 174. In-place. + /// + /// In accordance with BIP 174 this function is commutative i.e., `A.combine(B) == B.combine(A)` + pub fn combine(&mut self, other: Psbt) -> JsResult<()> { + self.0.combine(other.into())?; + Ok(()) + } + + /// The unsigned transaction, scriptSigs and witnesses for each input must be empty. + #[wasm_bindgen(getter)] + pub fn unsigned_tx(&self) -> Transaction { + self.0.unsigned_tx.clone().into() + } + /// Serialize the PSBT to a string in base64 format #[allow(clippy::inherent_to_string)] #[wasm_bindgen(js_name = toString)] @@ -92,30 +132,40 @@ impl From for BdkPsbt { #[wasm_bindgen] #[derive(Clone)] pub struct Recipient { - address: Address, - amount: Amount, + script_pubkey: BdkScriptBuf, + amount: BdkAmount, } #[wasm_bindgen] impl Recipient { #[wasm_bindgen(constructor)] - pub fn new(address: Address, amount: Amount) -> Self { - Recipient { address, amount } + pub fn new(script_pubkey: ScriptBuf, amount: Amount) -> Self { + Recipient { + script_pubkey: script_pubkey.into(), + amount: amount.into(), + } + } + + pub fn from_address(address: Address, amount: Amount) -> Self { + Recipient { + script_pubkey: address.script_pubkey().into(), + amount: amount.into(), + } } #[wasm_bindgen(getter)] - pub fn address(&self) -> Address { - self.address.clone() + pub fn script_pubkey(&self) -> ScriptBuf { + self.script_pubkey.clone().into() } #[wasm_bindgen(getter)] pub fn amount(&self) -> Amount { - self.amount + self.amount.into() } } impl From for (BdkScriptBuf, BdkAmount) { fn from(r: Recipient) -> Self { - (r.address().script_pubkey(), r.amount().into()) + (r.script_pubkey.clone(), r.amount) } } diff --git a/tests/node/integration/errors.test.ts b/tests/node/integration/errors.test.ts new file mode 100644 index 0000000..848d34c --- /dev/null +++ b/tests/node/integration/errors.test.ts @@ -0,0 +1,51 @@ +import { + Address, + Amount, + BdkError, + BdkErrorCode, +} from "../../../pkg/bitcoindevkit"; +import type { Network } from "../../../pkg/bitcoindevkit"; + +describe("Wallet", () => { + const network: Network = "testnet"; + + it("catches fine-grained address errors", () => { + try { + Address.from_string( + "tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v", + "bitcoin" + ); + } catch (error) { + expect(error).toBeInstanceOf(BdkError); + + const { code, message, data } = error; + expect(code).toBe(BdkErrorCode.NetworkValidation); + expect(message.startsWith("validation error")).toBe(true); + expect(data).toBeUndefined(); + } + + try { + Address.from_string("notAnAddress", network); + } catch (error) { + expect(error).toBeInstanceOf(BdkError); + + const { code, message, data } = error; + expect(code).toBe(BdkErrorCode.Base58); + expect(message.startsWith("base58 error")).toBe(true); + expect(data).toBeUndefined(); + } + }); + + it("catches fine-grained amount errors", () => { + try { + Amount.from_btc(-100000000); + } catch (error) { + expect(error).toBeInstanceOf(BdkError); + + const { code, message, data } = error; + expect(code).toBe(BdkErrorCode.OutOfRange); + expect(message.startsWith("amount out of range")).toBe(true); + expect(data).toBeUndefined(); + } + }); +}); diff --git a/tests/node/integration/esplora.test.ts b/tests/node/integration/esplora.test.ts index 610c064..803a03d 100644 --- a/tests/node/integration/esplora.test.ts +++ b/tests/node/integration/esplora.test.ts @@ -8,6 +8,8 @@ import { UnconfirmedTx, Wallet, SignOptions, + Psbt, + TxOrdering, } from "../../../pkg/bitcoindevkit"; // Tests are expected to run in order @@ -69,7 +71,7 @@ describe("Esplora client", () => { const psbt = wallet .build_tx() .fee_rate(feeRate) - .add_recipient(new Recipient(recipientAddress, sendAmount)) + .add_recipient(new Recipient(recipientAddress.script_pubkey, sendAmount)) .finish(); expect(psbt.fee().to_sat()).toBeGreaterThan(100); // We cannot know the exact fees @@ -105,4 +107,30 @@ describe("Esplora client", () => { .finish(); }).toThrow(); }); + + it("fills inputs of an output-only Psbt", () => { + const psbtBase64 = + "cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUArpyBMj+3+/wQDj+orDWG4y4yfUAAAAAAAAAAAA="; + const template = Psbt.from_string(psbtBase64); + + let builder = wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .ordering(TxOrdering.Untouched); + + for (const txout of template.unsigned_tx.output) { + if (wallet.is_mine(txout.script_pubkey)) { + builder = builder.drain_to(txout.script_pubkey); + } else { + const recipient = new Recipient(txout.script_pubkey, txout.value); + builder = builder.add_recipient(recipient); + } + } + + const psbt = builder.finish(); + expect(psbt.unsigned_tx.output).toHaveLength( + template.unsigned_tx.output.length + ); + expect(psbt.unsigned_tx.tx_out(2).value.to_btc()).toBeGreaterThan(0); + }); }); diff --git a/tests/node/integration/utilities.test.ts b/tests/node/integration/utilities.test.ts index ed1a83d..fb8f8e6 100644 --- a/tests/node/integration/utilities.test.ts +++ b/tests/node/integration/utilities.test.ts @@ -3,6 +3,7 @@ import { Network, seed_to_descriptor, seed_to_xpriv, + Wallet, xpriv_to_descriptor, xpub_to_descriptor, } from "../../../pkg/bitcoindevkit"; @@ -10,11 +11,36 @@ import { mnemonicToSeedSync } from "bip39"; describe("Utilities", () => { const addressType: AddressType = "p2wpkh"; - const network: Network = "testnet"; + const network: Network = "bitcoin"; const seed = mnemonicToSeedSync( - "journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose" + "spread raise short crane omit tent fringe mandate neglect detail suspect cradle" ); + it.only("test", async () => { + const desc = seed_to_descriptor(seed, network, "p2wpkh"); + + const w = Wallet.create(network, desc.external, desc.internal); + const eaddresses = w.reveal_addresses_to("external", 2); + const iaddresses = w.reveal_addresses_to("internal", 2); + + eaddresses.forEach((add) => { + console.log( + add.address.toString(), + add.address.scripthash, + add.address.script_pubkey.toString(), + add.address.script_pubkey.to_hex_string() + ); + }); + iaddresses.forEach((add) => { + console.log( + add.address.toString(), + add.address.scripthash, + add.address.script_pubkey.toString(), + add.address.script_pubkey.to_hex_string() + ); + }); + }); + it("generates xpriv from seed", async () => { const xpriv = seed_to_xpriv(seed, network); diff --git a/tests/node/integration/wallet.test.ts b/tests/node/integration/wallet.test.ts index 0af4ae3..f2acbbe 100644 --- a/tests/node/integration/wallet.test.ts +++ b/tests/node/integration/wallet.test.ts @@ -75,44 +75,4 @@ describe("Wallet", () => { expect(data.available).toBeDefined(); } }); - - it("catches fine-grained address errors", () => { - try { - Address.from_string( - "tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v", - "bitcoin" - ); - } catch (error) { - expect(error).toBeInstanceOf(BdkError); - - const { code, message, data } = error; - expect(code).toBe(BdkErrorCode.NetworkValidation); - expect(message.startsWith("validation error")).toBe(true); - expect(data).toBeUndefined(); - } - - try { - Address.from_string("notAnAddress", network); - } catch (error) { - expect(error).toBeInstanceOf(BdkError); - - const { code, message, data } = error; - expect(code).toBe(BdkErrorCode.Base58); - expect(message.startsWith("base58 error")).toBe(true); - expect(data).toBeUndefined(); - } - }); - - it("catches fine-grained amount errors", () => { - try { - Amount.from_btc(-100000000); - } catch (error) { - expect(error).toBeInstanceOf(BdkError); - - const { code, message, data } = error; - expect(code).toBe(BdkErrorCode.OutOfRange); - expect(message.startsWith("amount out of range")).toBe(true); - expect(data).toBeUndefined(); - } - }); }); From 7faf3b60742647f793001b865137a9c886181c2c Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 6 Aug 2025 18:31:50 +0200 Subject: [PATCH 2/3] DONE --- src/bitcoin/tx_builder.rs | 16 ++++++------- src/types/address.rs | 19 ++++++++------- tests/node/integration/utilities.test.ts | 30 ++---------------------- 3 files changed, 19 insertions(+), 46 deletions(-) diff --git a/src/bitcoin/tx_builder.rs b/src/bitcoin/tx_builder.rs index 8437713..e60852c 100644 --- a/src/bitcoin/tx_builder.rs +++ b/src/bitcoin/tx_builder.rs @@ -1,8 +1,6 @@ use std::{cell::RefCell, rc::Rc}; -use bdk_wallet::{ - bitcoin::ScriptBuf as BdkScriptBuf, error::CreateTxError, TxOrdering as BdkTxOrdering, Wallet as BdkWallet, -}; +use bdk_wallet::{error::CreateTxError, TxOrdering as BdkTxOrdering, Wallet as BdkWallet}; use serde::Serialize; use wasm_bindgen::prelude::wasm_bindgen; @@ -22,9 +20,9 @@ pub struct TxBuilder { unspendable: Vec, fee_rate: FeeRate, drain_wallet: bool, - drain_to: Option, + drain_to: Option, allow_dust: bool, - ordering: BdkTxOrdering, + ordering: TxOrdering, } #[wasm_bindgen] @@ -39,7 +37,7 @@ impl TxBuilder { drain_wallet: false, allow_dust: false, drain_to: None, - ordering: BdkTxOrdering::default(), + ordering: BdkTxOrdering::default().into(), } } @@ -100,7 +98,7 @@ impl TxBuilder { /// If you choose not to set any recipients, you should provide the utxos that the /// transaction should spend via [`add_utxos`]. pub fn drain_to(mut self, script_pubkey: ScriptBuf) -> Self { - self.drain_to = Some(script_pubkey.into()); + self.drain_to = Some(script_pubkey); self } @@ -114,7 +112,7 @@ impl TxBuilder { /// Choose the ordering for inputs and outputs of the transaction pub fn ordering(mut self, ordering: TxOrdering) -> Self { - self.ordering = ordering.into(); + self.ordering = ordering; self } @@ -137,7 +135,7 @@ impl TxBuilder { } if let Some(drain_recipient) = self.drain_to { - builder.drain_to(drain_recipient); + builder.drain_to(drain_recipient.into()); } let psbt = builder.finish()?; diff --git a/src/types/address.rs b/src/types/address.rs index 05d93f2..afb0042 100644 --- a/src/types/address.rs +++ b/src/types/address.rs @@ -4,10 +4,7 @@ use bdk_wallet::{ bitcoin::{Address as BdkAddress, AddressType as BdkAddressType, Network as BdkNetwork, ScriptBuf as BdkScriptBuf}, AddressInfo as BdkAddressInfo, }; -use bitcoin::{ - address::ParseError, - hashes::{sha256, Hash}, -}; +use bitcoin::address::ParseError; use wasm_bindgen::prelude::wasm_bindgen; use crate::{ @@ -104,11 +101,6 @@ impl Address { pub fn script_pubkey(&self) -> ScriptBuf { self.0.script_pubkey().into() } - - #[wasm_bindgen(getter)] - pub fn scripthash(&self) -> String { - sha256::Hash::hash(self.0.script_pubkey().as_bytes()).to_string() - } } impl From for Address { @@ -159,6 +151,15 @@ impl Deref for ScriptBuf { #[wasm_bindgen] impl ScriptBuf { + pub fn from_hex(s: &str) -> JsResult { + let script = BdkScriptBuf::from_hex(s)?; + Ok(script.into()) + } + + pub fn from_bytes(bytes: Vec) -> Self { + BdkScriptBuf::from_bytes(bytes).into() + } + #[allow(clippy::inherent_to_string)] #[wasm_bindgen(js_name = toString)] pub fn to_string(&self) -> String { diff --git a/tests/node/integration/utilities.test.ts b/tests/node/integration/utilities.test.ts index fb8f8e6..ed1a83d 100644 --- a/tests/node/integration/utilities.test.ts +++ b/tests/node/integration/utilities.test.ts @@ -3,7 +3,6 @@ import { Network, seed_to_descriptor, seed_to_xpriv, - Wallet, xpriv_to_descriptor, xpub_to_descriptor, } from "../../../pkg/bitcoindevkit"; @@ -11,36 +10,11 @@ import { mnemonicToSeedSync } from "bip39"; describe("Utilities", () => { const addressType: AddressType = "p2wpkh"; - const network: Network = "bitcoin"; + const network: Network = "testnet"; const seed = mnemonicToSeedSync( - "spread raise short crane omit tent fringe mandate neglect detail suspect cradle" + "journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose" ); - it.only("test", async () => { - const desc = seed_to_descriptor(seed, network, "p2wpkh"); - - const w = Wallet.create(network, desc.external, desc.internal); - const eaddresses = w.reveal_addresses_to("external", 2); - const iaddresses = w.reveal_addresses_to("internal", 2); - - eaddresses.forEach((add) => { - console.log( - add.address.toString(), - add.address.scripthash, - add.address.script_pubkey.toString(), - add.address.script_pubkey.to_hex_string() - ); - }); - iaddresses.forEach((add) => { - console.log( - add.address.toString(), - add.address.scripthash, - add.address.script_pubkey.toString(), - add.address.script_pubkey.to_hex_string() - ); - }); - }); - it("generates xpriv from seed", async () => { const xpriv = seed_to_xpriv(seed, network); From c3c6f2052cee01126b619065549c035260e0667d Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 6 Aug 2025 18:36:06 +0200 Subject: [PATCH 3/3] wallet test --- tests/node/integration/wallet.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/node/integration/wallet.test.ts b/tests/node/integration/wallet.test.ts index f2acbbe..7f3127c 100644 --- a/tests/node/integration/wallet.test.ts +++ b/tests/node/integration/wallet.test.ts @@ -63,7 +63,9 @@ describe("Wallet", () => { wallet .build_tx() .fee_rate(new FeeRate(BigInt(1))) - .add_recipient(new Recipient(recipientAddress, sendAmount)) + .add_recipient( + new Recipient(recipientAddress.script_pubkey, sendAmount) + ) .finish(); } catch (error) { expect(error).toBeInstanceOf(BdkError);