Skip to content

Commit 434fd72

Browse files
Merge pull request #64 from BitGo/BTC-2786.finalize-serialize-extract
feat(wasm-utxo): add PSBT serialize/finalize/extract functionality
2 parents 92da4c0 + aba7f73 commit 434fd72

File tree

4 files changed

+198
-0
lines changed

4 files changed

+198
-0
lines changed

packages/wasm-utxo/js/fixedScriptWallet.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,32 @@ export class BitGoPsbt {
169169
verifyReplayProtectionSignature(inputIndex: number, replayProtection: ReplayProtection): boolean {
170170
return this.wasm.verify_replay_protection_signature(inputIndex, replayProtection);
171171
}
172+
173+
/**
174+
* Serialize the PSBT to bytes
175+
*
176+
* @returns The serialized PSBT as a byte array
177+
*/
178+
serialize(): Uint8Array {
179+
return this.wasm.serialize();
180+
}
181+
182+
/**
183+
* Finalize all inputs in the PSBT
184+
*
185+
* @throws Error if any input failed to finalize
186+
*/
187+
finalizeAllInputs(): void {
188+
this.wasm.finalize_all_inputs();
189+
}
190+
191+
/**
192+
* Extract the final transaction from a finalized PSBT
193+
*
194+
* @returns The serialized transaction bytes
195+
* @throws Error if the PSBT is not fully finalized or extraction fails
196+
*/
197+
extractTransaction(): Uint8Array {
198+
return this.wasm.extract_transaction();
199+
}
172200
}

