Skip to content

Commit dd808d2

Browse files
refactor: add dynamic dispatch and Zcash hydration support
Extends BitGoPsbt.fromHalfSignedLegacyTransaction() with: - Dynamic dispatch to detect transaction type (Bitcoin/Dash/Zcash) - Early rejection of Zcash with helpful error message - ITransaction parameter overload to avoid re-parsing Adds ZcashBitGoPsbt.fromHalfSignedLegacyTransaction(): - Block height mode (recommended): auto-determines consensus_branch_id - Explicit consensus_branch_id mode for advanced usage - Full Sapling field restoration - Comprehensive round-trip tests Rust layer implements: - from_half_signed_legacy_transaction_zcash() with block height - from_half_signed_legacy_transaction_zcash_with_consensus_branch_id() - Shared logic for Zcash wire format decoding Transaction classes add supportsCoin() for dispatch routing. WASM bindings expose both Zcash variants with unified unspent parsing. Co-authored-by: llm-git <llm-git@ttll.de> refactor: change BitGoPsbt.fromHalfSignedLegacyTransaction to accept Transaction directly - Accept Transaction | DashTransaction instead of Uint8Array to avoid round-tripping through bytes - Extract WASM transaction directly and pass to WASM layer without re-serialization - Add Rust overload from_half_signed_legacy_transaction_dash for DashTransaction support - Update TypeScript to route to correct WASM method based on transaction type - Update all tests to parse transactions first before calling fromHalfSignedLegacyTransaction - Simplify the call site in BitGoPsbt - no more instanceof checks for coin names - WasmBitGoPsbt is private so breaking change in signature is allowed - All 13 fromHalfSignedLegacy tests pass Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 2e80e26 commit dd808d2

10 files changed

Lines changed: 622 additions & 89 deletions

File tree

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js";
1111
import { type ECPairArg, ECPair } from "../ecpair.js";
1212
import type { UtxolibName } from "../utxolibCompat.js";
1313
import type { CoinName } from "../coinName.js";
14+
import { toCoinName } from "../coinName.js";
1415
import type { InputScriptType } from "./scriptType.js";
1516
import {
1617
Transaction,
@@ -196,24 +197,52 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddre
196197
* with proper wallet metadata (bip32Derivation, scripts, witnessUtxo).
197198
* Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot).
198199
*
199-
* @param txBytes - The serialized half-signed legacy transaction
200+
* Supports both Bitcoin-like coins (BTC, LTC, DOGE) and Dash (DASH).
201+
* Zcash is NOT supported; use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction instead.
202+
*
203+
* @param txBytesOrTx - Transaction bytes or decoded transaction instance (Bitcoin-like or Dash)
200204
* @param network - Network name
201205
* @param walletKeys - The wallet's root keys
202206
* @param unspents - Chain, index, and value for each input
207+
* @throws Error if transaction is Zcash (use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction instead)
203208
*/
204209
static fromHalfSignedLegacyTransaction(
205-
txBytes: Uint8Array,
210+
txBytesOrTx: Uint8Array | Transaction | DashTransaction,
206211
network: NetworkName,
207212
walletKeys: WalletKeysArg,
208213
unspents: HydrationUnspent[],
214+
_options?: unknown,
209215
): BitGoPsbt {
210216
const keys = RootWalletKeys.from(walletKeys);
211-
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction(
212-
txBytes,
213-
network,
214-
keys.wasm,
215-
unspents,
216-
);
217+
218+
// Parse bytes to Transaction if needed
219+
const tx =
220+
txBytesOrTx instanceof Uint8Array
221+
? Transaction.fromBytes(txBytesOrTx, toCoinName(network))
222+
: txBytesOrTx;
223+
224+
// Validate that this is not a Zcash transaction
225+
if (tx instanceof ZcashTransaction) {
226+
throw new Error('Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction() for Zcash transactions');
227+
}
228+
229+
// Pass WASM transaction directly to avoid serialization round-trip
230+
let wasm;
231+
if (tx instanceof DashTransaction) {
232+
wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_dash(
233+
tx.wasm,
234+
network,
235+
keys.wasm,
236+
unspents,
237+
);
238+
} else {
239+
wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction(
240+
tx.wasm,
241+
network,
242+
keys.wasm,
243+
unspents,
244+
);
245+
}
217246
return new BitGoPsbt(wasm);
218247
}
219248

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
22
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
3-
import { BitGoPsbt, type CreateEmptyOptions } from "./BitGoPsbt.js";
4-
import { ZcashTransaction } from "../transaction.js";
3+
import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js";
4+
import { ZcashTransaction, type ITransaction } from "../transaction.js";
55

