Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub(crate) enum FixedScriptInput {
/// Raw sig bytes, or `None` if the slot is an OP_0 placeholder.
sig_bytes: Option<Vec<u8>>,
},
/// Input with neither scriptSig nor witness — not yet signed.
Unsigned,
}

impl FixedScriptInput {
Expand Down Expand Up @@ -67,7 +69,16 @@ impl FixedScriptInput {
_ => return Err("Last scriptSig item is not a push".to_string()),
};
let inner_script = ScriptBuf::from(redeem_bytes);
let slots = instructions[1..instructions.len() - 1]
// For multisig, scriptSig is OP_0 <sig...> <redeemScript>:
// index 0 is OP_0 (skip it), slots start at index 1.
// For P2SH-P2PK, scriptSig is <sig> <redeemScript>:
// index 0 IS the sig, so slots must start at index 0.
let slot_start = if parse_p2pk_script(&inner_script).is_some() {
0
} else {
1
};
let slots = instructions[slot_start..instructions.len() - 1]
.iter()
.map(|inst| match inst {
miniscript::bitcoin::script::Instruction::PushBytes(b) => b.as_bytes().to_vec(),
Expand All @@ -76,7 +87,7 @@ impl FixedScriptInput {
.collect();
(inner_script, slots)
} else {
return Err("Input has neither witness nor scriptSig".to_string());
return Ok(Self::Unsigned);
};

if parse_multisig_script_2_of_3(&inner_script).is_ok() {
Expand Down Expand Up @@ -163,6 +174,7 @@ impl FixedScriptInput {
.insert(PublicKey::from(*pubkey), sig);
}
}
Self::Unsigned => {}
}
Ok(())
}
Expand Down
54 changes: 29 additions & 25 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,34 +527,38 @@ impl BitGoPsbt {
pubkey: expected_pubkey,
value,
} => {
// Validate pubkey matches what's in the transaction
let parsed = FixedScriptInput::from_txin(tx_in)
.map_err(|e| format!("Input {}: {}", i, e))?;
if let FixedScriptInput::ReplayProtection {
pubkey: tx_pubkey, ..
} = &parsed
{
if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() {
return Err(format!("Input {}: replay protection pubkey mismatch", i));
let pubkey = match &parsed {
FixedScriptInput::ReplayProtection {
pubkey: tx_pubkey, ..
} => {
if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() {
return Err(format!(
"Input {}: replay protection pubkey mismatch",
i
));
}
*tx_pubkey
}
Self::add_replay_protection_input_to_psbt(
psbt,
i,
network,
*tx_pubkey,
tx_in.previous_output.txid,
tx_in.previous_output.vout,
*value,
ReplayProtectionOptions {
sequence: Some(tx_in.sequence.0),
prev_tx: None,
sighash_type: None,
},
)
.map_err(|e| format!("Input {}: {}", i, e))?;
} else {
return Err(format!("Input {}: expected replay protection input", i));
}
FixedScriptInput::Unsigned => *expected_pubkey,
_ => return Err(format!("Input {}: expected replay protection input", i)),
};
Self::add_replay_protection_input_to_psbt(
psbt,
i,
network,
pubkey,
tx_in.previous_output.txid,
tx_in.previous_output.vout,
*value,
ReplayProtectionOptions {
sequence: Some(tx_in.sequence.0),
prev_tx: None,
sighash_type: None,
},
)
.map_err(|e| format!("Input {}: {}", i, e))?;
}
}
}
Expand Down
71 changes: 71 additions & 0 deletions packages/wasm-utxo/test/fixedScript/bchForkidFinalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Regression test for BCH FORKID sighash in partial sigs.
*
* The wallet-platform reported: "Error: Invalid hashType 0" when calling
* utxolib's finalizeAllInputs() on a BCH PSBT after HSM co-signing.
*
* Root cause: an older version of wasm-utxo signed BCH p2shP2pk inputs with
* SIGHASH_ALL (0x01) instead of SIGHASH_ALL|SIGHASH_FORKID (0x41), or produced
* partial sigs without the hashType byte at all (last byte = 0x00).
*
* This test verifies that:
* 1. WASM-signed BCH PSBTs contain partial sigs with hashType 0x41.
* 2. utxolib's finalizeAllInputs() succeeds on such a PSBT (no "Invalid hashType" error).
*/

