diff --git a/src/bitcoin/tx_builder.rs b/src/bitcoin/tx_builder.rs index 4865ea5..e60852c 100644 --- a/src/bitcoin/tx_builder.rs +++ b/src/bitcoin/tx_builder.rs @@ -1,10 +1,10 @@ use std::{cell::RefCell, rc::Rc}; -use bdk_wallet::{bitcoin::ScriptBuf, error::CreateTxError, Wallet as BdkWallet}; +use bdk_wallet::{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. /// @@ -22,6 +22,7 @@ pub struct TxBuilder { drain_wallet: bool, drain_to: Option, allow_dust: bool, + ordering: TxOrdering, } #[wasm_bindgen] @@ -36,6 +37,7 @@ impl TxBuilder { drain_wallet: false, allow_dust: false, drain_to: None, + ordering: BdkTxOrdering::default().into(), } } @@ -95,8 +97,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); self } @@ -108,6 +110,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; + self + } + /// Finish building the transaction. /// /// Returns a new [`Psbt`] per [`BIP174`]. @@ -116,6 +124,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()) @@ -126,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()?; @@ -134,6 +143,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..afb0042 100644 --- a/src/types/address.rs +++ b/src/types/address.rs @@ -96,6 +96,11 @@ 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() + } } impl From for Address { @@ -133,6 +138,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 { @@ -145,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 { @@ -154,6 +169,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/wallet.test.ts b/tests/node/integration/wallet.test.ts index 0af4ae3..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); @@ -75,44 +77,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(); - } - }); });