Skip to content

Commit 79b8133

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add input script type detection
Add InputScriptType enum to determine the script type of transaction inputs. Implement detection logic based on script chain and PSBT metadata to identify input types (p2sh, p2wsh, p2tr variants, etc). Include the detected script type in ParsedInput to help with signature validation. Issue: BTC-2786 Co-authored-by: llm-git <[email protected]>
1 parent bd07bb2 commit 79b8133

File tree

5 files changed

+143
-4
lines changed

5 files changed

+143
-4
lines changed

packages/wasm-utxo/js/fixedScriptWallet.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,21 @@ type ReplayProtection =
5656

5757
export type ScriptId = { chain: number; index: number };
5858

59+
export type InputScriptType =
60+
| "p2shP2pk"
61+
| "p2sh"
62+
| "p2shP2wsh"
63+
| "p2wsh"
64+
| "p2trLegacy"
65+
| "p2trMusig2ScriptPath"
66+
| "p2trMusig2KeyPath";
67+
5968
export type ParsedInput = {
6069
address: string;
6170
script: Uint8Array;
6271
value: bigint;
6372
scriptId: ScriptId | null;
73+
scriptType: InputScriptType;
6474
};
6575

6676
export type ParsedOutput = {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ pub enum BitGoPsbt {
9797
}
9898

9999
// Re-export types from submodules for convenience
100-
pub use psbt_wallet_input::{ParsedInput, ScriptId};
100+
pub use psbt_wallet_input::{InputScriptType, ParsedInput, ScriptId};
101101
pub use psbt_wallet_output::ParsedOutput;
102102

103103
/// Parsed transaction with wallet information

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,13 +518,75 @@ pub struct ScriptId {
518518
pub index: u32,
519519
}
520520

521+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522+
pub enum InputScriptType {
523+
P2shP2pk,
524+
P2sh,
525+
P2shP2wsh,
526+
P2wsh,
527+
P2trLegacy,
528+
P2trMusig2ScriptPath,
529+
P2trMusig2KeyPath,
530+
}
531+
532+
impl InputScriptType {
533+
pub fn from_script_id(script_id: ScriptId, psbt_input: &Input) -> Result<Self, String> {
534+
let chain = Chain::try_from(script_id.chain).map_err(|e| e.to_string())?;
535+
match chain {
536+
Chain::P2shExternal | Chain::P2shInternal => Ok(InputScriptType::P2sh),
537+
Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => Ok(InputScriptType::P2shP2wsh),
538+
Chain::P2wshExternal | Chain::P2wshInternal => Ok(InputScriptType::P2wsh),
539+
Chain::P2trInternal | Chain::P2trExternal => Ok(InputScriptType::P2trLegacy),
540+
Chain::P2trMusig2Internal | Chain::P2trMusig2External => {
541+
// check if tap_script_sigs or tap_scripts are set
542+
if !psbt_input.tap_script_sigs.is_empty() || !psbt_input.tap_scripts.is_empty() {
543+
Ok(InputScriptType::P2trMusig2ScriptPath)
544+
} else {
545+
Ok(InputScriptType::P2trMusig2KeyPath)
546+
}
547+
}
548+
}
549+
}
550+
551+
/// Detects the script type from a script_id chain and PSBT input metadata
552+
///
553+
/// # Arguments
554+
/// - `script_id`: Optional script ID containing chain information (None for replay protection inputs)
555+
/// - `psbt_input`: The PSBT input containing signature metadata
556+
/// - `output_script`: The output script being spent
557+
/// - `replay_protection`: Replay protection configuration
558+
///
559+
/// # Returns
560+
/// - `Ok(InputScriptType)` with the detected script type
561+
/// - `Err(String)` if the script type cannot be determined
562+
pub fn detect(
563+
script_id: Option<ScriptId>,
564+
psbt_input: &Input,
565+
output_script: &ScriptBuf,
566+
replay_protection: &ReplayProtection,
567+
) -> Result<Self, String> {
568+
// For replay protection inputs (no script_id), detect from output script
569+
match script_id {
570+
Some(id) => Self::from_script_id(id, psbt_input),
571+
None => {
572+
if replay_protection.is_replay_protection_input(output_script) {
573+
Ok(InputScriptType::P2shP2pk)
574+
} else {
575+
Err("Input without script_id is not a replay protection input".to_string())
576+
}
577+
}
578+
}
579+
}
580+
}
581+
521582
/// Parsed input from a PSBT transaction
522583
#[derive(Debug, Clone)]
523584
pub struct ParsedInput {
524585
pub address: String,
525586
pub script: Vec<u8>,
526587
pub value: u64,
527588
pub script_id: Option<ScriptId>,
589+
pub script_type: InputScriptType,
528590
}
529591

530592
impl ParsedInput {
@@ -576,11 +638,17 @@ impl ParsedInput {
576638
)
577639
.map_err(ParseInputError::Address)?;
578640

641+
// Detect the script type using script_id chain information
642+
let script_type =
643+
InputScriptType::detect(script_id, psbt_input, output_script, replay_protection)
644+
.map_err(ParseInputError::ScriptTypeDetection)?;
645+
579646
Ok(Self {
580647
address,
581648
script: output_script.to_bytes(),
582649
value: value.to_sat(),
583650
script_id,
651+
script_type,
584652
})
585653
}
586654
}
@@ -598,6 +666,8 @@ pub enum ParseInputError {
598666
WalletValidation(String),
599667
/// Failed to generate address for input
600668
Address(crate::address::AddressError),
669+
/// Failed to detect script type for input
670+
ScriptTypeDetection(String),
601671
}
602672

