Skip to content

Commit 26ad785

Browse files
OttoAllmendingerllm-git
andcommitted
fix(wasm-utxo): handle replay protection inputs in PSBT reconstruction
Allow PSBT reconstruction from half-signed BCH transactions that include unsigned replay protection inputs. Previously, inputs without scriptSig or witness data would error; now they parse as `Unsigned`. When hydrating from a legacy transaction, use the expected pubkey from unspents metadata if the input is unsigned, rather than requiring the pubkey to be extracted from the transaction. Add comprehensive regression tests for BCH FORKID sighash finalization and PSBT hydration scenarios. Tests verify that: - All partial sigs use hashType 0x41 (SIGHASH_ALL|SIGHASH_FORKID) - utxolib's finalizeAllInputs() succeeds without "Invalid hashType 0" - Both unsigned and signed p2shP2pk inputs reconstruct correctly - fromNetworkFormat preserves sigs in half-signed and fully-signed txs - Wallet-platform hydrate-and-cosign flow completes successfully Issue: BTC-2650 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 23fbda6 commit 26ad785

5 files changed

Lines changed: 420 additions & 63 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)