Skip to content

Commit c0ada5b

Browse files
Merge pull request #252 from BitGo/BTC-0.decode-zcash-tx-support
refactor(wasm-utxo): add dynamic dispatch and Zcash hydration support
2 parents 7ad3564 + 6a46f4b commit c0ada5b

10 files changed

Lines changed: 645 additions & 116 deletions

File tree

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

Lines changed: 34 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,49 @@ 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+
* @param _options - Reserved for future use and signature compatibility with subclasses
208+
* @throws Error if transaction is Zcash (use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction instead)
203209
*/
204210
static fromHalfSignedLegacyTransaction(
205-
txBytes: Uint8Array,
211+
txBytesOrTx: Uint8Array | Transaction | DashTransaction,
206212
network: NetworkName,
207213
walletKeys: WalletKeysArg,
208214
unspents: HydrationUnspent[],
215+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
216+
_options?: unknown,
209217
): BitGoPsbt {
210218
const keys = RootWalletKeys.from(walletKeys);
211-
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction(
212-
txBytes,
213-
network,
214-
keys.wasm,
215-
unspents,
216-
);
219+
220+
// Parse bytes to Transaction if needed
221+
const tx =
222+
txBytesOrTx instanceof Uint8Array
223+
? Transaction.fromBytes(txBytesOrTx, toCoinName(network))
224+
: txBytesOrTx;
225+
226+
// Validate that this is not a Zcash transaction
227+
if (tx instanceof ZcashTransaction) {
228+
throw new Error(
229+
"Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction() for Zcash transactions",
230+
);
231+
}
232+
233+
// Pass WASM transaction directly to avoid serialization round-trip
234+
const wasm: WasmBitGoPsbt =
235+
tx instanceof DashTransaction
236+
? WasmBitGoPsbt.from_half_signed_legacy_transaction_dash(
237+
tx.wasm,
238+
network,
239+
keys.wasm,
240+
unspents,
241+
)
242+
: WasmBitGoPsbt.from_half_signed_legacy_transaction(tx.wasm, network, keys.wasm, unspents);
217243
return new BitGoPsbt(wasm);
218244
}
219245

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: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,36 @@ 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(
52+
bytes: Uint8Array,
53+
coin: CoinName,
54+
): Transaction | ZcashTransaction | DashTransaction;
55+
static fromBytes(
56+
bytes: Uint8Array,
57+
coin?: CoinName,
58+
): Transaction | ZcashTransaction | DashTransaction {
59+
if (coin !== undefined) {
60+
if (ZcashTransaction.supportsCoin(coin)) return ZcashTransaction.fromBytes(bytes);
61+
if (DashTransaction.supportsCoin(coin)) return DashTransaction.fromBytes(bytes);
62+
}
4163
return new Transaction(WasmTransaction.from_bytes(bytes));
4264
}
4365

@@ -96,6 +118,14 @@ export class ZcashTransaction extends TransactionBase<WasmZcashTransaction> {
96118
super(wasm);
97119
}
98120

121+
/**
122+
* Check if a coin is supported by this transaction class.
123+
* Zcash transactions support Zcash mainnet and testnet.
124+
*/
125+
static supportsCoin(coin: CoinName): boolean {
126+
return coin === "zec" || coin === "tzec";
127+
}
128+
99129
static fromBytes(bytes: Uint8Array): ZcashTransaction {
100130
return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes));
101131
}
@@ -121,6 +151,14 @@ export class DashTransaction extends TransactionBase<WasmDashTransaction> {
121151
super(wasm);
122152
}
123153

154+
/**
155+
* Check if a coin is supported by this transaction class.
156+
* Dash transactions support Dash mainnet and testnet.
157+
*/
158+
static supportsCoin(coin: CoinName): boolean {
159+
return coin === "dash" || coin === "tdash";
160+
}
161+
124162
static fromBytes(bytes: Uint8Array): DashTransaction {
125163
return new DashTransaction(WasmDashTransaction.from_bytes(bytes));
126164
}

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)