603673
impl std::fmt::Display for ParseInputError {
@@ -618,6 +688,9 @@ impl std::fmt::Display for ParseInputError {
618688
ParseInputError::Address(error) => {
619689
write!(f, "failed to generate address: {}", error)
620690
}
691+
ParseInputError::ScriptTypeDetection(error) => {
692+
write!(f, "failed to detect script type: {}", error)
693+
}
621694
}
622695
}
623696
}

packages/wasm-utxo/src/wasm/try_into_js_value.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,29 @@ impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ScriptId {
317317
}
318318
}
319319

320+
impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::InputScriptType {
321+
fn try_to_js_value(&self) -> Result<JsValue, WasmUtxoError> {
322+
use crate::fixed_script_wallet::bitgo_psbt::InputScriptType;
323+
let script_type = match self {
324+
InputScriptType::P2shP2pk => "p2shP2pk",
325+
InputScriptType::P2sh => "p2sh",
326+
InputScriptType::P2shP2wsh => "p2shP2wsh",
327+
InputScriptType::P2wsh => "p2wsh",
328+
InputScriptType::P2trLegacy => "p2trLegacy",
329+
InputScriptType::P2trMusig2ScriptPath => "p2trMusig2ScriptPath",
330+
InputScriptType::P2trMusig2KeyPath => "p2trMusig2KeyPath",
331+
};
332+
Ok(JsValue::from_str(script_type))
333+
}
334+
}
335+
320336
impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedInput {
321337
fn try_to_js_value(&self) -> Result<JsValue, WasmUtxoError> {
322338
js_obj!(
323339
"address" => self.address.clone(),
324340
"value" => self.value,
325-
"scriptId" => self.script_id
341+
"scriptId" => self.script_id,
342+
"scriptType" => self.script_type
326343
)
327344
}
328345
}

packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
import assert from "node:assert";
22
import * as utxolib from "@bitgo/utxo-lib";
33
import { fixedScriptWallet } from "../../js/index.js";
4-
import { BitGoPsbt } from "../../js/fixedScriptWallet.js";
4+
import { BitGoPsbt, InputScriptType } from "../../js/fixedScriptWallet.js";
55
import { loadPsbtFixture, loadWalletKeysFromFixture, getPsbtBuffer } from "./fixtureUtil.js";
66

7+
function getExpectedInputScriptType(fixtureScriptType: string): InputScriptType {
8+
// Map fixture types to InputScriptType values
9+
// Based on the Rust mapping in src/fixed_script_wallet/test_utils/fixtures.rs
10+
switch (fixtureScriptType) {
11+
case "p2shP2pk":
12+
case "p2sh":
13+
case "p2shP2wsh":
14+
case "p2wsh":
15+
return fixtureScriptType;
16+
case "p2tr":
17+
return "p2trLegacy";
18+
case "p2trMusig2":
19+
return "p2trMusig2ScriptPath";
20+
case "taprootKeyPathSpend":
21+
return "p2trMusig2KeyPath";
22+
default:
23+
throw new Error(`Unknown fixture script type: ${fixtureScriptType}`);
24+
}
25+
}
26+
727
function getOtherWalletKeys(): utxolib.bitgo.RootWalletKeys {
828
const otherWalletKeys = utxolib.testutil.getKeyTriple("too many secrets");
929
return new utxolib.bitgo.RootWalletKeys(otherWalletKeys);
@@ -34,9 +54,11 @@ describe("parseTransactionWithWalletKeys", function () {
3454
let fullsignedPsbtBytes: Buffer;
3555
let bitgoPsbt: BitGoPsbt;
3656
let rootWalletKeys: utxolib.bitgo.RootWalletKeys;
57+
let fixture: ReturnType<typeof loadPsbtFixture>;
3758

3859
before(function () {
39-
fullsignedPsbtBytes = getPsbtBuffer(loadPsbtFixture(networkName, "fullsigned"));
60+
fixture = loadPsbtFixture(networkName, "fullsigned");
61+
fullsignedPsbtBytes = getPsbtBuffer(fixture);
4062
bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBytes, networkName);
4163
rootWalletKeys = loadWalletKeysFromFixture(networkName);
4264
});
@@ -117,6 +139,23 @@ describe("parseTransactionWithWalletKeys", function () {
117139
assert.ok(parsed.virtualSize > 0, "Virtual size should be > 0");
118140
});
119141

142+
it("should parse inputs with correct scriptType", function () {
143+
const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, {
144+
outputScripts: [replayProtectionScript],
145+
});
146+
147+
// Verify all inputs have scriptType matching fixture
148+
parsed.inputs.forEach((input, i) => {
149+
const fixtureInput = fixture.psbtInputs[i];
150+
const expectedScriptType = getExpectedInputScriptType(fixtureInput.type);
151+
assert.strictEqual(
152+
input.scriptType,
153+
expectedScriptType,
154+
`Input ${i} scriptType should be ${expectedScriptType}, got ${input.scriptType}`,
155+
);
156+
});
157+
});
158+
120159
it("should fail to parse with other wallet keys", function () {
121160
assert.throws(
122161
() => {

0 commit comments

Comments
 (0)