|
| 1 | +/** |
| 2 | + * Regression test for the wallet-platform "Invalid hashType 0" error on BCH PSBTs. |
| 3 | + * |
| 4 | + * Root cause (found in fixed_script_input.rs::from_txin): |
| 5 | + * For P2SH-P2PK scriptSig = <sig> <redeemScript>, the sig is at instructions[0]. |
| 6 | + * The original slot range instructions[1..len-1] (designed for multisig OP_0 prefix) |
| 7 | + * produced an empty slice, so sig_bytes was always None. The p2shP2pk partial sig |
| 8 | + * was lost during fromNetworkFormat hydration. |
| 9 | + * |
| 10 | + * Fix: detect P2PK redeemScript and start slots at index 0 for p2shP2pk inputs. |
| 11 | + * |
| 12 | + * The wallet-platform reported "Invalid hashType 0" at finalizeAllInputs() because |
| 13 | + * the p2shP2pk partial sig was absent from the hydrated PSBT, leaving no valid sig |
| 14 | + * for finalization. With a later combine step, a partial sig with unexpected bytes |
| 15 | + * could produce the literal "Invalid hashType 0" error; the core cause is the lost sig. |
| 16 | + */ |
| 17 | + |
| 18 | +import assert from "node:assert"; |
| 19 | +import * as utxolib from "@bitgo/utxo-lib"; |
| 20 | +import { AcidTest } from "../../js/testutils/AcidTest.js"; |
| 21 | +import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; |
| 22 | +import { ECPair } from "../../js/ecpair.js"; |
| 23 | +import type { HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js"; |
| 24 | + |
| 25 | +function buildHydrationUnspents(acid: AcidTest): HydrationUnspent[] { |
| 26 | + const rpPubkey = acid.userXprv.publicKey; |
| 27 | + return acid.inputs.map((input, i) => { |
| 28 | + if ("scriptType" in input && input.scriptType === "p2shP2pk") { |
| 29 | + return { pubkey: rpPubkey, value: input.value }; |
| 30 | + } |
| 31 | + const scriptId = |
| 32 | + "scriptId" in input && input.scriptId ? input.scriptId : { chain: 0, index: i }; |
| 33 | + return { chain: scriptId.chain, index: scriptId.index, value: input.value }; |
| 34 | + }); |
| 35 | +} |
| 36 | + |
| 37 | +describe("BCH p2shP2pk hydration (regression: 'Invalid hashType 0' / sig lost in fromNetworkFormat)", function () { |
| 38 | + for (const txFormat of ["psbt-lite", "psbt"] as const) { |
| 39 | + describe(`txFormat: ${txFormat}`, function () { |
| 40 | + it("p2shP2pk sig preserved after fromNetworkFormat on half-signed tx", function () { |
| 41 | + const acid = AcidTest.withConfig("bch", "halfsigned", txFormat); |
| 42 | + const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey)); |
| 43 | + const rpIdx = acid.inputs.findIndex( |
| 44 | + (i) => "scriptType" in i && i.scriptType === "p2shP2pk", |
| 45 | + ); |
| 46 | + assert.ok(rpIdx >= 0, "AcidTest should include a p2shP2pk input for BCH"); |
| 47 | + |
| 48 | + const halfsignedPsbt = acid.createPsbt(); // user+rp signed |
| 49 | + const legacyBytes = halfsignedPsbt.getHalfSignedLegacyFormat(); |
| 50 | + |
| 51 | + const unspents = buildHydrationUnspents(acid); |
| 52 | + const hydratedPsbt = BitGoPsbt.fromNetworkFormat( |
| 53 | + legacyBytes, |
| 54 | + "bch", |
| 55 | + acid.rootWalletKeys, |
| 56 | + unspents, |
| 57 | + ); |
| 58 | + |
| 59 | + // The p2shP2pk partial sig must survive the round-trip |
| 60 | + assert.ok( |
| 61 | + hydratedPsbt.verifySignature(rpIdx, rpECPair), |
| 62 | + "p2shP2pk partial sig should be preserved after fromNetworkFormat", |
| 63 | + ); |
| 64 | + }); |
| 65 | + |
| 66 | + it("p2shP2pk sig preserved after fromNetworkFormat on fully-signed tx", function () { |
| 67 | + const acid = AcidTest.withConfig("bch", "fullsigned", txFormat); |
| 68 | + const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey)); |
| 69 | + const rpIdx = acid.inputs.findIndex( |
| 70 | + (i) => "scriptType" in i && i.scriptType === "p2shP2pk", |
| 71 | + ); |
| 72 | + assert.ok(rpIdx >= 0, "AcidTest should include a p2shP2pk input for BCH"); |
| 73 | + |
| 74 | + const fullsignedPsbt = acid.createPsbt(); |
| 75 | + fullsignedPsbt.finalizeAllInputs(); |
| 76 | + const txBytes = fullsignedPsbt.extractTransaction().toBytes(); |
| 77 | + |
| 78 | + const unspents = buildHydrationUnspents(acid); |
| 79 | + const hydratedPsbt = BitGoPsbt.fromNetworkFormat( |
| 80 | + txBytes, |
| 81 | + "bch", |
| 82 | + acid.rootWalletKeys, |
| 83 | + unspents, |
| 84 | + ); |
| 85 | + |
| 86 | + // The p2shP2pk partial sig must survive hydration from a fully-signed tx |
| 87 | + assert.ok( |
| 88 | + hydratedPsbt.verifySignature(rpIdx, rpECPair), |
| 89 | + "p2shP2pk partial sig should be preserved after fromNetworkFormat from fully-signed tx", |
| 90 | + ); |
| 91 | + }); |
| 92 | + |
| 93 | + it("finalization succeeds via utxolib after hydrate-and-cosign (wallet-platform scenario)", function () { |
| 94 | + const acid = AcidTest.withConfig("bch", "halfsigned", txFormat); |
| 95 | + const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey)); |
| 96 | + const rpIdx = acid.inputs.findIndex( |
| 97 | + (i) => "scriptType" in i && i.scriptType === "p2shP2pk", |
| 98 | + ); |
| 99 | + |
| 100 | + // 1. User signs wallet inputs → half-signed legacy tx |
| 101 | + const legacyBytes = acid.createPsbt().getHalfSignedLegacyFormat(); |
| 102 | + |
| 103 | + // 2. indexerdb hydrates (fromNetworkFormat) |
| 104 | + const unspents = buildHydrationUnspents(acid); |
| 105 | + const hydratedPsbt = BitGoPsbt.fromNetworkFormat( |
| 106 | + legacyBytes, |
| 107 | + "bch", |
| 108 | + acid.rootWalletKeys, |
| 109 | + unspents, |
| 110 | + ); |
| 111 | + |
| 112 | + // 3. HSM co-signs wallet inputs + p2shP2pk |
| 113 | + hydratedPsbt.sign(acid.bitgoXprv); |
| 114 | + if (rpIdx >= 0) hydratedPsbt.signInput(rpIdx, rpECPair); |
| 115 | + |
| 116 | + // 4. wallet-platform finalizes via utxolib |
| 117 | + const utxolibPsbt = utxolib.bitgo.createPsbtFromBuffer( |
| 118 | + Buffer.from(hydratedPsbt.serialize()), |
| 119 | + utxolib.networks.bitcoincash, |
| 120 | + ); |
| 121 | + |
| 122 | + // All partial sigs must have hashType 0x41 (SIGHASH_ALL | SIGHASH_FORKID) |
| 123 | + utxolibPsbt.data.inputs.forEach((input, idx) => { |
| 124 | + (input.partialSig ?? []).forEach((ps) => { |
| 125 | + const hashTypeByte = ps.signature[ps.signature.length - 1]; |
| 126 | + assert.strictEqual( |
| 127 | + hashTypeByte, |
| 128 | + 0x41, |
| 129 | + `input ${idx}: partial sig hashType = 0x${hashTypeByte.toString(16)}, expected 0x41`, |
| 130 | + ); |
| 131 | + }); |
| 132 | + }); |
| 133 | + |
| 134 | + // This is the line that threw "Invalid hashType 0" on the wallet-platform |
| 135 | + assert.doesNotThrow(() => utxolibPsbt.finalizeAllInputs()); |
| 136 | + assert.ok(utxolibPsbt.extractTransaction()); |
| 137 | + }); |
| 138 | + }); |
| 139 | + } |
| 140 | +}); |
0 commit comments