import assert from "node:assert";
import * as utxolib from "@bitgo/utxo-lib";
import { loadPsbtFixture, getPsbtBuffer } from "./fixtureUtil.js";

const BCH_SIGHASH_FORKID = 0x41; // SIGHASH_ALL | SIGHASH_FORKID

type ForkIdTestCase = {
coin: "bch" | "bcha";
network: utxolib.Network;
};

const forkIdCoins: ForkIdTestCase[] = [
{ coin: "bch", network: utxolib.networks.bitcoincash },
{ coin: "bcha", network: utxolib.networks.ecash },
];

describe("BCH/XEC FORKID finalization (regression: Invalid hashType 0)", function () {
for (const { coin, network } of forkIdCoins) {
for (const txFormat of ["psbt-lite", "psbt"] as const) {
describe(`coin: ${coin}, txFormat: ${txFormat}`, function () {
it("all partial sigs have hashType 0x41 (SIGHASH_ALL|SIGHASH_FORKID)", async function () {
const fixture = await loadPsbtFixture(coin, "fullsigned", txFormat);
const psbtBuffer = getPsbtBuffer(fixture);
const psbt = utxolib.bitgo.createPsbtFromBuffer(psbtBuffer, network);

psbt.data.inputs.forEach((input, idx) => {
if (!input.partialSig || input.partialSig.length === 0) {
return;
}
input.partialSig.forEach((ps) => {
const hashTypeByte = ps.signature[ps.signature.length - 1];
assert.strictEqual(
hashTypeByte,
BCH_SIGHASH_FORKID,
`input ${idx}: partial sig hashType byte is 0x${hashTypeByte.toString(16)}, expected 0x41 (SIGHASH_ALL|SIGHASH_FORKID)`,
);
});
});
});

it("utxolib finalizeAllInputs() succeeds on WASM-signed PSBT", async function () {
const fixture = await loadPsbtFixture(coin, "fullsigned", txFormat);
const psbtBuffer = getPsbtBuffer(fixture);
const psbt = utxolib.bitgo.createPsbtFromBuffer(psbtBuffer, network);

// This is where the wallet-platform error occurred:
// "Error: Invalid hashType 0 at checkPartialSigSighashes"
assert.doesNotThrow(() => psbt.finalizeAllInputs());

const tx = psbt.extractTransaction();
assert.ok(tx);
});
});
}
}
});
140 changes: 140 additions & 0 deletions packages/wasm-utxo/test/fixedScript/bchHydrateAndFinalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Regression test for the wallet-platform "Invalid hashType 0" error on BCH PSBTs.
*
* Root cause (found in fixed_script_input.rs::from_txin):
* For P2SH-P2PK scriptSig = <sig> <redeemScript>, the sig is at instructions[0].
* The original slot range instructions[1..len-1] (designed for multisig OP_0 prefix)
* produced an empty slice, so sig_bytes was always None. The p2shP2pk partial sig
* was lost during fromNetworkFormat hydration.
*
* Fix: detect P2PK redeemScript and start slots at index 0 for p2shP2pk inputs.
*
* The wallet-platform reported "Invalid hashType 0" at finalizeAllInputs() because
* the p2shP2pk partial sig was absent from the hydrated PSBT, leaving no valid sig
* for finalization. With a later combine step, a partial sig with unexpected bytes
* could produce the literal "Invalid hashType 0" error; the core cause is the lost sig.
*/

