From 947cd014b099004f2aaa7377df34f5cad91a6293 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 2 Feb 2026 10:08:12 +0100 Subject: [PATCH] feat(wasm-utxo)!: return transaction object from extractTransaction Changes extractTransaction() to return a Transaction object instead of raw bytes. The transaction object provides additional functionality like getId() to get the transaction ID directly. This is a breaking change as the return type changes from Uint8Array to a Transaction object. Clients need to call toBytes() on the returned object to get the raw bytes. Issue: BTC-2978 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 20 +++++- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 11 +++ packages/wasm-utxo/js/transaction.ts | 71 ++++++++++++++++++- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 55 ++++++++++++++ .../wasm-utxo/src/wasm/dash_transaction.rs | 29 ++++++++ .../src/wasm/fixed_script_wallet/mod.rs | 60 ++++++++++++++++ packages/wasm-utxo/src/wasm/transaction.rs | 48 +++++++++++++ .../test/fixedScript/dogecoinLOLAmount.ts | 2 +- .../test/fixedScript/finalizeExtract.ts | 21 +++++- .../test/fixedScript/psbtReconstruction.ts | 23 ++++++ 10 files changed, 331 insertions(+), 9 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index cf221ee4..a8a6d88e 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -6,6 +6,12 @@ import { type ECPairArg, ECPair } from "../ecpair.js"; import type { UtxolibName } from "../utxolibCompat.js"; import type { CoinName } from "../coinName.js"; import type { InputScriptType } from "./scriptType.js"; +import { + Transaction, + DashTransaction, + ZcashTransaction, + type ITransaction, +} from "../transaction.js"; export type { InputScriptType }; @@ -755,11 +761,19 @@ export class BitGoPsbt { /** * Extract the final transaction from a finalized PSBT * - * @returns The serialized transaction bytes + * @returns The extracted transaction instance * @throws Error if the PSBT is not fully finalized or extraction fails */ - extractTransaction(): Uint8Array { - return this._wasm.extract_transaction(); + extractTransaction(): ITransaction { + const networkType = this._wasm.get_network_type(); + + if (networkType === "dash") { + return DashTransaction.fromWasm(this._wasm.extract_dash_transaction()); + } + if (networkType === "zcash") { + return ZcashTransaction.fromWasm(this._wasm.extract_zcash_transaction()); + } + return Transaction.fromWasm(this._wasm.extract_bitcoin_transaction()); } /** diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index 67a1d789..3c386d41 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -1,6 +1,7 @@ import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { BitGoPsbt, type CreateEmptyOptions } from "./BitGoPsbt.js"; +import { ZcashTransaction } from "../transaction.js"; /** Zcash network names */ export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec"; @@ -160,4 +161,14 @@ export class ZcashBitGoPsbt extends BitGoPsbt { get expiryHeight(): number { return this.wasm.expiry_height(); } + + /** + * Extract the final Zcash transaction from a finalized PSBT + * + * @returns The extracted Zcash transaction instance + * @throws Error if the PSBT is not fully finalized or extraction fails + */ + override extractTransaction(): ZcashTransaction { + return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction()); + } } diff --git a/packages/wasm-utxo/js/transaction.ts b/packages/wasm-utxo/js/transaction.ts index 90710e22..b26e36ac 100644 --- a/packages/wasm-utxo/js/transaction.ts +++ b/packages/wasm-utxo/js/transaction.ts @@ -1,21 +1,48 @@ import { WasmDashTransaction, WasmTransaction, WasmZcashTransaction } from "./wasm/wasm_utxo.js"; +/** + * Common interface for all transaction types + */ +export interface ITransaction { + toBytes(): Uint8Array; + getId(): string; +} + /** * Transaction wrapper (Bitcoin-like networks) * * Provides a camelCase, strongly-typed API over the snake_case WASM bindings. */ -export class Transaction { +export class Transaction implements ITransaction { private constructor(private _wasm: WasmTransaction) {} static fromBytes(bytes: Uint8Array): Transaction { return new Transaction(WasmTransaction.from_bytes(bytes)); } + /** + * @internal Create from WASM instance directly (avoids re-parsing bytes) + */ + static fromWasm(wasm: WasmTransaction): Transaction { + return new Transaction(wasm); + } + toBytes(): Uint8Array { return this._wasm.to_bytes(); } + /** + * Get the transaction ID (txid) + * + * The txid is the double SHA256 of the transaction bytes (excluding witness + * data for segwit transactions), displayed in reverse byte order as is standard. + * + * @returns The transaction ID as a hex string + */ + getId(): string { + return this._wasm.get_txid(); + } + /** * Get the virtual size of the transaction * @@ -40,17 +67,36 @@ export class Transaction { * * Provides a camelCase, strongly-typed API over the snake_case WASM bindings. */ -export class ZcashTransaction { +export class ZcashTransaction implements ITransaction { private constructor(private _wasm: WasmZcashTransaction) {} static fromBytes(bytes: Uint8Array): ZcashTransaction { return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes)); } + /** + * @internal Create from WASM instance directly (avoids re-parsing bytes) + */ + static fromWasm(wasm: WasmZcashTransaction): ZcashTransaction { + return new ZcashTransaction(wasm); + } + toBytes(): Uint8Array { return this._wasm.to_bytes(); } + /** + * Get the transaction ID (txid) + * + * The txid is the double SHA256 of the full Zcash transaction bytes, + * displayed in reverse byte order as is standard. + * + * @returns The transaction ID as a hex string + */ + getId(): string { + return this._wasm.get_txid(); + } + /** * @internal */ @@ -64,17 +110,36 @@ export class ZcashTransaction { * * Round-trip only: bytes -> parse -> bytes. */ -export class DashTransaction { +export class DashTransaction implements ITransaction { private constructor(private _wasm: WasmDashTransaction) {} static fromBytes(bytes: Uint8Array): DashTransaction { return new DashTransaction(WasmDashTransaction.from_bytes(bytes)); } + /** + * @internal Create from WASM instance directly (avoids re-parsing bytes) + */ + static fromWasm(wasm: WasmDashTransaction): DashTransaction { + return new DashTransaction(wasm); + } + toBytes(): Uint8Array { return this._wasm.to_bytes(); } + /** + * Get the transaction ID (txid) + * + * The txid is the double SHA256 of the full Dash transaction bytes, + * displayed in reverse byte order as is standard. + * + * @returns The transaction ID as a hex string + */ + getId(): string { + return this._wasm.get_txid(); + } + /** * @internal */ diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 0b0088d3..8df69cf5 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -1147,6 +1147,61 @@ impl BitGoPsbt { } } + /// Extract the Bitcoin transaction directly (for BitcoinLike networks only) + /// + /// # Returns + /// * `Ok(Transaction)` - The extracted transaction + /// * `Err(String)` - If not BitcoinLike or extraction fails + pub fn extract_bitcoin_tx(self) -> Result { + match self { + BitGoPsbt::BitcoinLike(psbt, _) => psbt + .extract_tx() + .map_err(|e| format!("Failed to extract transaction: {}", e)), + _ => Err("extract_bitcoin_tx only supported for BitcoinLike networks".to_string()), + } + } + + /// Extract the Dash transaction parts directly + /// + /// # Returns + /// * `Ok(DashTransactionParts)` - The extracted transaction parts + /// * `Err(String)` - If not Dash or extraction fails + pub fn extract_dash_tx(self) -> Result { + use miniscript::bitcoin::consensus::serialize; + match self { + BitGoPsbt::Dash(dash_psbt, _) => { + let tx = dash_psbt + .psbt + .extract_tx() + .map_err(|e| format!("Failed to extract transaction: {}", e))?; + let tx_bytes = serialize(&tx); + crate::dash::transaction::decode_dash_transaction_parts(&tx_bytes) + .map_err(|e| format!("Failed to decode Dash transaction: {}", e)) + } + _ => Err("extract_dash_tx only supported for Dash networks".to_string()), + } + } + + /// Extract the Zcash transaction parts directly + /// + /// # Returns + /// * `Ok(ZcashTransactionParts)` - The extracted transaction parts + /// * `Err(String)` - If not Zcash or extraction fails + pub fn extract_zcash_tx( + self, + ) -> Result { + match self { + BitGoPsbt::Zcash(zcash_psbt, _) => { + let bytes = zcash_psbt + .extract_tx() + .map_err(|e| format!("Failed to extract transaction: {}", e))?; + crate::zcash::transaction::decode_zcash_transaction_parts(&bytes) + .map_err(|e| format!("Failed to decode Zcash transaction: {}", e)) + } + _ => Err("extract_zcash_tx only supported for Zcash networks".to_string()), + } + } + /// Extract a half-signed transaction in legacy format for p2ms-based script types. /// /// This method extracts a transaction where each input has exactly one signature, diff --git a/packages/wasm-utxo/src/wasm/dash_transaction.rs b/packages/wasm-utxo/src/wasm/dash_transaction.rs index e8a63fe5..71f2cfb6 100644 --- a/packages/wasm-utxo/src/wasm/dash_transaction.rs +++ b/packages/wasm-utxo/src/wasm/dash_transaction.rs @@ -7,6 +7,13 @@ pub struct WasmDashTransaction { parts: crate::dash::transaction::DashTransactionParts, } +impl WasmDashTransaction { + /// Create from parts (internal use) + pub(crate) fn from_parts(parts: crate::dash::transaction::DashTransactionParts) -> Self { + WasmDashTransaction { parts } + } +} + #[wasm_bindgen] impl WasmDashTransaction { /// Deserialize a Dash transaction from bytes (supports EVO special tx extra payload). @@ -24,4 +31,26 @@ impl WasmDashTransaction { WasmUtxoError::new(&format!("Failed to serialize Dash transaction: {}", e)) }) } + + /// Get the transaction ID (txid) + /// + /// The txid is the double SHA256 of the full Dash transaction bytes, + /// displayed in reverse byte order (big-endian) as is standard. + /// + /// # Returns + /// The transaction ID as a hex string + /// + /// # Errors + /// Returns an error if the transaction cannot be serialized + pub fn get_txid(&self) -> Result { + use miniscript::bitcoin::hashes::{sha256d, Hash}; + use miniscript::bitcoin::Txid; + let tx_bytes = crate::dash::transaction::encode_dash_transaction_parts(&self.parts) + .map_err(|e| { + WasmUtxoError::new(&format!("Failed to serialize Dash transaction: {}", e)) + })?; + let hash = sha256d::Hash::hash(&tx_bytes); + let txid = Txid::from_raw_hash(hash); + Ok(txid.to_string()) + } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index f9bd3a03..55ceac29 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -585,6 +585,19 @@ impl BitGoPsbt { self.psbt.network().to_string() } + /// Get the network type for transaction extraction + /// + /// Returns "bitcoin", "dash", or "zcash" to indicate which transaction + /// wrapper class should be used in TypeScript. + pub fn get_network_type(&self) -> String { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match &self.psbt { + InnerBitGoPsbt::BitcoinLike(_, _) => "bitcoin".to_string(), + InnerBitGoPsbt::Dash(_, _) => "dash".to_string(), + InnerBitGoPsbt::Zcash(_, _) => "zcash".to_string(), + } + } + /// Get the transaction version pub fn version(&self) -> i32 { self.psbt.psbt().unsigned_tx.version.0 @@ -1490,6 +1503,53 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&e)) } + /// Extract the final transaction as a WasmTransaction (for BitcoinLike networks) + /// + /// This avoids re-parsing bytes by returning the transaction directly. + /// Only valid for Bitcoin-like networks (not Dash or Zcash). + pub fn extract_bitcoin_transaction( + &self, + ) -> Result { + let tx = self + .psbt + .clone() + .extract_bitcoin_tx() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::transaction::WasmTransaction::from_tx(tx)) + } + + /// Extract the final transaction as a WasmDashTransaction (for Dash networks) + /// + /// This avoids re-parsing bytes by returning the transaction directly. + /// Only valid for Dash networks. + pub fn extract_dash_transaction( + &self, + ) -> Result { + let parts = self + .psbt + .clone() + .extract_dash_tx() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::dash_transaction::WasmDashTransaction::from_parts(parts)) + } + + /// Extract the final transaction as a WasmZcashTransaction (for Zcash networks) + /// + /// This avoids re-parsing bytes by returning the transaction directly. + /// Only valid for Zcash networks. + pub fn extract_zcash_transaction( + &self, + ) -> Result { + let parts = self + .psbt + .clone() + .extract_zcash_tx() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts( + parts, + )) + } + /// Extract a half-signed transaction in legacy format for p2ms-based script types. /// /// This method extracts a transaction where each input has exactly one signature, diff --git a/packages/wasm-utxo/src/wasm/transaction.rs b/packages/wasm-utxo/src/wasm/transaction.rs index e50d410b..b5486272 100644 --- a/packages/wasm-utxo/src/wasm/transaction.rs +++ b/packages/wasm-utxo/src/wasm/transaction.rs @@ -12,6 +12,13 @@ pub struct WasmTransaction { pub(crate) tx: Transaction, } +impl WasmTransaction { + /// Create a WasmTransaction from a Transaction (internal use) + pub(crate) fn from_tx(tx: Transaction) -> Self { + WasmTransaction { tx } + } +} + #[wasm_bindgen] impl WasmTransaction { /// Deserialize a transaction from bytes @@ -53,6 +60,18 @@ impl WasmTransaction { pub fn get_vsize(&self) -> usize { self.tx.vsize() } + + /// Get the transaction ID (txid) + /// + /// The txid is the double SHA256 of the transaction bytes (excluding witness + /// data for segwit transactions), displayed in reverse byte order (big-endian) + /// as is standard for Bitcoin. + /// + /// # Returns + /// The transaction ID as a hex string + pub fn get_txid(&self) -> String { + self.tx.compute_txid().to_string() + } } /// A Zcash transaction with network-specific fields @@ -64,6 +83,13 @@ pub struct WasmZcashTransaction { parts: crate::zcash::transaction::ZcashTransactionParts, } +impl WasmZcashTransaction { + /// Create from parts (internal use) + pub(crate) fn from_parts(parts: crate::zcash::transaction::ZcashTransactionParts) -> Self { + WasmZcashTransaction { parts } + } +} + #[wasm_bindgen] impl WasmZcashTransaction { /// Deserialize a Zcash transaction from bytes @@ -93,4 +119,26 @@ impl WasmZcashTransaction { WasmUtxoError::new(&format!("Failed to serialize Zcash transaction: {}", e)) }) } + + /// Get the transaction ID (txid) + /// + /// The txid is the double SHA256 of the full Zcash transaction bytes, + /// displayed in reverse byte order (big-endian) as is standard. + /// + /// # Returns + /// The transaction ID as a hex string + /// + /// # Errors + /// Returns an error if the transaction cannot be serialized + pub fn get_txid(&self) -> Result { + use miniscript::bitcoin::hashes::{sha256d, Hash}; + use miniscript::bitcoin::Txid; + let tx_bytes = crate::zcash::transaction::encode_zcash_transaction_parts(&self.parts) + .map_err(|e| { + WasmUtxoError::new(&format!("Failed to serialize Zcash transaction: {}", e)) + })?; + let hash = sha256d::Hash::hash(&tx_bytes); + let txid = Txid::from_raw_hash(hash); + Ok(txid.to_string()) + } } diff --git a/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts b/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts index de4239bb..ac22a107 100644 --- a/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts +++ b/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts @@ -51,6 +51,6 @@ describe("Dogecoin large output limit amount (LOL amounts) (1-in/1-out)", functi psbt.finalizeAllInputs(); const extractedTx = psbt.extractTransaction(); - assert.ok(extractedTx.length > 0, "expected extracted tx bytes"); + assert.ok(extractedTx.toBytes().length > 0, "expected extracted tx bytes"); }); }); diff --git a/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts b/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts index 47199ada..23fd9c06 100644 --- a/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts +++ b/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts @@ -67,7 +67,7 @@ describe("finalize and extract transaction", function () { // Verify it can be extracted (which confirms finalization worked) const extractedTx = deserialized.extractTransaction(); - const extractedTxHex = Buffer.from(extractedTx).toString("hex"); + const extractedTxHex = Buffer.from(extractedTx.toBytes()).toString("hex"); const expectedTxHex = getExtractedTransactionHex(fullsignedFixture); assert.strictEqual( @@ -86,7 +86,7 @@ describe("finalize and extract transaction", function () { // Extract transaction const extractedTx = psbt.extractTransaction(); - const extractedTxHex = Buffer.from(extractedTx).toString("hex"); + const extractedTxHex = Buffer.from(extractedTx.toBytes()).toString("hex"); // Get expected transaction hex from fixture const expectedTxHex = getExtractedTransactionHex(fullsignedFixture); @@ -97,6 +97,23 @@ describe("finalize and extract transaction", function () { "Extracted transaction should match expected transaction", ); }); + + it("should have extracted transaction with valid getId()", function () { + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBuffer, networkName); + + psbt.finalizeAllInputs(); + const extractedTx = psbt.extractTransaction(); + + // Verify getId() returns a valid 64-character hex txid + const txid = extractedTx.getId(); + assert.strictEqual(txid.length, 64, "txid should be 64 characters"); + assert.match(txid, /^[0-9a-f]{64}$/, "txid should be lowercase hex"); + + // Verify txid matches utxolib calculation + const expectedTxHex = getExtractedTransactionHex(fullsignedFixture); + const utxolibTx = utxolib.bitgo.createTransactionFromHex(expectedTxHex, network); + assert.strictEqual(txid, utxolibTx.getId(), "txid should match utxolib calculation"); + }); }); }); }); diff --git a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts index 263f1e82..12c4afa1 100644 --- a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts +++ b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts @@ -345,6 +345,29 @@ describe("PSBT reconstruction", function () { "PSBTs should serialize to identical bytes", ); }); + + it("should extract transaction with valid getId() after finalization", function () { + // Load fullsigned fixture for this network + const fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes( + getPsbtBuffer(fullsignedFixture), + networkName, + ); + + // Finalize and extract + psbt.finalizeAllInputs(); + const extractedTx = psbt.extractTransaction(); + + // Verify getId() returns a valid 64-character hex txid + const txid = extractedTx.getId(); + assert.strictEqual(txid.length, 64, "txid should be 64 characters"); + assert.match(txid, /^[0-9a-f]{64}$/, "txid should be lowercase hex"); + + // Verify unsignedTxid() also returns valid format + const unsignedTxid = psbt.unsignedTxid(); + assert.strictEqual(unsignedTxid.length, 64, "unsignedTxid should be 64 characters"); + assert.match(unsignedTxid, /^[0-9a-f]{64}$/, "unsignedTxid should be lowercase hex"); + }); }); }); });