Skip to content

Commit 5600a48

Browse files
Merge pull request #211 from BitGo/BTC-3159.fix-hydration
fix(wasm-utxo): use BigInt-specific conversion for unspent values
2 parents be58af4 + 0e35201 commit 5600a48

2 files changed

Lines changed: 133 additions & 4 deletions

File tree

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -421,10 +421,8 @@ impl BitGoPsbt {
421421
as u32;
422422
let value_js = js_sys::Reflect::get(&item, &"value".into())
423423
.map_err(|_| WasmUtxoError::new("Missing 'value' field on unspent"))?;
424-
let value = js_sys::BigInt::from(value_js)
425-
.as_f64()
426-
.ok_or_else(|| WasmUtxoError::new("'value' must be a bigint"))?
427-
as u64;
424+
let value = u64::try_from(js_sys::BigInt::unchecked_from_js(value_js))
425+
.map_err(|_| WasmUtxoError::new("'value' must be a bigint convertible to u64"))?;
428426
parsed_unspents.push(ScriptIdWithValue {
429427
chain,
430428
index,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Tests for BitGoPsbt.fromHalfSignedLegacyTransaction()
3+
*
4+
* Bug: js_sys::BigInt::from(value_js).as_f64() does an unchecked wrap but then
5+
* JsValue::as_f64() only works for JS Number type — not BigInt. Passing any proper
6+
* JS BigInt value (e.g. 10000n) returned None, so the function always threw
7+
* "'value' must be a bigint" even though the caller did exactly the right thing.
8+
*
9+
* Fix: u64::try_from(js_sys::BigInt::unchecked_from_js(value_js)) uses the
10+
* BigInt-specific conversion path and then safely maps to u64.
11+
*/
12+
import { describe, it } from "mocha";
13+
import * as assert from "assert";
14+
import * as utxolib from "@bitgo/utxo-lib";
15+
import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js";
16+
import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js";
17+
import { ChainCode } from "../../js/fixedScriptWallet/chains.js";
18+
import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js";
19+
import { getCoinNameForNetwork } from "../networks.js";
20+
21+
const ZCASH_NU5_HEIGHT = 1687105;
22+
23+
const p2msScriptTypes = ["p2sh", "p2shP2wsh", "p2wsh"] as const;
24+
25+
function isSupportedNetwork(n: utxolib.Network): boolean {
26+
return utxolib.isMainnet(n) && n !== utxolib.networks.bitcoinsv && n !== utxolib.networks.ecash;
27+
}
28+
29+
function createHalfSignedP2msPsbt(
30+
network: utxolib.Network,
31+
valueOverride?: bigint,
32+
): { psbt: BitGoPsbt; unspents: HydrationUnspent[] } {
33+
const coinName = getCoinNameForNetwork(network);
34+
const rootWalletKeys = getDefaultWalletKeys();
35+
const [userXprv] = getKeyTriple("default");
36+
37+
const supportedTypes = p2msScriptTypes.filter((scriptType) =>
38+
utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType),
39+
);
40+
41+
const isZcash = utxolib.getMainnet(network) === utxolib.networks.zcash;
42+
const psbt = isZcash
43+
? ZcashBitGoPsbt.createEmpty(coinName as "zec" | "tzec", rootWalletKeys, {
44+
version: 4,
45+
lockTime: 0,
46+
blockHeight: ZCASH_NU5_HEIGHT,
47+
})
48+
: BitGoPsbt.createEmpty(coinName, rootWalletKeys, { version: 2, lockTime: 0 });
49+
50+
const unspents: HydrationUnspent[] = [];
51+
supportedTypes.forEach((scriptType, index) => {
52+
const chain = ChainCode.value(scriptType, "external");
53+
const value = valueOverride ?? BigInt(10000 + index * 10000);
54+
psbt.addWalletInput(
55+
{
56+
txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`,
57+
vout: 0,
58+
value,
59+
sequence: 0xfffffffd,
60+
},
61+
rootWalletKeys,
62+
{ scriptId: { chain, index } },
63+
);
64+
unspents.push({ chain, index, value });
65+
});
66+
67+
psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) });
68+
psbt.sign(userXprv);
69+
70+
return { psbt, unspents };
71+
}
72+
73+
describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () {
74+
describe("BigInt value conversion (regression for unchecked-from/as_f64 bug)", function () {
75+
it("should not throw when unspent values are JS BigInt", function () {
76+
// With the buggy Rust code this always threw "'value' must be a bigint"
77+
// because BigInt::from(value_js).as_f64() calls JsValue::as_f64(), which
78+
// returns None for JS BigInt (it only works for JS Number).
79+
const rootWalletKeys = getDefaultWalletKeys();
80+
const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.bitcoin);
81+
const txBytes = psbt.getHalfSignedLegacyFormat();
82+
83+
assert.doesNotThrow(() => {
84+
BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, "btc", rootWalletKeys, unspents);
85+
}, "fromHalfSignedLegacyTransaction must not throw for valid JS BigInt values");
86+
});
87+
88+
it("should handle values larger than Number.MAX_SAFE_INTEGER", function () {
89+
// Values beyond 2^53-1 would silently lose precision through f64; the fixed
90+
// code converts directly via u64::try_from so precision is preserved.
91+
const rootWalletKeys = getDefaultWalletKeys();
92+
// 21 million BTC in satoshis — the maximum possible UTXO value
93+
const maxSats = 21_000_000n * 100_000_000n;
94+
const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.bitcoin, maxSats);
95+
const txBytes = psbt.getHalfSignedLegacyFormat();
96+
97+
assert.doesNotThrow(() => {
98+
BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, "btc", rootWalletKeys, unspents);
99+
}, "fromHalfSignedLegacyTransaction must handle large satoshi values");
100+
});
101+
});
102+
103+
describe("Round-trip: getHalfSignedLegacyFormat → fromHalfSignedLegacyTransaction", function () {
104+
// Zcash uses a non-standard transaction format (version 4 overwintered) that
105+
// fromHalfSignedLegacyTransaction does not support; skip it here.
106+
const roundTripNetworks = utxolib
107+
.getNetworkList()
108+
.filter(isSupportedNetwork)
109+
.filter((n) => utxolib.getMainnet(n) !== utxolib.networks.zcash);
110+
111+
for (const network of roundTripNetworks) {
112+
const networkName = utxolib.getNetworkName(network);
113+
it(`${networkName}: reconstructed PSBT serializes without error`, function () {
114+
const rootWalletKeys = getDefaultWalletKeys();
115+
const coinName = getCoinNameForNetwork(network);
116+
const { psbt, unspents } = createHalfSignedP2msPsbt(network);
117+
const txBytes = psbt.getHalfSignedLegacyFormat();
118+
119+
const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction(
120+
txBytes,
121+
coinName,
122+
rootWalletKeys,
123+
unspents,
124+
);
125+
126+
const serialized = reconstructed.serialize();
127+
assert.ok(serialized.length > 0, "Reconstructed PSBT should serialize to non-empty bytes");
128+
});
129+
}
130+
});
131+
});

0 commit comments

Comments
 (0)