Skip to content

Commit 7ac35da

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add legacy tx to PSBT conversion and introspection
Add bidirectional conversion between half-signed legacy transactions and PSBTs. Implement transaction introspection API for accessing inputs, outputs, and metadata across all transaction types. - Add `fromHalfSignedLegacyTransaction()` to convert legacy txs to PSBT - Extract partial sigs from scriptSig/witness in `unsign_legacy_input()` - Expose input/output accessors on Transaction/ZcashTransaction/DashTransaction - Add `getInputs()`, `getOutputs()`, `getOutputsWithAddress()` methods - Include version, lockTime, input/output counts - Export `HydrationUnspent` type for legacy conversion - Add comprehensive round-trip tests for p2sh, p2shP2wsh, p2wsh Co-authored-by: llm-git <llm-git@ttll.de>
1 parent ab0fbc5 commit 7ac35da

11 files changed

Lines changed: 843 additions & 21 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ export type ParseOutputsOptions = {
126126
payGoPubkeys?: ECPairArg[];
127127
};
128128

129+
export type HydrationUnspent = {
130+
chain: number;
131+
index: number;
132+
value: bigint;
133+
};
134+
129135
export class BitGoPsbt implements IPsbtWithAddress {
130136
protected constructor(protected _wasm: WasmBitGoPsbt) {}
131137

@@ -185,6 +191,34 @@ export class BitGoPsbt implements IPsbtWithAddress {
185191
return new BitGoPsbt(wasm);
186192
}
187193

194+
/**
195+
* Convert a half-signed legacy transaction to a psbt-lite.
196+
*
197+
* Extracts partial signatures from scriptSig/witness and creates a PSBT
198+
* with proper wallet metadata (bip32Derivation, scripts, witnessUtxo).
199+
* Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot).
200+
*
201+
* @param txBytes - The serialized half-signed legacy transaction
202+
* @param network - Network name
203+
* @param walletKeys - The wallet's root keys
204+
* @param unspents - Chain, index, and value for each input
205+
*/
206+
static fromHalfSignedLegacyTransaction(
207+
txBytes: Uint8Array,
208+
network: NetworkName,
209+
walletKeys: WalletKeysArg,
210+
unspents: HydrationUnspent[],
211+
): BitGoPsbt {
212+
const keys = RootWalletKeys.from(walletKeys);
213+
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction(
214+
txBytes,
215+
network,
216+
keys.wasm,
217+
unspents,
218+
);
219+
return new BitGoPsbt(wasm);
220+
}
221+
188222
/**
189223
* Add an input to the PSBT
190224
*

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
type AddWalletOutputOptions,
3232
type ParseTransactionOptions,
3333
type ParseOutputsOptions,
34+
type HydrationUnspent,
3435
} from "./BitGoPsbt.js";
3536

3637
// Zcash-specific PSBT subclass

packages/wasm-utxo/js/index.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,35 @@ declare module "./wasm/wasm_utxo.js" {
8686
interface PsbtOutputDataWithAddress extends PsbtOutputData {
8787
address: string;
8888
}
89+
90+
/** Outpoint referencing a previous transaction output */
91+
interface TxOutPoint {
92+
txid: string;
93+
vout: number;
94+
}
95+
96+
/** Raw transaction input data returned by Transaction.getInputs() */
97+
interface TxInputData {
98+
previousOutput: TxOutPoint;
99+
sequence: number;
100+
scriptSig: Uint8Array;
101+
witness: Uint8Array[];
102+
}
103+
104+
/** Raw transaction output data returned by Transaction.getOutputs() */
105+
interface TxOutputData {
106+
script: Uint8Array;
107+
value: bigint;
108+
}
109+
110+
/** Transaction output data with resolved address */
111+
interface TxOutputDataWithAddress extends TxOutputData {
112+
address: string;
113+
}
89114
}
90115