import assert from "node:assert";
import * as utxolib from "@bitgo/utxo-lib";
import { AcidTest } from "../../js/testutils/AcidTest.js";
import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js";
import { ECPair } from "../../js/ecpair.js";
import type { HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js";

function buildHydrationUnspents(acid: AcidTest): HydrationUnspent[] {
const rpPubkey = acid.userXprv.publicKey;
return acid.inputs.map((input, i) => {
if ("scriptType" in input && input.scriptType === "p2shP2pk") {
return { pubkey: rpPubkey, value: input.value };
}
const scriptId =
"scriptId" in input && input.scriptId ? input.scriptId : { chain: 0, index: i };
return { chain: scriptId.chain, index: scriptId.index, value: input.value };
});
}

describe("BCH p2shP2pk hydration (regression: 'Invalid hashType 0' / sig lost in fromNetworkFormat)", function () {
for (const txFormat of ["psbt-lite", "psbt"] as const) {
describe(`txFormat: ${txFormat}`, function () {
it("p2shP2pk sig preserved after fromNetworkFormat on half-signed tx", function () {
const acid = AcidTest.withConfig("bch", "halfsigned", txFormat);
const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey));
const rpIdx = acid.inputs.findIndex(
(i) => "scriptType" in i && i.scriptType === "p2shP2pk",
);
assert.ok(rpIdx >= 0, "AcidTest should include a p2shP2pk input for BCH");

const halfsignedPsbt = acid.createPsbt(); // user+rp signed
const legacyBytes = halfsignedPsbt.getHalfSignedLegacyFormat();

const unspents = buildHydrationUnspents(acid);
const hydratedPsbt = BitGoPsbt.fromNetworkFormat(
legacyBytes,
"bch",
acid.rootWalletKeys,
unspents,
);

// The p2shP2pk partial sig must survive the round-trip
assert.ok(
hydratedPsbt.verifySignature(rpIdx, rpECPair),
"p2shP2pk partial sig should be preserved after fromNetworkFormat",
);
});

it("p2shP2pk sig preserved after fromNetworkFormat on fully-signed tx", function () {
const acid = AcidTest.withConfig("bch", "fullsigned", txFormat);
const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey));
const rpIdx = acid.inputs.findIndex(
(i) => "scriptType" in i && i.scriptType === "p2shP2pk",
);
assert.ok(rpIdx >= 0, "AcidTest should include a p2shP2pk input for BCH");

const fullsignedPsbt = acid.createPsbt();
fullsignedPsbt.finalizeAllInputs();
const txBytes = fullsignedPsbt.extractTransaction().toBytes();

const unspents = buildHydrationUnspents(acid);
const hydratedPsbt = BitGoPsbt.fromNetworkFormat(
txBytes,
"bch",
acid.rootWalletKeys,
unspents,
);

// The p2shP2pk partial sig must survive hydration from a fully-signed tx
assert.ok(
hydratedPsbt.verifySignature(rpIdx, rpECPair),
"p2shP2pk partial sig should be preserved after fromNetworkFormat from fully-signed tx",
);
});

it("finalization succeeds via utxolib after hydrate-and-cosign (wallet-platform scenario)", function () {
const acid = AcidTest.withConfig("bch", "halfsigned", txFormat);
const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey));
const rpIdx = acid.inputs.findIndex(
(i) => "scriptType" in i && i.scriptType === "p2shP2pk",
);

// 1. User signs wallet inputs → half-signed legacy tx
const legacyBytes = acid.createPsbt().getHalfSignedLegacyFormat();

// 2. indexerdb hydrates (fromNetworkFormat)
const unspents = buildHydrationUnspents(acid);
const hydratedPsbt = BitGoPsbt.fromNetworkFormat(
legacyBytes,
"bch",
acid.rootWalletKeys,
unspents,
);

// 3. HSM co-signs wallet inputs + p2shP2pk
hydratedPsbt.sign(acid.bitgoXprv);
if (rpIdx >= 0) hydratedPsbt.signInput(rpIdx, rpECPair);

// 4. wallet-platform finalizes via utxolib
const utxolibPsbt = utxolib.bitgo.createPsbtFromBuffer(
Buffer.from(hydratedPsbt.serialize()),
utxolib.networks.bitcoincash,
);

// All partial sigs must have hashType 0x41 (SIGHASH_ALL | SIGHASH_FORKID)
utxolibPsbt.data.inputs.forEach((input, idx) => {
(input.partialSig ?? []).forEach((ps) => {
const hashTypeByte = ps.signature[ps.signature.length - 1];
assert.strictEqual(
hashTypeByte,
0x41,
`input ${idx}: partial sig hashType = 0x${hashTypeByte.toString(16)}, expected 0x41`,
);
});
});

// This is the line that threw "Invalid hashType 0" on the wallet-platform
assert.doesNotThrow(() => utxolibPsbt.finalizeAllInputs());
assert.ok(utxolibPsbt.extractTransaction());
});
});
}
});
Loading
Loading