Skip to content

Commit 77b3545

Browse files
Merge pull request #271 from BitGo/BTC-2650.fix-fromnetworktx-bch-rp
fix(wasm-utxo): handle replay protection inputs in PSBT reconstruction
2 parents 23fbda6 + cb86a54 commit 77b3545

6 files changed

Lines changed: 496 additions & 472 deletions

File tree

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub(crate) enum FixedScriptInput {
2727
/// Raw sig bytes, or `None` if the slot is an OP_0 placeholder.
2828
sig_bytes: Option<Vec<u8>>,
2929
},
30+
/// Input with neither scriptSig nor witness — not yet signed.
31+
Unsigned,
3032
}
3133

3234
impl FixedScriptInput {
@@ -67,7 +69,16 @@ impl FixedScriptInput {
6769
_ => return Err("Last scriptSig item is not a push".to_string()),
6870
};
6971
let inner_script = ScriptBuf::from(redeem_bytes);
70-
let slots = instructions[1..instructions.len() - 1]
72+
// For multisig, scriptSig is OP_0 <sig...> <redeemScript>:
73+
// index 0 is OP_0 (skip it), slots start at index 1.
74+
// For P2SH-P2PK, scriptSig is <sig> <redeemScript>:
75+
// index 0 IS the sig, so slots must start at index 0.
76+
let slot_start = if parse_p2pk_script(&inner_script).is_some() {
77+
0
78+
} else {
79+
1
80+
};
81+
let slots = instructions[slot_start..instructions.len() - 1]
7182
.iter()
7283
.map(|inst| match inst {
7384
miniscript::bitcoin::script::Instruction::PushBytes(b) => b.as_bytes().to_vec(),
@@ -76,7 +87,7 @@ impl FixedScriptInput {
7687
.collect();
7788
(inner_script, slots)
7889
} else {
79-
return Err("Input has neither witness nor scriptSig".to_string());
90+
return Ok(Self::Unsigned);
8091
};
8192

8293
if parse_multisig_script_2_of_3(&inner_script).is_ok() {
@@ -163,6 +174,7 @@ impl FixedScriptInput {
163174
.insert(PublicKey::from(*pubkey), sig);
164175
}
165176
}
177+
Self::Unsigned => {}
166178
}
167179
Ok(())
168180
}

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

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -527,34 +527,38 @@ impl BitGoPsbt {
527527
pubkey: expected_pubkey,
528528
value,
529529
} => {
530-
// Validate pubkey matches what's in the transaction
531530
let parsed = FixedScriptInput::from_txin(tx_in)
532531
.map_err(|e| format!("Input {}: {}", i, e))?;
533-
if let FixedScriptInput::ReplayProtection {
534-
pubkey: tx_pubkey, ..
535-
} = &parsed
536-
{
537-
if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() {
538-
return Err(format!("Input {}: replay protection pubkey mismatch", i));
532+
let pubkey = match &parsed {
533+
FixedScriptInput::ReplayProtection {
534+
pubkey: tx_pubkey, ..
535+
} => {
536+
if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() {
537+
return Err(format!(
538+
"Input {}: replay protection pubkey mismatch",
539+
i
540+
));
541+
}
542+
*tx_pubkey
539543
}
540-
Self::add_replay_protection_input_to_psbt(
541-
psbt,
542-
i,
543-
network,
544-
*tx_pubkey,
545-
tx_in.previous_output.txid,
546-
tx_in.previous_output.vout,
547-
*value,
548-
ReplayProtectionOptions {
549-
sequence: Some(tx_in.sequence.0),
550-
prev_tx: None,
551-
sighash_type: None,
552-
},
553-
)
554-
.map_err(|e| format!("Input {}: {}", i, e))?;
555-
} else {
556-
return Err(format!("Input {}: expected replay protection input", i));
557-
}
544+
FixedScriptInput::Unsigned => *expected_pubkey,
545+
_ => return Err(format!("Input {}: expected replay protection input", i)),
546+
};
547+
Self::add_replay_protection_input_to_psbt(
548+
psbt,
549+
i,
550+
network,
551+
pubkey,
552+
tx_in.previous_output.txid,
553+
tx_in.previous_output.vout,
554+
*value,
555+
ReplayProtectionOptions {
556+
sequence: Some(tx_in.sequence.0),
557+
prev_tx: None,
558+
sighash_type: None,
559+
},
560+
)
561+
.map_err(|e| format!("Input {}: {}", i, e))?;
558562
}
559563
}
560564
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Regression test for BCH FORKID sighash in partial sigs.
3+
*
4+
* The wallet-platform reported: "Error: Invalid hashType 0" when calling
5+
* utxolib's finalizeAllInputs() on a BCH PSBT after HSM co-signing.
6+
*
7+
* Root cause: an older version of wasm-utxo signed BCH p2shP2pk inputs with
8+
* SIGHASH_ALL (0x01) instead of SIGHASH_ALL|SIGHASH_FORKID (0x41), or produced
9+
* partial sigs without the hashType byte at all (last byte = 0x00).
10+
*
11+
* This test verifies that:
12+
* 1. WASM-signed BCH PSBTs contain partial sigs with hashType 0x41.
13+
* 2. utxolib's finalizeAllInputs() succeeds on such a PSBT (no "Invalid hashType" error).
14+
*/
15+
16+
import assert from "node:assert";
17+
import * as utxolib from "@bitgo/utxo-lib";
18+
import { loadPsbtFixture, getPsbtBuffer } from "./fixtureUtil.js";
19+
20+
const BCH_SIGHASH_FORKID = 0x41; // SIGHASH_ALL | SIGHASH_FORKID
21+
22+
type ForkIdTestCase = {
23+
coin: "bch" | "bcha";
24+
network: utxolib.Network;
25+
};
26+
27+
const forkIdCoins: ForkIdTestCase[] = [
28+
{ coin: "bch", network: utxolib.networks.bitcoincash },
29+
{ coin: "bcha", network: utxolib.networks.ecash },
30+
];
31+
32+
describe("BCH/XEC FORKID finalization (regression: Invalid hashType 0)", function () {
33+
for (const { coin, network } of forkIdCoins) {
34+
for (const txFormat of ["psbt-lite", "psbt"] as const) {
35+
describe(`coin: ${coin}, txFormat: ${txFormat}`, function () {
36+
it("all partial sigs have hashType 0x41 (SIGHASH_ALL|SIGHASH_FORKID)", async function () {
37+
const fixture = await loadPsbtFixture(coin, "fullsigned", txFormat);
38+
const psbtBuffer = getPsbtBuffer(fixture);
39+
const psbt = utxolib.bitgo.createPsbtFromBuffer(psbtBuffer, network);
40+
41+
psbt.data.inputs.forEach((input, idx) => {
42+
if (!input.partialSig || input.partialSig.length === 0) {
43+
return;
44+
}
45+
input.partialSig.forEach((ps) => {
46+
const hashTypeByte = ps.signature[ps.signature.length - 1];
47+
assert.strictEqual(
48+
hashTypeByte,
49+
BCH_SIGHASH_FORKID,
50+
`input ${idx}: partial sig hashType byte is 0x${hashTypeByte.toString(16)}, expected 0x41 (SIGHASH_ALL|SIGHASH_FORKID)`,
51+
);
52+
});
53+
});
54+
});
55+
56+
it("utxolib finalizeAllInputs() succeeds on WASM-signed PSBT", async function () {
57+
const fixture = await loadPsbtFixture(coin, "fullsigned", txFormat);
58+
const psbtBuffer = getPsbtBuffer(fixture);
59+
const psbt = utxolib.bitgo.createPsbtFromBuffer(psbtBuffer, network);
60+
61+
// This is where the wallet-platform error occurred:
62+
// "Error: Invalid hashType 0 at checkPartialSigSighashes"
63+
assert.doesNotThrow(() => psbt.finalizeAllInputs());
64+
65+
const tx = psbt.extractTransaction();
66+
assert.ok(tx);
67+
});
68+
});
69+
}
70+
}
71+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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

Comments
 (0)