91116
export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo.js";
92117
export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo.js";
93118
export { Psbt } from "./descriptorWallet/Psbt.js";
94-
export { DashTransaction, Transaction, ZcashTransaction } from "./transaction.js";
119+
export { DashTransaction, Transaction, ZcashTransaction, type ITransaction } from "./transaction.js";
95120
export { hasPsbtMagic, type IPsbt, type IPsbtWithAddress } from "./psbt.js";

packages/wasm-utxo/js/transaction.ts

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
1-
import { WasmDashTransaction, WasmTransaction, WasmZcashTransaction } from "./wasm/wasm_utxo.js";
1+
import {
2+
WasmDashTransaction,
3+
WasmTransaction,
4+
WasmZcashTransaction,
5+
type TxInputData,
6+
type TxOutputData,
7+
type TxOutputDataWithAddress,
8+
} from "./wasm/wasm_utxo.js";
9+
import type { CoinName } from "./coinName.js";
210

311
/**
412
* Common interface for all transaction types
513
*/
614
export interface ITransaction {
715
toBytes(): Uint8Array;
816
getId(): string;
17+
inputCount(): number;
18+
outputCount(): number;
19+
get version(): number;
20+
get lockTime(): number;
21+
getInputs(): TxInputData[];
22+
getOutputs(): TxOutputData[];
23+
getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[];
924
}
1025

1126
/**
@@ -27,9 +42,7 @@ export class Transaction implements ITransaction {
2742
return new Transaction(WasmTransaction.from_bytes(bytes));
2843
}
2944

30-
/**
31-
* @internal Create from WASM instance directly (avoids re-parsing bytes)
32-
*/
45+
/** @internal Create from WASM instance directly (avoids re-parsing bytes) */
3346
static fromWasm(wasm: WasmTransaction): Transaction {
3447
return new Transaction(wasm);
3548
}
@@ -84,9 +97,35 @@ export class Transaction implements ITransaction {
8497
return this._wasm.get_vsize();
8598
}
8699

87-
/**
88-
* @internal
89-
*/
100+
inputCount(): number {
101+
return this._wasm.input_count();
102+
}
103+
104+
outputCount(): number {
105+
return this._wasm.output_count();
106+
}
107+
108+
get version(): number {
109+
return this._wasm.version();
110+
}
111+
112+
get lockTime(): number {
113+
return this._wasm.lock_time();
114+
}
115+
116+
getInputs(): TxInputData[] {
117+
return this._wasm.get_inputs() as TxInputData[];
118+
}
119+
120+
getOutputs(): TxOutputData[] {
121+
return this._wasm.get_outputs() as TxOutputData[];
122+
}
123+
124+
getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[] {
125+
return this._wasm.get_outputs_with_address(coin) as TxOutputDataWithAddress[];
126+
}
127+
128+
/** @internal */
90129
get wasm(): WasmTransaction {
91130
return this._wasm;
92131
}
@@ -104,9 +143,7 @@ export class ZcashTransaction implements ITransaction {
104143
return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes));
105144
}
106145

107-
/**
108-
* @internal Create from WASM instance directly (avoids re-parsing bytes)
109-
*/
146+
/** @internal Create from WASM instance directly (avoids re-parsing bytes) */
110147
static fromWasm(wasm: WasmZcashTransaction): ZcashTransaction {
111148
return new ZcashTransaction(wasm);
112149
}
@@ -127,9 +164,35 @@ export class ZcashTransaction implements ITransaction {
127164
return this._wasm.get_txid();
128165
}
129166

130-
/**
131-
* @internal
132-
*/
167+
inputCount(): number {
168+
return this._wasm.input_count();
169+
}
170+
171+
outputCount(): number {
172+
return this._wasm.output_count();
173+
}
174+
175+
get version(): number {
176+
return this._wasm.version();
177+
}
178+
179+
get lockTime(): number {
180+
return this._wasm.lock_time();
181+
}
182+
183+
getInputs(): TxInputData[] {
184+
return this._wasm.get_inputs() as TxInputData[];
185+
}
186+
187+
getOutputs(): TxOutputData[] {
188+
return this._wasm.get_outputs() as TxOutputData[];
189+
}
190+
191+
getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[] {
192+
return this._wasm.get_outputs_with_address(coin) as TxOutputDataWithAddress[];
193+
}
194+
195+
/** @internal */
133196
get wasm(): WasmZcashTransaction {
134197
return this._wasm;
135198
}
@@ -147,9 +210,7 @@ export class DashTransaction implements ITransaction {
147210
return new DashTransaction(WasmDashTransaction.from_bytes(bytes));
148211
}
149212