66
/** Zcash network names */
77
export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec";
@@ -144,6 +144,80 @@ export class ZcashBitGoPsbt extends BitGoPsbt {
144144
return new ZcashBitGoPsbt(wasm);
145145
}
146146

147+
/**
148+
* Reconstruct a Zcash PSBT from a half-signed legacy transaction
149+
*
150+
* This is the inverse of `getHalfSignedLegacyFormat()` for Zcash. It decodes the Zcash wire
151+
* format (which includes version_group_id, expiry_height, and sapling fields), extracts
152+
* partial signatures, and reconstructs a proper Zcash PSBT with consensus metadata.
153+
*
154+
* Supports two modes for determining consensus_branch_id:
155+
* - **Recommended**: Pass `blockHeight` to auto-determine consensus_branch_id via network upgrade activation heights
156+
* - **Advanced**: Pass `consensusBranchId` directly if you already know it (e.g., 0xC2D6D0B4 for NU5)
157+
*
158+
* @param txBytesOrTx - Either serialized Zcash transaction bytes or a decoded ZcashTransaction instance
159+
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
160+
* @param walletKeys - The wallet's root keys
161+
* @param unspents - Chain, index, and value for each input
162+
* @param options - Either `{ blockHeight: number }` or `{ consensusBranchId: number }`
163+
* @returns A ZcashBitGoPsbt instance
164+
*
165+
* @example
166+
* ```typescript
167+
* // Round-trip with block height (recommended)
168+
* const legacyBytes = psbt.getHalfSignedLegacyFormat();
169+
* const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction(
170+
* legacyBytes,
171+
* "zec",
172+
* walletKeys,
173+
* unspents,
174+
* { blockHeight: 1687105 } // NU5 activation height
175+
* );
176+
*
177+
* // Or with explicit consensus branch ID
178+
* const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction(
179+
* legacyBytes,
180+
* "zec",
181+
* walletKeys,
182+
* unspents,
183+
* { consensusBranchId: 0xC2D6D0B4 } // NU5 branch ID
184+
* );
185+
* ```
186+
*/
187+
static fromHalfSignedLegacyTransaction(
188+
txBytesOrTx: Uint8Array | ITransaction,
189+
network: ZcashNetworkName,
190+
walletKeys: WalletKeysArg,
191+
unspents: HydrationUnspent[],
192+
options: { blockHeight: number } | { consensusBranchId: number },
193+
): ZcashBitGoPsbt {
194+
const keys = RootWalletKeys.from(walletKeys);
195+
const tx =
196+
txBytesOrTx instanceof Uint8Array
197+
? ZcashTransaction.fromBytes(txBytesOrTx)
198+
: (txBytesOrTx as ZcashTransaction);
199+
200+
if ("blockHeight" in options) {
201+
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_zcash_with_block_height(
202+
tx.wasm,
203+
network,
204+
keys.wasm,
205+
unspents,
206+
options.blockHeight,
207+
);
208+
return new ZcashBitGoPsbt(wasm);
209+
} else {
210+
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_zcash_with_branch_id(
211+
tx.wasm,
212+
network,
213+
keys.wasm,
214+
unspents,
215+
options.consensusBranchId,
216+
);
217+
return new ZcashBitGoPsbt(wasm);
218+
}
219+
}
220+
147221
// --- Zcash-specific getters ---
148222

149223
/**

packages/wasm-utxo/js/transaction.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,33 @@ export class Transaction extends TransactionBase<WasmTransaction> {
3030
super(wasm);
3131
}
3232

33+
/**
34+
* Check if a coin is supported by this transaction class.
35+
* Bitcoin-like transactions support all coins except Zcash and Dash.
36+
*/
37+
static supportsCoin(coin: CoinName): boolean {
38+
return !ZcashTransaction.supportsCoin(coin) && !DashTransaction.supportsCoin(coin);
39+
}
40+
3341
/**
3442
* Create an empty transaction (version 1, locktime 0)
3543
*/
3644
static create(): Transaction {
3745
return new Transaction(WasmTransaction.create());
3846
}
3947

