From 10fc9a043736349cb412bc393c41b94e74996b24 Mon Sep 17 00:00:00 2001 From: Toshi Date: Mon, 16 Mar 2026 16:25:04 +0000 Subject: [PATCH] feat: expand utility methods on Psbt, Transaction, Address, TxOut, and TxIn Add commonly needed utility methods across multiple wrapper types: Psbt: - to_bytes()/from_bytes() for BIP-174 binary serialization - n_inputs()/n_outputs() for PSBT input/output counts Transaction: - weight getter (Weight Units) - version getter (protocol version) - lock_time getter (consensus locktime) TxIn: - sequence getter (nSequence value) Address: - address_type getter (P2PKH, P2SH, P2WPKH, P2WSH, P2TR) - is_related_to_pubkey() for script matching TxOut: - new(value, script_pubkey) constructor Includes comprehensive Node.js integration tests for all additions. Closes #21 --- CHANGELOG.md | 10 ++ src/types/address.rs | 17 +++ src/types/input.rs | 9 ++ src/types/output.rs | 14 ++ src/types/psbt.rs | 33 ++++- src/types/transaction.rs | 30 ++++ tests/node/integration/utilities.test.ts | 176 +++++++++++++++++++++++ 7 files changed, 287 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3e934..17b0b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expand utility methods on `Psbt`, `Transaction`, `Address`, `TxOut`, and `TxIn` ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21)): + - `Psbt::to_bytes()` and `Psbt::from_bytes()` for BIP-174 binary serialization + - `Psbt::n_inputs()` and `Psbt::n_outputs()` for PSBT input/output counts + - `Transaction::weight` getter for transaction weight in Weight Units + - `Transaction::version` getter for the protocol version number + - `Transaction::lock_time` getter for the transaction locktime value + - `TxIn::sequence` getter for the input sequence number + - `Address::address_type` getter to retrieve the address type directly + - `Address::is_related_to_pubkey()` to check if an address matches a script + - `TxOut::new(value, script_pubkey)` constructor for creating outputs from components - 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 diff --git a/src/types/address.rs b/src/types/address.rs index 64e8f28..f7038ec 100644 --- a/src/types/address.rs +++ b/src/types/address.rs @@ -107,6 +107,23 @@ impl Address { self.0.script_pubkey().into() } + /// Gets the address type of the address. + /// + /// # Returns + /// + /// None if unknown, non-standard or related to the future witness version. + #[wasm_bindgen(getter)] + pub fn address_type(&self) -> Option { + self.0.address_type().map(Into::into) + } + + /// Returns `true` if the address creates a particular script. + /// This will return `true` even if the address has not been checked against + /// the current network. + pub fn is_related_to_pubkey(&self, script: ScriptBuf) -> bool { + self.0.script_pubkey() == *script + } + #[wasm_bindgen(js_name = clone)] pub fn js_clone(&self) -> Address { self.clone() 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/src/types/output.rs b/src/types/output.rs index c72c251..6a1c458 100644 --- a/src/types/output.rs +++ b/src/types/output.rs @@ -89,6 +89,20 @@ impl Deref for TxOut { #[wasm_bindgen] impl TxOut { + /// Create a new transaction output. + /// + /// # Arguments + /// + /// * `value` - The value of the output, in satoshis. + /// * `script_pubkey` - The script which must be satisfied for the output to be spent. + #[wasm_bindgen(constructor)] + pub fn new(value: Amount, script_pubkey: ScriptBuf) -> Self { + TxOut(BdkTxOut { + value: value.into(), + script_pubkey: script_pubkey.into(), + }) + } + /// The value of the output, in satoshis. #[wasm_bindgen(getter)] pub fn value(&self) -> Amount { diff --git a/src/types/psbt.rs b/src/types/psbt.rs index e48386e..430e453 100644 --- a/src/types/psbt.rs +++ b/src/types/psbt.rs @@ -8,6 +8,7 @@ use bdk_wallet::{ }; use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsError; use crate::result::JsResult; use crate::types::ScriptBuf; @@ -99,14 +100,42 @@ impl Psbt { self.0.unsigned_tx.clone().into() } - /// Serialize the PSBT to a string in base64 format + /// The number of PSBT inputs. + pub fn n_inputs(&self) -> usize { + self.0.inputs.len() + } + + /// The number of PSBT outputs. + pub fn n_outputs(&self) -> usize { + self.0.outputs.len() + } + + /// Serialize the PSBT to binary (BIP-174) format. + /// + /// Returns the raw PSBT as a `Uint8Array` suitable for storing, + /// sending to hardware wallets, or passing to other WASM modules. + pub fn to_bytes(&self) -> Vec { + self.0.serialize() + } + + /// Deserialize a PSBT from binary (BIP-174) format. + /// + /// Accepts a `Uint8Array` of raw PSBT bytes (as produced by `to_bytes()` + /// or any standard PSBT serializer). + pub fn from_bytes(bytes: &[u8]) -> JsResult { + let psbt = + BdkPsbt::deserialize(bytes).map_err(|e| JsError::new(&format!("Failed to deserialize PSBT: {e}")))?; + Ok(Psbt(psbt)) + } + + /// Serialize the PSBT to a string in base64 format. #[allow(clippy::inherent_to_string)] #[wasm_bindgen(js_name = toString)] pub fn to_string(&self) -> String { self.0.to_string() } - /// Create a PSBT from a base64 string + /// Create a PSBT from a base64 string. pub fn from_string(val: &str) -> JsResult { Ok(Psbt(BdkPsbt::from_str(val)?)) } diff --git a/src/types/transaction.rs b/src/types/transaction.rs index 5a255d7..d0fd6dd 100644 --- a/src/types/transaction.rs +++ b/src/types/transaction.rs @@ -56,6 +56,36 @@ impl Transaction { self.0.vsize() } + /// Returns the weight of this transaction, as defined by BIP-141. + /// + /// > Transaction weight is defined as Base transaction size * 3 + Total transaction size + /// > (ie. the same method as determining the weight of individual transactions in a block). + /// + /// Returns the weight in Weight Units (WU). Divide by 4 (rounding up) to get vsize. + #[wasm_bindgen(getter)] + pub fn weight(&self) -> u64 { + self.0.weight().to_wu() + } + + /// The protocol version, is currently expected to be 1, 2, or 3. + /// + /// Version 2 is required for transactions using relative timelocks (BIP 68/112/113). + /// Version 3 enables additional relay policy rules. + #[wasm_bindgen(getter)] + pub fn version(&self) -> i32 { + self.0.version.0 + } + + /// Block height or timestamp after which this transaction can be mined. + /// + /// Returns the raw lock_time value as a u32. A value of 0 means the transaction + /// is not locked. Values below 500,000,000 are interpreted as block heights; + /// values at or above 500,000,000 are interpreted as Unix timestamps. + #[wasm_bindgen(getter)] + pub fn lock_time(&self) -> u32 { + self.0.lock_time.to_consensus_u32() + } + /// Computes the [`Txid`]. /// /// Hashes the transaction **excluding** the segwit data (i.e. the marker, flag bytes, and the diff --git a/tests/node/integration/utilities.test.ts b/tests/node/integration/utilities.test.ts index ed1a83d..f1a1531 100644 --- a/tests/node/integration/utilities.test.ts +++ b/tests/node/integration/utilities.test.ts @@ -1,6 +1,12 @@ import { + Address, AddressType, + Amount, Network, + Psbt, + ScriptBuf, + Transaction, + TxOut, seed_to_descriptor, seed_to_xpriv, xpriv_to_descriptor, @@ -72,3 +78,173 @@ describe("Utilities", () => { ); }); }); + +describe("Address", () => { + const network: Network = "testnet"; + + it("returns address_type for P2WPKH address", () => { + const address = Address.from_string( + "tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v", + network + ); + expect(address.address_type).toBe("p2wpkh"); + }); + + it("returns address_type for P2PKH address", () => { + const address = Address.from_string( + "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn", + network + ); + expect(address.address_type).toBe("p2pkh"); + }); + + it("returns address_type for P2SH address", () => { + const address = Address.from_string( + "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc", + network + ); + expect(address.address_type).toBe("p2sh"); + }); + + it("returns address_type for P2TR address", () => { + const address = Address.from_string( + "tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c", + network + ); + expect(address.address_type).toBe("p2tr"); + }); + + it("is_related_to_pubkey matches own script_pubkey", () => { + const address = Address.from_string( + "tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v", + network + ); + expect(address.is_related_to_pubkey(address.script_pubkey)).toBe(true); + }); + + it("is_related_to_pubkey returns false for different script", () => { + const address = Address.from_string( + "tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v", + network + ); + const other = Address.from_string( + "tb1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgc26xfdz", + network + ); + expect(address.is_related_to_pubkey(other.script_pubkey)).toBe(false); + }); +}); + +describe("TxOut", () => { + it("creates a TxOut with constructor", () => { + const value = Amount.from_sat(BigInt(50000)); + const script = ScriptBuf.from_hex("0014d51e61c85f81c91e3891e69807656c11573e5e48"); + const txout = new TxOut(value, script); + + expect(txout.value.to_sat()).toBe(BigInt(50000)); + expect(txout.script_pubkey.to_hex_string()).toBe( + "0014d51e61c85f81c91e3891e69807656c11573e5e48" + ); + expect(txout.size).toBeGreaterThan(0); + }); +}); + +describe("Transaction", () => { + // A known testnet coinbase transaction (hex-encoded) + // This is a minimal valid transaction for testing getters. + // We use from_bytes to construct it from raw consensus bytes. + const COINBASE_TX_HEX = + "01000000010000000000000000000000000000000000000000000000000000000000000000" + + "ffffffff0704ffff001d0104ffffffff0100f2052a0100000043410496b538e853519c726a" + + "2c91e61ec11600ae1390813a627c66fb8be7947be63c52da7589379515d4e0a604f8141781" + + "e62294721166bf621e73a82cbf2342c858eeac00000000"; + + function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + } + + it("exposes version getter", () => { + const tx = Transaction.from_bytes(hexToBytes(COINBASE_TX_HEX)); + expect(tx.version).toBe(1); + }); + + it("exposes lock_time getter", () => { + const tx = Transaction.from_bytes(hexToBytes(COINBASE_TX_HEX)); + expect(tx.lock_time).toBe(0); + }); + + it("exposes weight getter", () => { + const tx = Transaction.from_bytes(hexToBytes(COINBASE_TX_HEX)); + // Weight should be positive and base_size * 4 for non-segwit transactions + expect(tx.weight).toBeGreaterThan(0); + expect(tx.weight).toBe(BigInt(tx.base_size * 4)); + }); + + it("weight is consistent with vsize", () => { + const tx = Transaction.from_bytes(hexToBytes(COINBASE_TX_HEX)); + // vsize = ceil(weight / 4) + const expectedVsize = Math.ceil(Number(tx.weight) / 4); + expect(tx.vsize).toBe(expectedVsize); + }); + + it("exposes input sequence via TxIn.sequence", () => { + const tx = Transaction.from_bytes(hexToBytes(COINBASE_TX_HEX)); + const inputs = tx.input; + expect(inputs.length).toBeGreaterThan(0); + // Coinbase input has sequence 0xFFFFFFFF + expect(inputs[0].sequence).toBe(0xffffffff); + }); +}); + +describe("Psbt", () => { + // A minimal valid PSBT (base64-encoded) + // Created from a simple unsigned transaction with one input and one output + const PSBT_BASE64 = + "cHNidP8BAHECAAAAAbiWoQ6LzBOyGdMOTSma/0AZMxuAKXOFECsHxe69kMEAAAAAAP////8BYIkV" + + "AAAAAAAZdqkU/wnITVpEpeCXb9MRxWC5GGtptOWIrAAAAAAAAQEfgJaYAAAAAAAZdqkUCMkO8CyW" + + "gcJzT0WfmUi+nfBc+ZeIrAAAAA=="; + + it("round-trips through to_bytes/from_bytes", () => { + const psbt = Psbt.from_string(PSBT_BASE64); + const bytes = psbt.to_bytes(); + expect(bytes.length).toBeGreaterThan(0); + + const restored = Psbt.from_bytes(bytes); + expect(restored.toString()).toBe(psbt.toString()); + }); + + it("round-trips through to_string/from_string", () => { + const psbt = Psbt.from_string(PSBT_BASE64); + const base64 = psbt.toString(); + const restored = Psbt.from_string(base64); + expect(restored.toString()).toBe(psbt.toString()); + }); + + it("reports correct n_inputs and n_outputs", () => { + const psbt = Psbt.from_string(PSBT_BASE64); + expect(psbt.n_inputs()).toBe(1); + expect(psbt.n_outputs()).toBe(1); + }); + + it("returns version", () => { + const psbt = Psbt.from_string(PSBT_BASE64); + expect(psbt.version).toBe(0); + }); + + it("unsigned_tx is accessible", () => { + const psbt = Psbt.from_string(PSBT_BASE64); + const tx = psbt.unsigned_tx; + expect(tx.input.length).toBe(1); + expect(tx.output.length).toBe(1); + }); + + it("from_bytes rejects invalid data", () => { + expect(() => { + Psbt.from_bytes(new Uint8Array([0x00, 0x01, 0x02])); + }).toThrow("Failed to deserialize PSBT"); + }); +});