150-
/**
151-
* @internal Create from WASM instance directly (avoids re-parsing bytes)
152-
*/
213+
/** @internal Create from WASM instance directly (avoids re-parsing bytes) */
153214
static fromWasm(wasm: WasmDashTransaction): DashTransaction {
154215
return new DashTransaction(wasm);
155216
}
@@ -170,9 +231,35 @@ export class DashTransaction implements ITransaction {
170231
return this._wasm.get_txid();
171232
}
172233

173-
/**
174-
* @internal
175-
*/
234+
inputCount(): number {
235+
return this._wasm.input_count();
236+
}
237+
238+
outputCount(): number {
239+
return this._wasm.output_count();
240+
}
241+
242+
get version(): number {
243+
return this._wasm.version();
244+
}
245+
246+
get lockTime(): number {
247+
return this._wasm.lock_time();
248+
}
249+
250+
getInputs(): TxInputData[] {
251+
return this._wasm.get_inputs() as TxInputData[];
252+
}
253+
254+
getOutputs(): TxOutputData[] {
255+
return this._wasm.get_outputs() as TxOutputData[];
256+
}
257+
258+
getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[] {
259+
return this._wasm.get_outputs_with_address(coin) as TxOutputDataWithAddress[];
260+
}
261+
262+
/** @internal */
176263
get wasm(): WasmDashTransaction {
177264
return this._wasm;
178265
}

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

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3;
88
use miniscript::bitcoin::blockdata::opcodes::all::OP_PUSHBYTES_0;
99
use miniscript::bitcoin::blockdata::script::Builder;
10+
use miniscript::bitcoin::ecdsa::Signature as EcdsaSig;
1011
use miniscript::bitcoin::psbt::Psbt;
1112
use miniscript::bitcoin::script::PushBytesBuf;
12-
use miniscript::bitcoin::{Transaction, Witness};
13+
use miniscript::bitcoin::{CompressedPublicKey, ScriptBuf, Transaction, TxIn, Witness};
1314