packages/wasm-utxo/src/wasm/fixed_script_wallet.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,52 @@ impl BitGoPsbt {
265265
))
266266
})
267267
}
268+
269+
/// Serialize the PSBT to bytes
270+
///
271+
/// # Returns
272+
/// The serialized PSBT as a byte array
273+
pub fn serialize(&self) -> Result<Vec<u8>, WasmUtxoError> {
274+
self.psbt
275+
.serialize()
276+
.map_err(|e| WasmUtxoError::new(&format!("Failed to serialize PSBT: {}", e)))
277+
}
278+
279+
/// Finalize all inputs in the PSBT
280+
///
281+
/// This method attempts to finalize all inputs in the PSBT, computing the final
282+
/// scriptSig and witness data for each input.
283+
///
284+
/// # Returns
285+
/// - `Ok(())` if all inputs were successfully finalized
286+
/// - `Err(WasmUtxoError)` if any input failed to finalize
287+
pub fn finalize_all_inputs(&mut self) -> Result<(), WasmUtxoError> {
288+
let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only();
289+
self.psbt.finalize_mut(&secp).map_err(|errors| {
290+
WasmUtxoError::new(&format!(
291+
"Failed to finalize {} input(s): {}",
292+
errors.len(),
293+
errors.join("; ")
294+
))
295+
})
296+
}
297+
298+
/// Extract the final transaction from a finalized PSBT
299+
///
300+
/// This method should be called after all inputs have been finalized.
301+
/// It extracts the fully signed transaction.
302+
///
303+
/// # Returns
304+
/// - `Ok(Vec<u8>)` containing the serialized transaction bytes
305+
/// - `Err(WasmUtxoError)` if the PSBT is not fully finalized or extraction fails
306+
pub fn extract_transaction(&self) -> Result<Vec<u8>, WasmUtxoError> {
307+
let psbt = self.psbt.psbt().clone();
308+
let tx = psbt
309+
.extract_tx()
310+
.map_err(|e| WasmUtxoError::new(&format!("Failed to extract transaction: {}", e)))?;
311+
312+
// Serialize the transaction
313+
use miniscript::bitcoin::consensus::encode::serialize;
314+
Ok(serialize(&tx))
315+
}
268316
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import assert from "node:assert";
2+
import * as utxolib from "@bitgo/utxo-lib";
3+
import { fixedScriptWallet } from "../../js/index.js";
4+
import {
5+
loadPsbtFixture,
6+
getPsbtBuffer,
7+
getExtractedTransactionHex,
8+
type Fixture,
9+
} from "./fixtureUtil.js";
10+
11+
describe("finalize and extract transaction", function () {
12+
const supportedNetworks = utxolib.getNetworkList().filter((network) => {
13+
return (
14+
utxolib.isMainnet(network) &&
15+
network !== utxolib.networks.bitcoincash &&
16+
network !== utxolib.networks.bitcoingold &&
17+
network !== utxolib.networks.bitcoinsv &&
18+
network !== utxolib.networks.ecash &&
19+
network !== utxolib.networks.zcash
20+
);
21+
});
22+
23+
supportedNetworks.forEach((network) => {
24+
const networkName = utxolib.getNetworkName(network);
25+
26+
describe(`network: ${networkName}`, function () {
27+
let fullsignedFixture: Fixture;
28+
let fullsignedPsbtBuffer: Buffer;
29+
let fullsignedBitgoPsbt: fixedScriptWallet.BitGoPsbt;
30+
31+
before(function () {
32+
fullsignedFixture = loadPsbtFixture(networkName, "fullsigned");
33+
fullsignedPsbtBuffer = getPsbtBuffer(fullsignedFixture);
34+
fullsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(
35+
fullsignedPsbtBuffer,
36+
networkName,
37+
);
38+
});
39+
40+
it("should serialize and deserialize PSBT (round-trip)", function () {
41+
const serialized = fullsignedBitgoPsbt.serialize();
42+
43+
// Verify we can deserialize what we serialized (functional round-trip)
44+
const deserialized = fixedScriptWallet.BitGoPsbt.fromBytes(serialized, networkName);
45+
46+
// Verify the deserialized PSBT has the same unsigned txid
47+
assert.strictEqual(
48+
deserialized.unsignedTxid(),
49+
fullsignedBitgoPsbt.unsignedTxid(),
50+
"Deserialized PSBT should have same unsigned txid after round-trip",
51+
);
52+
53+
// Verify the re-deserialized PSBT can be serialized back to bytes
54+
const reserialized = deserialized.serialize();
55+
56+
// Verify functional equivalence by deserializing again and checking txid
57+
const redeserialized = fixedScriptWallet.BitGoPsbt.fromBytes(reserialized, networkName);
58+
assert.strictEqual(
59+
redeserialized.unsignedTxid(),
60+
fullsignedBitgoPsbt.unsignedTxid(),
61+
"PSBT should maintain consistency through multiple serialize/deserialize cycles",
62+
);
63+
});
64+
65+
it("should finalize all inputs and be extractable", function () {
66+
// Create a fresh instance for finalization
67+
const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBuffer, networkName);
68+
69+
// Finalize all inputs
70+
psbt.finalizeAllInputs();
71+
72+
// Serialize the finalized PSBT
73+
const serialized = psbt.serialize();
74+
75+
// Verify we can deserialize the finalized PSBT
76+
const deserialized = fixedScriptWallet.BitGoPsbt.fromBytes(serialized, networkName);
77+
78+
// Verify it can be extracted (which confirms finalization worked)
79+
const extractedTx = deserialized.extractTransaction();
80+
const extractedTxHex = Buffer.from(extractedTx).toString("hex");
81+
const expectedTxHex = getExtractedTransactionHex(fullsignedFixture);
82+
83+
assert.strictEqual(
84+
extractedTxHex,
85+
expectedTxHex,
86+
"Extracted transaction from finalized PSBT should match expected transaction",
87+
);
88+
});
89+
90+
it("should extract transaction from finalized PSBT", function () {
91+
// Create a fresh instance for extraction
92+
const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBuffer, networkName);
93+
94+
// Finalize all inputs
95+
psbt.finalizeAllInputs();
96+
97+
// Extract transaction
98+
const extractedTx = psbt.extractTransaction();
99+
const extractedTxHex = Buffer.from(extractedTx).toString("hex");
100+
101+
// Get expected transaction hex from fixture
102+
const expectedTxHex = getExtractedTransactionHex(fullsignedFixture);
103+
104+
assert.strictEqual(
105+
extractedTxHex,
106+
expectedTxHex,
107+
"Extracted transaction should match expected transaction",
108+
);
109+
});
110+
});
111+
});
112+
});

packages/wasm-utxo/test/fixedScript/fixtureUtil.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,13 @@ export function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWa
136136

137137
return new utxolib.bitgo.RootWalletKeys(xpubs as Triple<utxolib.BIP32Interface>);
138138
}
139+
140+
/**
141+
* Get extracted transaction hex from fixture
142+
*/
143+
export function getExtractedTransactionHex(fixture: Fixture): string {
144+
if (fixture.extractedTransaction === null) {
145+
throw new Error("Fixture does not have an extracted transaction");
146+
}
147+
return fixture.extractedTransaction;
148+
}

0 commit comments

Comments
 (0)