Skip to content

Commit fd615ad

Browse files
Merge pull request #284 from BitGo/otto/add-get-unsigned-tx
feat(wasm-utxo): add BitGoPsbt.getUnsignedTransaction()
2 parents f2946b6 + 70b0954 commit fd615ad

4 files changed

Lines changed: 204 additions & 0 deletions

File tree

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,18 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddre
989989
return this._wasm.extract_half_signed_legacy_tx();
990990
}
991991

992+
/**
993+
* Serialize the unsigned transaction embedded in this PSBT.
994+
*
995+
* Unlike {@link extractTransaction}, this does NOT require finalization or signatures.
996+
* Equivalent to utxo-lib's `UtxoPsbt.getUnsignedTx().toBuffer()`.
997+
*
998+
* @returns The serialized unsigned transaction bytes (network/consensus encoding).
999+
*/
1000+
getUnsignedTransaction(): Uint8Array {
1001+
return this._wasm.get_unsigned_tx();
1002+
}
1003+
9921004
/**
9931005
* Get all PSBT outputs with resolved address strings
9941006
*

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,26 @@ impl BitGoPsbt {
14261426
}
14271427
}
14281428

1429+
/// Serialize the unsigned transaction embedded in this PSBT.
1430+
///
1431+
/// Unlike `extract_half_signed_legacy_tx`, this does NOT require signatures or finalization.
1432+
pub fn get_unsigned_tx_bytes(&self) -> Vec<u8> {
1433+
use miniscript::bitcoin::consensus::Encodable;
1434+
match self {
1435+
BitGoPsbt::BitcoinLike(psbt, _) => {
1436+
let mut buf = Vec::new();
1437+
psbt.unsigned_tx
1438+
.consensus_encode(&mut buf)
1439+
.expect("encoding to vec should not fail");
1440+
buf
1441+
}
1442+
BitGoPsbt::Dash(dash_psbt, _) => dash_psbt.unsigned_tx_bytes.clone(),
1443+
BitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt
1444+
.extract_unsigned_zcash_transaction()
1445+
.expect("Zcash unsigned tx encoding should not fail"),
1446+
}
1447+
}
1448+
14291449
pub fn into_psbt(self) -> Psbt {
14301450
match self {
14311451
BitGoPsbt::BitcoinLike(psbt, _network) => psbt,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,6 +1933,13 @@ impl BitGoPsbt {
19331933
.extract_half_signed_legacy_tx()
19341934
.map_err(|e| WasmUtxoError::new(&e))
19351935
}
1936+
1937+
/// Serialize the unsigned transaction embedded in this PSBT.
1938+
///
1939+
/// Unlike `extract_transaction()`, this does NOT require finalization or signatures.
1940+
pub fn get_unsigned_tx(&self) -> Vec<u8> {
1941+
self.psbt.get_unsigned_tx_bytes()
1942+
}
19361943
}
19371944

19381945
impl_wasm_psbt_ops!(BitGoPsbt, psbt);
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Tests for getUnsignedTransaction() method against reference utxo-lib implementation
3+
*/
4+
import { describe, it } from "mocha";
5+
import * as assert from "assert";
6+
import * as utxolib from "@bitgo/utxo-lib";
7+
import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js";
8+
import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js";
9+
import { ChainCode } from "../../js/fixedScriptWallet/chains.js";
10+
import { ECPair } from "../../js/ecpair.js";
11+
import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js";
12+
import { getCoinNameForNetwork } from "../networks.js";
13+
14+
// Zcash Nu5 activation height (mainnet)
15+
const ZCASH_NU5_HEIGHT = 1687105;
16+
17+
const p2msNetworks = utxolib
18+
.getNetworkList()
19+
.filter(
20+
(n) => utxolib.isMainnet(n) && n !== utxolib.networks.bitcoinsv && n !== utxolib.networks.ecash,
21+
);
22+
23+
/**
24+
* Create an unsigned PSBT with p2sh inputs across all supported p2ms script types.
25+
*/
26+
function createUnsignedP2msPsbt(network: utxolib.Network): BitGoPsbt {
27+
const coinName = getCoinNameForNetwork(network);
28+
const rootWalletKeys = getDefaultWalletKeys();
29+
30+
const supportedTypes = (["p2sh", "p2shP2wsh", "p2wsh"] as const).filter((scriptType) =>
31+
utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType),
32+
);
33+
34+
const isZcash = utxolib.getMainnet(network) === utxolib.networks.zcash;
35+
const psbt = isZcash
36+
? ZcashBitGoPsbt.createEmpty(coinName as "zec" | "tzec", rootWalletKeys, {
37+
version: 4,
38+
lockTime: 0,
39+
blockHeight: ZCASH_NU5_HEIGHT,
40+
})
41+
: BitGoPsbt.createEmpty(coinName, rootWalletKeys, { version: 2, lockTime: 0 });
42+
43+
supportedTypes.forEach((scriptType, index) => {
44+
const scriptId = { chain: ChainCode.value(scriptType, "external"), index };
45+
psbt.addWalletInput(
46+
{
47+
txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`,
48+
vout: 0,
49+
value: BigInt(10000 + index * 10000),
50+
sequence: 0xfffffffd,
51+
},
52+
rootWalletKeys,
53+
{ scriptId },
54+
);
55+
});
56+
57+
psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) });
58+
59+
return psbt;
60+
}
61+
62+
/**
63+
* Convert wasm-utxo PSBT bytes to a utxo-lib UtxoPsbt for reference comparisons.
64+
*/
65+
function toUtxolibPsbt(wasmPsbt: BitGoPsbt, network: utxolib.Network): utxolib.bitgo.UtxoPsbt {
66+
return utxolib.bitgo.createPsbtFromBuffer(Buffer.from(wasmPsbt.serialize()), network);
67+
}
68+
69+
describe("getUnsignedTransaction", function () {
70+
describe("Basic functionality", function () {
71+
it("returns non-empty bytes for an unsigned PSBT", function () {
72+
const psbt = createUnsignedP2msPsbt(utxolib.networks.bitcoin);
73+
const txBytes = psbt.getUnsignedTransaction();
74+
assert.ok(txBytes.length > 0, "Should return non-empty bytes");
75+
});
76+
77+
it("deserializes as a valid transaction with the expected inputs", function () {
78+
const psbt = createUnsignedP2msPsbt(utxolib.networks.bitcoin);
79+
const txBytes = psbt.getUnsignedTransaction();
80+
81+
const tx = utxolib.bitgo.createTransactionFromBuffer(
82+
Buffer.from(txBytes),
83+
utxolib.networks.bitcoin,
84+
{ amountType: "bigint" },
85+
);
86+
assert.ok(tx, "Should deserialize as valid transaction");
87+
assert.ok(tx.ins.length >= 1, "Should have at least 1 input");
88+
assert.ok(tx.outs.length >= 1, "Should have at least 1 output");
89+
});
90+
91+
it("returns identical bytes when called on a half-signed PSBT", function () {
92+
const rootWalletKeys = getDefaultWalletKeys();
93+
const [userXprv] = getKeyTriple("default");
94+
95+
const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 });
96+
psbt.addWalletInput(
97+
{ txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd },
98+
rootWalletKeys,
99+
{ scriptId: { chain: 0, index: 0 } },
100+
);
101+
psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) });
102+
103+
const unsignedBytes = psbt.getUnsignedTransaction();
104+
105+
psbt.sign(userXprv);
106+
const halfSignedBytes = psbt.getUnsignedTransaction();
107+
108+
// The embedded unsigned_tx in the PSBT global map is not affected by partial sigs
109+
assert.strictEqual(
110+
Buffer.from(unsignedBytes).toString("hex"),
111+
Buffer.from(halfSignedBytes).toString("hex"),
112+
"Unsigned tx bytes should not change after signing",
113+
);
114+
});
115+
});
116+
117+
describe("Comparison with utxo-lib getUnsignedTx", function () {
118+
for (const network of p2msNetworks) {
119+
const networkName = utxolib.getNetworkName(network);
120+
it(`${networkName}: matches utxo-lib UtxoPsbt.getUnsignedTx().toBuffer()`, function () {
121+
const psbt = createUnsignedP2msPsbt(network);
122+
123+
const wasmBytes = psbt.getUnsignedTransaction();
124+
125+
const utxolibPsbt = toUtxolibPsbt(psbt, network);
126+
const utxolibBytes = utxolibPsbt.getUnsignedTx().toBuffer();
127+
128+
assert.strictEqual(
129+
Buffer.from(wasmBytes).toString("hex"),
130+
utxolibBytes.toString("hex"),
131+
`Unsigned tx bytes should match utxo-lib output for ${networkName}`,
132+
);
133+
});
134+
}
135+
});
136+
137+
describe("Replay protection inputs", function () {
138+
it("includes replay protection input in the unsigned transaction", function () {
139+
const rootWalletKeys = getDefaultWalletKeys();
140+
const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey);
141+
142+
const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 });
143+
psbt.addWalletInput(
144+
{ txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd },
145+
rootWalletKeys,
146+
{ scriptId: { chain: 0, index: 0 } },
147+
);
148+
psbt.addReplayProtectionInput(
149+
{ txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd },
150+
ecpair,
151+
);
152+
psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) });
153+
154+
const txBytes = psbt.getUnsignedTransaction();
155+
assert.ok(txBytes.length > 0, "Should produce non-empty bytes");
156+
157+
const tx = utxolib.bitgo.createTransactionFromBuffer(
158+
Buffer.from(txBytes),
159+
utxolib.networks.bitcoin,
160+
{ amountType: "bigint" },
161+
);
162+
assert.strictEqual(tx.ins.length, 2, "Both wallet and replay protection inputs included");
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)