40-
static fromBytes(bytes: Uint8Array): Transaction {
48+
static fromBytes(bytes: Uint8Array): Transaction;
49+
static fromBytes(bytes: Uint8Array, coin: "zec" | "tzec"): ZcashTransaction;
50+
static fromBytes(bytes: Uint8Array, coin: "dash" | "tdash"): DashTransaction;
51+
static fromBytes(bytes: Uint8Array, coin: CoinName): Transaction | ZcashTransaction | DashTransaction;
52+
static fromBytes(
53+
bytes: Uint8Array,
54+
coin?: CoinName,
55+
): Transaction | ZcashTransaction | DashTransaction {
56+
if (coin !== undefined) {
57+
if (ZcashTransaction.supportsCoin(coin)) return ZcashTransaction.fromBytes(bytes);
58+
if (DashTransaction.supportsCoin(coin)) return DashTransaction.fromBytes(bytes);
59+
}
4160
return new Transaction(WasmTransaction.from_bytes(bytes));
4261
}
4362

@@ -96,6 +115,14 @@ export class ZcashTransaction extends TransactionBase<WasmZcashTransaction> {
96115
super(wasm);
97116
}
98117

118+
/**
119+
* Check if a coin is supported by this transaction class.
120+
* Zcash transactions support Zcash mainnet and testnet.
121+
*/
122+
static supportsCoin(coin: CoinName): boolean {
123+
return coin === "zec" || coin === "tzec";
124+
}
125+
99126
static fromBytes(bytes: Uint8Array): ZcashTransaction {
100127
return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes));
101128
}
@@ -121,6 +148,14 @@ export class DashTransaction extends TransactionBase<WasmDashTransaction> {
121148
super(wasm);
122149
}
123150

