Skip to content

Commit d089518

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add legacy transaction format extraction
Adds support for extracting half-signed transactions in the legacy format used by bitcoinjs-lib and utxo-lib. This format places signatures in the correct position for p2ms-based inputs (p2sh, p2shP2wsh, p2wsh) with empty placeholders for missing signatures. The implementation handles all supported networks and script types and produces output compatible with utxo-lib's extractP2msOnlyHalfSignedTx. Issue: BTC-2993 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 9fba676 commit d089518

7 files changed

Lines changed: 733 additions & 56 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,4 +761,33 @@ export class BitGoPsbt {
761761
extractTransaction(): Uint8Array {
762762
return this._wasm.extract_transaction();
763763
}
764+
765+
/**
766+
* Extract a half-signed transaction in legacy format for p2ms-based script types.
767+
*
768+
* This method extracts a transaction where each input has exactly one signature,
769+
* formatted in the legacy style used by utxo-lib and bitcoinjs-lib. The legacy
770+
* format places signatures in the correct position (0, 1, or 2) based on which
771+
* key signed, with empty placeholders for unsigned positions.
772+
*
773+
* Requirements:
774+
* - All inputs must be p2ms-based (p2sh, p2shP2wsh, or p2wsh)
775+
* - Each input must have exactly 1 partial signature
776+
*
777+
* @returns The serialized half-signed transaction bytes
778+
* @throws Error if any input is not a p2ms type (Taproot, replay protection, etc.)
779+
* @throws Error if any input has 0 or more than 1 partial signature
780+
*
781+
* @example
782+
* ```typescript
783+
* // Sign with user key only
784+
* psbt.sign(userXpriv);
785+
*
786+
* // Extract half-signed transaction in legacy format
787+
* const halfSignedTx = psbt.getHalfSignedLegacyFormat();
788+
* ```
789+
*/
790+
getHalfSignedLegacyFormat(): Uint8Array {
791+
return this._wasm.extract_half_signed_legacy_tx();
792+
}
764793
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Legacy transaction format extraction for half-signed transactions.
2+
//!
3+
//! This module provides functionality to extract half-signed transactions in the
4+
//! legacy format used by utxo-lib and bitcoinjs-lib, where signatures are placed
5+
//! in scriptSig/witness with OP_0 placeholders for missing signatures.
6+
7+
use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3;
8+
use miniscript::bitcoin::blockdata::opcodes::all::OP_PUSHBYTES_0;
9+
use miniscript::bitcoin::blockdata::script::Builder;
10+
use miniscript::bitcoin::psbt::Psbt;
11+
use miniscript::bitcoin::script::PushBytesBuf;
12+
use miniscript::bitcoin::{Transaction, Witness};
13+
14+
/// Build a half-signed transaction in legacy format from a PSBT.
15+
///
16+
/// Returns the Transaction with signatures placed in scriptSig/witness.
17+
/// Use `extract_half_signed_legacy_tx` for serialized bytes.
18+
pub fn build_half_signed_legacy_tx(psbt: &Psbt) -> Result<Transaction, String> {
19+
// Validate we have inputs and outputs
20+
if psbt.inputs.is_empty() || psbt.unsigned_tx.output.is_empty() {
21+
return Err("empty inputs or outputs".to_string());
22+
}
23+
24+
// Clone the unsigned transaction - we'll set scriptSig/witness on this
25+
let mut tx = psbt.unsigned_tx.clone();
26+
27+
for (input_index, psbt_input) in psbt.inputs.iter().enumerate() {
28+
// Determine script type and get the multisig script
29+
let (is_p2sh, is_p2wsh, multisig_script) =
30+
if let Some(ref witness_script) = psbt_input.witness_script {
31+
// p2wsh or p2shP2wsh - witness_script contains the multisig script
32+
let is_p2sh = psbt_input.redeem_script.is_some();
33+
(is_p2sh, true, witness_script.clone())
34+
} else if let Some(ref redeem_script) = psbt_input.redeem_script {
35+
// p2sh only - redeem_script contains the multisig script
36+
(true, false, redeem_script.clone())
37+
} else {
38+
return Err(format!(
39+
"Input {}: unsupported script type (no witness_script or redeem_script found). \
40+
Only p2ms-based types (p2sh, p2shP2wsh, p2wsh) are supported.",
41+
input_index
42+
));
43+
};
44+
45+
// Check for taproot inputs (not supported)
46+
if !psbt_input.tap_script_sigs.is_empty() || !psbt_input.tap_key_origins.is_empty() {
47+
return Err(format!(
48+
"Input {}: Taproot inputs are not supported in legacy half-signed format",
49+
input_index
50+
));
51+
}
52+
53+
// Validate exactly 1 partial signature
54+
let sig_count = psbt_input.partial_sigs.len();
55+
if sig_count != 1 {
56+
return Err(format!(
57+
"Input {}: expected exactly 1 partial signature, got {}",
58+
input_index, sig_count
59+
));
60+
}
61+
62+
// Get the single partial signature
63+
let (sig_pubkey, ecdsa_sig) = psbt_input.partial_sigs.iter().next().unwrap();
64+
65+
// Parse the multisig script to get the 3 public keys
66+
let pubkeys = parse_multisig_script_2_of_3(&multisig_script).map_err(|e| {
67+
format!(
68+
"Input {}: failed to parse multisig script: {}",
69+
input_index, e
70+
)
71+
})?;
72+
73+
// Find which key index (0, 1, 2) matches the signature's pubkey
74+
let sig_key_index = pubkeys
75+
.iter()
76+
.position(|pk| pk.to_bytes() == sig_pubkey.to_bytes()[..])
77+
.ok_or_else(|| {
78+
format!(
79+
"Input {}: signature pubkey not found in multisig script",
80+
input_index
81+
)
82+
})?;
83+
84+
// Serialize the signature
85+
let sig_bytes = ecdsa_sig.to_vec();
86+
87+
// Build the signatures array with the signature in the correct position
88+
// Format: [OP_0, sig_or_empty, sig_or_empty, sig_or_empty]
89+
let mut sig_stack: Vec<Vec<u8>> = vec![vec![]]; // Start with OP_0 (empty)
90+
for i in 0..3 {
91+
if i == sig_key_index {
92+
sig_stack.push(sig_bytes.clone());
93+
} else {
94+
sig_stack.push(vec![]); // OP_0 placeholder
95+
}
96+
}
97+
98+
// Build scriptSig and/or witness based on script type
99+
if is_p2wsh {
100+
// p2wsh or p2shP2wsh: witness = [empty, sigs..., witnessScript]
101+
let mut witness_items = sig_stack;
102+
witness_items.push(multisig_script.to_bytes());
103+
tx.input[input_index].witness = Witness::from_slice(&witness_items);
104+
105+
if is_p2sh {
106+
// p2shP2wsh: also need scriptSig = [redeemScript]
107+
// The redeemScript is the p2wsh script (hash of witness script)
108+
let redeem_script = psbt_input.redeem_script.as_ref().unwrap();
109+
let redeem_script_bytes = PushBytesBuf::try_from(redeem_script.to_bytes())
110+
.map_err(|e| {
111+
format!(
112+
"Input {}: failed to convert redeem script to push bytes: {}",
113+
input_index, e
114+
)
115+
})?;
116+
let script_sig = Builder::new().push_slice(redeem_script_bytes).into_script();
117+
tx.input[input_index].script_sig = script_sig;
118+
}
119+
} else {
120+
// p2sh only: scriptSig = [OP_0, sigs..., redeemScript]
121+
let mut builder = Builder::new().push_opcode(OP_PUSHBYTES_0);
122+
for i in 0..3 {
123+
if i == sig_key_index {
124+
let sig_push_bytes =
125+
PushBytesBuf::try_from(sig_bytes.clone()).map_err(|e| {
126+
format!(
127+
"Input {}: failed to convert signature to push bytes: {}",
128+
input_index, e
129+
)
130+
})?;
131+
builder = builder.push_slice(sig_push_bytes);
132+
} else {
133+
builder = builder.push_opcode(OP_PUSHBYTES_0);
134+
}
135+
}
136+
let multisig_push_bytes =
137+
PushBytesBuf::try_from(multisig_script.to_bytes()).map_err(|e| {
138+
format!(
139+
"Input {}: failed to convert multisig script to push bytes: {}",
140+
input_index, e
141+
)
142+
})?;
143+
builder = builder.push_slice(multisig_push_bytes);
144+
tx.input[input_index].script_sig = builder.into_script();
145+
}
146+
}
147+
148+
Ok(tx)
149+
}

0 commit comments

Comments
 (0)