Skip to content

Commit a4b730d

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): implement single input signing methods
Add sign methods to BitGoPsbt to support signing individual inputs by index: - Implement `sign` in JS that dispatches based on key type - Add Rust `sign_with_xpriv` method for wallet inputs (BIP32) - Add Rust `sign_with_privkey` method for raw private keys (ECPair) - Handle MuSig2 inputs with existing first round state - Support replay protection inputs with proper P2SH sighashing - Add comprehensive tests and documentation Issue: BTC-2786 Co-authored-by: llm-git <[email protected]>
1 parent a9d717b commit a4b730d

File tree

6 files changed

+515
-37
lines changed

6 files changed

+515
-37
lines changed

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,66 @@ export class BitGoPsbt {
142142
return this.wasm.verify_signature_with_pub(inputIndex, wasmECPair);
143143
}
144144

145+
/**
146+
* Sign a single input with a private key
147+
*
148+
* This method signs a specific input using the provided key. It accepts either:
149+
* - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs - derives the key and signs
150+
* - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs - signs directly
151+
*
152+
* This method automatically detects and handles different input types:
153+
* - For regular inputs: uses standard PSBT signing
154+
* - For MuSig2 inputs: uses the FirstRound state stored by generateMusig2Nonces()
155+
* - For replay protection inputs: signs with legacy P2SH sighash
156+
*
157+
* @param inputIndex - The index of the input to sign (0-based)
158+
* @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg)
159+
* @throws Error if signing fails, or if generateMusig2Nonces() was not called first for MuSig2 inputs
160+
*
161+
* @example
162+
* ```typescript
163+
* // Parse transaction to identify input types
164+
* const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection);
165+
*
166+
* // Sign regular wallet inputs with xpriv
167+
* for (let i = 0; i < parsed.inputs.length; i++) {
168+
* const input = parsed.inputs[i];
169+
* if (input.scriptId !== null && input.scriptType !== "p2shP2pk") {
170+
* psbt.sign(i, userXpriv);
171+
* }
172+
* }
173+
*
174+
* // Sign replay protection inputs with raw privkey
175+
* const userPrivkey = bip32.fromBase58(userXpriv).privateKey!;
176+
* for (let i = 0; i < parsed.inputs.length; i++) {
177+
* const input = parsed.inputs[i];
178+
* if (input.scriptType === "p2shP2pk") {
179+
* psbt.sign(i, userPrivkey);
180+
* }
181+
* }
182+
* ```
183+
*/
184+
sign(inputIndex: number, key: BIP32Arg | ECPairArg): void {
185+
// Detect key type
186+
// If string or has 'derive' method → BIP32Arg
187+
// Otherwise → ECPairArg
188+
if (
189+
typeof key === "string" ||
190+
(typeof key === "object" &&
191+
key !== null &&
192+
"derive" in key &&
193+
typeof key.derive === "function")
194+
) {
195+
// It's a BIP32Arg
196+
const wasmKey = BIP32.from(key as BIP32Arg);
197+
this.wasm.sign_with_xpriv(inputIndex, wasmKey.wasm);
198+
} else {
199+
// It's an ECPairArg
200+
const wasmKey = ECPair.from(key as ECPairArg);
201+
this.wasm.sign_with_privkey(inputIndex, wasmKey.wasm);
202+
}
203+
}
204+
145205
/**
146206
* @deprecated - use verifySignature with the replay protection key instead
147207
*

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,146 @@ impl BitGoPsbt {
426426
.map_err(|e| e.to_string())
427427
}
428428

429+
/// Sign a single input with a raw private key
430+
///
431+
/// This method signs a specific input using the provided private key. It automatically
432+
/// detects the input type and uses the appropriate signing method:
433+
/// - Replay protection inputs (P2SH-P2PK): Signs with legacy P2SH sighash
434+
/// - Regular inputs: Uses standard PSBT signing
435+
/// - MuSig2 inputs: Returns error (requires FirstRound state, use sign_with_first_round)
436+
///
437+
/// # Arguments
438+
/// - `input_index`: The index of the input to sign
439+
/// - `privkey`: The private key to sign with
440+
///
441+
/// # Returns
442+
/// - `Ok(())` if signing was successful
443+
/// - `Err(String)` if signing fails or input type is not supported
444+
pub fn sign_with_privkey(
445+
&mut self,
446+
input_index: usize,
447+
privkey: &secp256k1::SecretKey,
448+
) -> Result<(), String> {
449+
use miniscript::bitcoin::{
450+
ecdsa::Signature as EcdsaSignature, hashes::Hash, sighash::SighashCache, PublicKey,
451+
};
452+
453+
// Get network before mutable borrow
454+
let network = self.network();
455+
let is_testnet = network.is_testnet();
456+
457+
let psbt = self.psbt_mut();
458+
459+
// Check bounds
460+
if input_index >= psbt.inputs.len() {
461+
return Err(format!(
462+
"Input index {} out of bounds (total inputs: {})",
463+
input_index,
464+
psbt.inputs.len()
465+
));
466+
}
467+
468+
// Check if this is a MuSig2 input
469+
if p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) {
470+
return Err(
471+
"MuSig2 inputs cannot be signed with raw privkey. Use sign_with_first_round instead."
472+
.to_string(),
473+
);
474+
}
475+
476+
let secp = secp256k1::Secp256k1::new();
477+
478+
// Derive public key from private key
479+
let public_key = PublicKey::from_slice(
480+
&secp256k1::PublicKey::from_secret_key(&secp, privkey).serialize(),
481+
)
482+
.map_err(|e| format!("Failed to derive public key: {}", e))?;
483+
484+
// Check if this is a replay protection input (P2SH-P2PK)
485+
if let Some(redeem_script) = &psbt.inputs[input_index].redeem_script.clone() {
486+
// Try to extract pubkey from redeem script
487+
if let Ok(redeem_pubkey) = Self::extract_pubkey_from_p2pk_redeem_script(redeem_script) {
488+
// This is a replay protection input - verify the derived pubkey matches
489+
if public_key != redeem_pubkey {
490+
return Err(
491+
"Public key mismatch: derived pubkey does not match redeem_script pubkey"
492+
.to_string(),
493+
);
494+
}
495+
496+
// Sign the replay protection input with legacy P2SH sighash
497+
let sighash_type = miniscript::bitcoin::sighash::EcdsaSighashType::All;
498+
let cache = SighashCache::new(&psbt.unsigned_tx);
499+
let sighash = cache
500+
.legacy_signature_hash(input_index, redeem_script, sighash_type.to_u32())
501+
.map_err(|e| format!("Failed to compute sighash: {}", e))?;
502+
503+
// Create ECDSA signature
504+
let message = secp256k1::Message::from_digest(sighash.to_byte_array());
505+
let signature = secp.sign_ecdsa(&message, privkey);
506+
let ecdsa_sig = EcdsaSignature {
507+
signature,
508+
sighash_type,
509+
};
510+
511+
// Add signature to partial_sigs
512+
psbt.inputs[input_index]
513+
.partial_sigs
514+
.insert(public_key, ecdsa_sig);
515+
516+
return Ok(());
517+
}
518+
}
519+
520+
// For regular inputs (non-RP, non-MuSig2), use standard signing via miniscript
521+
// This will handle legacy, SegWit, and Taproot script path inputs
522+
match self {
523+
BitGoPsbt::BitcoinLike(ref mut psbt, _network) => {
524+
// Create a key provider that returns our single key
525+
// Convert SecretKey to PrivateKey for the GetKey trait
526+
// Note: The network parameter is only used for WIF serialization, not for signing
527+
let bitcoin_network = if is_testnet {
528+
miniscript::bitcoin::Network::Testnet
529+
} else {
530+
miniscript::bitcoin::Network::Bitcoin
531+
};
532+
let private_key = miniscript::bitcoin::PrivateKey::new(*privkey, bitcoin_network);
533+
let key_map = std::collections::BTreeMap::from_iter([(public_key, private_key)]);
534+
535+
// Sign the PSBT
536+
let result = psbt.sign(&key_map, &secp);
537+
538+
// Check if our specific input was signed
539+
match result {
540+
Ok(signing_keys) => {
541+
if signing_keys.contains_key(&input_index) {
542+
Ok(())
543+
} else {
544+
Err(format!(
545+
"Input {} was not signed (no key found or already signed)",
546+
input_index
547+
))
548+
}
549+
}
550+
Err((partial_success, errors)) => {
551+
// Check if there's an error for our specific input
552+
if let Some(error) = errors.get(&input_index) {
553+
Err(format!("Failed to sign input {}: {:?}", input_index, error))
554+
} else if partial_success.contains_key(&input_index) {
555+
// Input was signed successfully despite other errors
556+
Ok(())
557+
} else {
558+
Err(format!("Input {} was not signed", input_index))
559+
}
560+
}
561+
}
562+
}
563+
BitGoPsbt::Zcash(_zcash_psbt, _network) => {
564+
Err("Zcash signing not yet implemented".to_string())
565+
}
566+
}
567+
}
568+
429569
/// Sign the PSBT with the provided key.
430570
/// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt.
431571
///

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ impl WasmECPair {
4141
pub(crate) fn get_public_key(&self) -> PublicKey {
4242
self.key.public_key()
4343
}
44+
45+
/// Get the private key as a secp256k1::SecretKey (for internal Rust use)
46+
pub(crate) fn get_private_key(&self) -> Result<SecretKey, WasmUtxoError> {
47+
self.key
48+
.secret_key()
49+
.ok_or_else(|| WasmUtxoError::new("Cannot get private key from public-only ECPair"))
50+
}
4451
}
4552

4653
#[wasm_bindgen]

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,139 @@ impl BitGoPsbt {
371371
Ok(())
372372
}
373373

374+
/// Sign a single input with an extended private key (xpriv)
375+
///
376+
/// This method signs a specific input using the provided xpriv. It accepts:
377+
/// - An xpriv (WasmBIP32) for wallet inputs - derives the key and signs
378+
///
379+
/// This method automatically detects and handles different input types:
380+
/// - For regular inputs: uses standard PSBT signing
381+
/// - For MuSig2 inputs: uses the FirstRound state stored by generate_musig2_nonces()
382+
/// - For replay protection inputs: returns error (use sign_with_privkey instead)
383+
///
384+
/// # Arguments
385+
/// - `input_index`: The index of the input to sign (0-based)
386+
/// - `xpriv`: The extended private key as a WasmBIP32 instance
387+
///
388+
/// # Returns
389+
/// - `Ok(())` if signing was successful
390+
/// - `Err(WasmUtxoError)` if signing fails
391+
pub fn sign_with_xpriv(
392+
&mut self,
393+
input_index: usize,
394+
xpriv: &WasmBIP32,
395+
) -> Result<(), WasmUtxoError> {
396+
// Extract Xpriv from WasmBIP32
397+
let xpriv = xpriv.to_xpriv()?;
398+
399+
let secp = miniscript::bitcoin::secp256k1::Secp256k1::new();
400+
401+
// Check if this is a MuSig2 input
402+
let psbt = self.psbt.psbt();
403+
if input_index >= psbt.inputs.len() {
404+
return Err(WasmUtxoError::new(&format!(
405+
"Input index {} out of bounds (total inputs: {})",
406+
input_index,
407+
psbt.inputs.len()
408+
)));
409+
}
410+
411+
if crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input(
412+
&psbt.inputs[input_index],
413+
) {
414+
// This is a MuSig2 input - use FirstRound signing
415+
let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv);
416+
let xpub_str = xpub.to_string();
417+
418+
// Remove the stored FirstRound for this (input, xpub) pair (it can only be used once)
419+
let first_round = self.first_rounds.remove(&(input_index, xpub_str.clone()))
420+
.ok_or_else(|| WasmUtxoError::new(&format!(
421+
"No FirstRound found for input {} and xpub {}. You must call generate_musig2_nonces() first.",
422+
input_index, xpub_str
423+
)))?;
424+
425+
// Sign with the FirstRound
426+
self.psbt
427+
.sign_with_first_round(input_index, first_round, &xpriv)
428+
.map_err(|e| {
429+
WasmUtxoError::new(&format!(
430+
"Failed to sign MuSig2 input {}: {}",
431+
input_index, e
432+
))
433+
})?;
434+
435+
Ok(())
436+
} else {
437+
// This is a regular input - use standard signing
438+
// Sign the PSBT - this will attempt to sign all inputs but we only care about the result
439+
// The miniscript sign method returns (SigningKeysMap, SigningErrors) on error
440+
let result = self.psbt.sign(&xpriv, &secp);
441+
442+
// Check if this specific input was signed successfully
443+
match result {
444+
Ok(signing_keys) => {
445+
// Check if our input_index was in the successfully signed keys
446+
if signing_keys.contains_key(&input_index) {
447+
Ok(())
448+
} else {
449+
Err(WasmUtxoError::new(&format!(
450+
"Input {} was not signed (no key found or already signed)",
451+
input_index
452+
)))
453+
}
454+
}
455+
Err((partial_success, errors)) => {
456+
// Check if there's an error for our specific input
457+
if let Some(error) = errors.get(&input_index) {
458+
Err(WasmUtxoError::new(&format!(
459+
"Failed to sign input {}: {:?}",
460+
input_index, error
461+
)))
462+
} else if partial_success.contains_key(&input_index) {
463+
// Input was signed successfully despite other errors
464+
Ok(())
465+
} else {
466+
Err(WasmUtxoError::new(&format!(
467+
"Input {} was not signed",
468+
input_index
469+
)))
470+
}
471+
}
472+
}
473+
}
474+
}
475+
476+
/// Sign a single input with a raw private key
477+
///
478+
/// This method signs a specific input using the provided ECPair. It accepts:
479+
/// - A raw privkey (WasmECPair) for replay protection inputs - signs directly
480+
///
481+
/// This method automatically detects and handles different input types:
482+
/// - For replay protection inputs: signs with legacy P2SH sighash
483+
/// - For regular inputs: uses standard PSBT signing
484+
/// - For MuSig2 inputs: returns error (requires FirstRound, use sign_with_xpriv instead)
485+
///
486+
/// # Arguments
487+
/// - `input_index`: The index of the input to sign (0-based)
488+
/// - `ecpair`: The ECPair containing the private key
489+
///
490+
/// # Returns
491+
/// - `Ok(())` if signing was successful
492+
/// - `Err(WasmUtxoError)` if signing fails
493+
pub fn sign_with_privkey(
494+
&mut self,
495+
input_index: usize,
496+
ecpair: &WasmECPair,
497+
) -> Result<(), WasmUtxoError> {
498+
// Extract private key from WasmECPair
499+
let privkey = ecpair.get_private_key()?;
500+
501+
// Call the Rust implementation
502+
self.psbt
503+
.sign_with_privkey(input_index, &privkey)
504+
.map_err(|e| WasmUtxoError::new(&format!("Failed to sign input: {}", e)))
505+
}
506+
374507
/// Finalize all inputs in the PSBT
375508
///
376509
/// This method attempts to finalize all inputs in the PSBT, computing the final

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js";
77
import { BIP32, type BIP32Interface } from "../../js/bip32.js";
88
import { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js";
99
import { ECPair } from "../../js/ecpair.js";
10+
import { fixedScriptWallet } from "../../js/index.js";
11+
import type { BitGoPsbt, NetworkName } from "../../js/fixedScriptWallet/index.js";
1012

1113
const __filename = fileURLToPath(import.meta.url);
1214
const __dirname = dirname(__filename);
@@ -103,6 +105,16 @@ export function getPsbtBuffer(fixture: Fixture): Buffer {
103105
return Buffer.from(fixture.psbtBase64, "base64");
104106
}
105107

108+
/**
109+
* Get BitGoPsbt from a fixture
110+
* @param fixture - The test fixture
111+
* @param networkName - The network name for deserializing the PSBT
112+
* @returns A BitGoPsbt instance
113+
*/
114+
export function getBitGoPsbt(fixture: Fixture, networkName: NetworkName): BitGoPsbt {
115+
return fixedScriptWallet.BitGoPsbt.fromBytes(getPsbtBuffer(fixture), networkName);
116+
}
117+
106118
/**
107119
* Load a PSBT fixture from JSON file
108120
*/

0 commit comments

Comments
 (0)