151+
/**
152+
* Check if a coin is supported by this transaction class.
153+
* Dash transactions support Dash mainnet and testnet.
154+
*/
155+
static supportsCoin(coin: CoinName): boolean {
156+
return coin === "dash" || coin === "tdash";
157+
}
158+
124159
static fromBytes(bytes: Uint8Array): DashTransaction {
125160
return new DashTransaction(WasmDashTransaction.from_bytes(bytes));
126161
}
@@ -135,3 +170,4 @@ export class DashTransaction extends TransactionBase<WasmDashTransaction> {
135170
return this._wasm;
136171
}
137172
}
173+

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

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -525,11 +525,100 @@ impl BitGoPsbt {
525525
unspents: &[HydrationUnspentInput],
526526
) -> Result<Self, String> {
527527
use miniscript::bitcoin::consensus::Decodable;
528-
use miniscript::bitcoin::{PublicKey, Transaction};
528+
use miniscript::bitcoin::Transaction;
529529

530530
let tx = Transaction::consensus_decode(&mut &tx_bytes[..])
531531
.map_err(|e| format!("Failed to decode transaction: {}", e))?;
532532

533+
let version = tx.version.0;
534+
let lock_time = tx.lock_time.to_consensus_u32();
535+
536+
let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time));
537+
538+
Self::hydrate_psbt(&mut psbt, &tx, wallet_keys, unspents)?;
539+
540+
Ok(psbt)
541+
}
542+
543+
/// Convert a half-signed legacy Zcash transaction to a PSBT with Zcash metadata.
544+
///
545+
/// This is the Zcash-specific inverse of `get_half_signed_legacy_format()`.
546+
/// It decodes the Zcash wire format (which includes version_group_id, expiry_height, sapling_fields),
547+
/// extracts partial signatures, and reconstructs a Zcash PSBT.
548+
///
549+
/// Unlike `from_half_signed_legacy_transaction`, this requires a `block_height` to determine
550+
/// the correct `consensus_branch_id` via network upgrade activation height lookup.
551+
///
552+
/// # Arguments
553+
/// * `tx_bytes` - Zcash transaction bytes (overwintered format)
554+
/// * `network` - Zcash network (Zcash or ZcashTestnet)
555+
/// * `wallet_keys` - The wallet's root keys
556+
/// * `unspents` - Chain, index, value for each input
557+
/// * `block_height` - Block height to determine consensus branch ID
558+
///
559+
/// # Returns
560+
/// Thin wrapper over `from_half_signed_legacy_transaction_zcash_with_consensus_branch_id`.
561+
/// Resolves consensus_branch_id from block height.
562+
/// Convert a half-signed legacy Zcash transaction to a PSBT with explicit consensus branch ID.
563+
///
564+
/// This is similar to `from_half_signed_legacy_transaction_zcash`, but takes an explicit
565+
/// `consensus_branch_id` instead of deriving it from block height. Use this when you
566+
/// already know the consensus branch ID (e.g., 0xC2D6D0B4 for NU5, 0x76B809BB for Sapling).
567+
///
568+
/// # Arguments
569+
/// * `tx_bytes` - Zcash transaction bytes (overwintered format)
570+
/// * `network` - Zcash network (Zcash or ZcashTestnet)
571+
/// * `wallet_keys` - The wallet's root keys
572+
/// * `unspents` - Chain, index, value for each input
573+
/// * `consensus_branch_id` - Explicit consensus branch ID for sighash computation
574+
///
575+
/// # Returns
576+
/// A BitGoPsbt::Zcash instance with restored Sapling fields
577+
/// Helper that accepts already-decoded Zcash transaction parts (avoiding re-parsing).
578+
pub fn from_half_signed_legacy_transaction_zcash_with_consensus_branch_id_from_parts(
579+
parts: &crate::zcash::transaction::ZcashTransactionParts,
580+
network: Network,
581+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
582+
unspents: &[HydrationUnspentInput],
583+
consensus_branch_id: u32,
584+
) -> Result<Self, String> {
585+
let tx = &parts.transaction;
586+
let version = tx.version.0;
587+
let lock_time = tx.lock_time.to_consensus_u32();
588+
589+
// Create Zcash PSBT using explicit consensus_branch_id
590+
let mut psbt = Self::new_zcash(
591+
network,
592+
wallet_keys,
593+
consensus_branch_id,
594+
Some(version),
595+
Some(lock_time),
596+
parts.version_group_id,
597+
parts.expiry_height,
598+
);
599+
600+
Self::hydrate_psbt(&mut psbt, tx, wallet_keys, unspents)?;
601+
602+
// Restore Sapling fields in the Zcash PSBT variant
603+
if let BitGoPsbt::Zcash(ref mut zcash_psbt, _) = psbt {
604+
zcash_psbt.sapling_fields = parts.sapling_fields.clone();
605+
}
606+
607+
Ok(psbt)
608+
}
609+
610+
/// Helper that accepts already-decoded Zcash transaction parts (avoiding re-parsing).
611+
/// Private helper: hydrate inputs and outputs in an already-created PSBT.
612+
/// Shared logic for both Bitcoin-like and Zcash variants.
613+
fn hydrate_psbt(
614+
psbt: &mut BitGoPsbt,
615+
tx: &miniscript::bitcoin::Transaction,
616+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
617+
unspents: &[HydrationUnspentInput],
618+
) -> Result<(), String> {
619+
use miniscript::bitcoin::PublicKey;
620+
621+
// Validate input count
533622
if tx.input.len() != unspents.len() {
534623
return Err(format!(
535624
"Input count mismatch: tx has {} inputs, got {} unspents",
@@ -538,11 +627,6 @@ impl BitGoPsbt {
538627
));
539628
}
540629

541-
let version = tx.version.0;
542-
let lock_time = tx.lock_time.to_consensus_u32();
543-
544-
let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time));
545-
546630
// Parse each input from the legacy tx
547631
let input_results: Vec<legacy_txformat::LegacyInputResult> = tx
548632
.input
@@ -554,6 +638,7 @@ impl BitGoPsbt {
554638
})
555639
.collect::<Result<Vec<_>, _>>()?;
556640

641+
// Hydrate inputs
557642
for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() {
558643
match (&input_results[i], unspent) {
559644
(
@@ -573,7 +658,7 @@ impl BitGoPsbt {
573658
psbt_wallet_input::WalletInputOptions {
574659
sign_path: None,
575660
sequence: Some(tx_in.sequence.0),
576-
prev_tx: None, // psbt-lite: no nonWitnessUtxo
661+
prev_tx: None,
577662
},
578663
)
579664
.map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?;
@@ -652,12 +737,12 @@ impl BitGoPsbt {
652737
}
653738
}
654739

655-
// Add outputs (plain script+value, no wallet metadata)
740+
// Add outputs
656741
for tx_out in &tx.output {
657742
psbt.add_output(tx_out.script_pubkey.clone(), tx_out.value.to_sat());
658743
}
659744

660-
Ok(psbt)
745+
Ok(())
661746
}
662747

663748
fn new_internal(

0 commit comments

Comments
 (0)