1415
/// Build a half-signed transaction in legacy format from a PSBT.
1516
///
@@ -147,3 +148,106 @@ pub fn build_half_signed_legacy_tx(psbt: &Psbt) -> Result<Transaction, String> {
147148

148149
Ok(tx)
149150
}
151+
152+
/// A partial signature extracted from a legacy half-signed input.
153+
pub struct LegacyPartialSig {
154+
pub pubkey: CompressedPublicKey,
155+
pub sig: EcdsaSig,
156+
}
157+
158+
/// Determines whether a legacy input uses segwit (witness data) and whether it
159+
/// has a p2sh wrapper (scriptSig pushing a redeem script).
160+
///
161+
/// Returns `(is_p2sh, is_segwit, multisig_script)`.
162+
fn classify_legacy_input(tx_in: &TxIn) -> Result<(bool, bool, ScriptBuf), String> {
163+
let has_witness = !tx_in.witness.is_empty();
164+
let has_script_sig = !tx_in.script_sig.is_empty();
165+
166+
if has_witness {
167+
// Segwit: witness contains [empty, sig0?, sig1?, sig2?, witnessScript]
168+
let witness_items: Vec<&[u8]> = tx_in.witness.iter().collect();
169+
if witness_items.len() < 5 {
170+
return Err(format!(
171+
"Expected at least 5 witness items, got {}",
172+
witness_items.len()
173+
));
174+
}
175+
let multisig_script = ScriptBuf::from(witness_items.last().unwrap().to_vec());
176+
let is_p2sh = has_script_sig; // p2shP2wsh has scriptSig, p2wsh does not
177+
Ok((is_p2sh, true, multisig_script))
178+
} else if has_script_sig {
179+
// p2sh only: scriptSig = [OP_0, sig0?, sig1?, sig2?, redeemScript]
180+
// Parse the scriptSig instructions to extract the redeemScript (last push)
181+
let instructions: Vec<_> = tx_in
182+
.script_sig
183+
.instructions()
184+
.collect::<Result<Vec<_>, _>>()
185+
.map_err(|e| format!("Failed to parse scriptSig: {}", e))?;
186+
if instructions.len() < 5 {
187+
return Err(format!(
188+
"Expected at least 5 scriptSig items, got {}",
189+
instructions.len()
190+
));
191+
}
192+
let last = instructions.last().unwrap();
193+
let multisig_bytes = match last {
194+
miniscript::bitcoin::script::Instruction::PushBytes(bytes) => bytes.as_bytes(),
195+
_ => return Err("Last scriptSig item is not a push".to_string()),
196+
};
197+
Ok((true, false, ScriptBuf::from(multisig_bytes.to_vec())))
198+
} else {
199+
Err("Input has neither witness nor scriptSig".to_string())
200+
}
201+
}
202+
203+
/// Extract a partial signature from a legacy half-signed input.
204+
///
205+
/// This is the inverse of the signature placement in `build_half_signed_legacy_tx`.
206+
/// It parses the scriptSig/witness to find the single signature and its position
207+
/// in the 2-of-3 multisig, then returns the corresponding pubkey and signature.
208+
pub fn unsign_legacy_input(tx_in: &TxIn) -> Result<LegacyPartialSig, String> {
209+
let (_, is_segwit, multisig_script) = classify_legacy_input(tx_in)?;
210+
211+
let pubkeys = parse_multisig_script_2_of_3(&multisig_script)?;
212+
213+
// Extract the 3 signature slots (index 1..=3, skipping the leading OP_0/empty)
214+
let sig_slots: Vec<Vec<u8>> = if is_segwit {
215+
let items: Vec<&[u8]> = tx_in.witness.iter().collect();
216+
// witness = [empty, sig0?, sig1?, sig2?, witnessScript]
217+
items[1..=3].iter().map(|s| s.to_vec()).collect()
218+
} else {
219+
// scriptSig = [OP_0, sig0?, sig1?, sig2?, redeemScript]
220+
let instructions: Vec<_> = tx_in
221+
.script_sig
222+
.instructions()
223+
.collect::<Result<Vec<_>, _>>()
224+
.map_err(|e| format!("Failed to parse scriptSig: {}", e))?;
225+
// instructions[0] = OP_0, [1..=3] = sigs, [4] = redeemScript
226+
instructions[1..=3]
227+
.iter()
228+
.map(|inst| match inst {
229+
miniscript::bitcoin::script::Instruction::PushBytes(bytes) => {
230+
bytes.as_bytes().to_vec()
231+
}
232+
miniscript::bitcoin::script::Instruction::Op(_) => vec![],
233+
})
234+
.collect()
235+
};
236+
237+
// Find the non-empty signature slot
238+
let mut found_sig = None;
239+
for (i, slot) in sig_slots.iter().enumerate() {
240+
if !slot.is_empty() {
241+
if found_sig.is_some() {
242+
return Err("Expected exactly 1 signature, found multiple".to_string());
243+
}
244+
let sig = EcdsaSig::from_slice(slot)
245+
.map_err(|e| format!("Failed to parse signature at position {}: {}", i, e))?;
246+
let pubkey = CompressedPublicKey::from_slice(&pubkeys[i].to_bytes())
247+
.map_err(|e| format!("Failed to convert pubkey: {}", e))?;
248+
found_sig = Some(LegacyPartialSig { pubkey, sig });
249+
}
250+
}
251+
252+
found_sig.ok_or_else(|| "No signature found in input".to_string())
253+
}

0 commit comments

Comments
 (0)