Skip to content
Draft
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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
Expand Down
17 changes: 17 additions & 0 deletions src/types/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AddressType> {
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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why add a function that doesn't exist in the underlying library? We want to expose the same API.

self.0.script_pubkey() == *script
}

#[wasm_bindgen(js_name = clone)]
pub fn js_clone(&self) -> Address {
self.clone()
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
14 changes: 14 additions & 0 deletions src/types/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 31 additions & 2 deletions src/types/psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why add a function that doesn't exist in the underlying library? We just want to expose the same API.

self.0.inputs.len()
}

/// The number of PSBT outputs.
pub fn n_outputs(&self) -> usize {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why add a function that doesn't exist in the underlying library? We just want to expose the same API.

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<u8> {
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<Psbt> {
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<Psbt> {
Ok(Psbt(BdkPsbt::from_str(val)?))
}
Expand Down
30 changes: 30 additions & 0 deletions src/types/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
176 changes: 176 additions & 0 deletions tests/node/integration/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {
Address,
AddressType,
Amount,
Network,
Psbt,
ScriptBuf,
Transaction,
TxOut,
seed_to_descriptor,
seed_to_xpriv,
xpriv_to_descriptor,
Expand Down Expand Up @@ -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");
});
});
Loading