Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js";
import { type ECPairArg, ECPair } from "../ecpair.js";
import type { UtxolibName } from "../utxolibCompat.js";
import type { CoinName } from "../coinName.js";
import { toCoinName } from "../coinName.js";
import type { InputScriptType } from "./scriptType.js";
import {
Transaction,
Expand Down Expand Up @@ -196,24 +197,49 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddre
* with proper wallet metadata (bip32Derivation, scripts, witnessUtxo).
* Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot).
*
* @param txBytes - The serialized half-signed legacy transaction
* Supports both Bitcoin-like coins (BTC, LTC, DOGE) and Dash (DASH).
* Zcash is NOT supported; use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction instead.
*
* @param txBytesOrTx - Transaction bytes or decoded transaction instance (Bitcoin-like or Dash)
* @param network - Network name
* @param walletKeys - The wallet's root keys
* @param unspents - Chain, index, and value for each input
* @param _options - Reserved for future use and signature compatibility with subclasses
* @throws Error if transaction is Zcash (use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction instead)
*/
static fromHalfSignedLegacyTransaction(
txBytes: Uint8Array,
txBytesOrTx: Uint8Array | Transaction | DashTransaction,
network: NetworkName,
walletKeys: WalletKeysArg,
unspents: HydrationUnspent[],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options?: unknown,
): BitGoPsbt {
const keys = RootWalletKeys.from(walletKeys);
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction(
txBytes,
network,
keys.wasm,
unspents,
);

// Parse bytes to Transaction if needed
const tx =
txBytesOrTx instanceof Uint8Array
? Transaction.fromBytes(txBytesOrTx, toCoinName(network))
: txBytesOrTx;

// Validate that this is not a Zcash transaction
if (tx instanceof ZcashTransaction) {
throw new Error(
"Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction() for Zcash transactions",
);
}

// Pass WASM transaction directly to avoid serialization round-trip
const wasm: WasmBitGoPsbt =
tx instanceof DashTransaction
? WasmBitGoPsbt.from_half_signed_legacy_transaction_dash(
tx.wasm,
network,
keys.wasm,
unspents,
)
: WasmBitGoPsbt.from_half_signed_legacy_transaction(tx.wasm, network, keys.wasm, unspents);
return new BitGoPsbt(wasm);
}

Expand Down
78 changes: 76 additions & 2 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
import { BitGoPsbt, type CreateEmptyOptions } from "./BitGoPsbt.js";
import { ZcashTransaction } from "../transaction.js";
import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js";
import { ZcashTransaction, type ITransaction } from "../transaction.js";

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

/**
* Reconstruct a Zcash PSBT from a half-signed legacy transaction
*
* This is the inverse of `getHalfSignedLegacyFormat()` for Zcash. It decodes the Zcash wire
* format (which includes version_group_id, expiry_height, and sapling fields), extracts
* partial signatures, and reconstructs a proper Zcash PSBT with consensus metadata.
*
* Supports two modes for determining consensus_branch_id:
* - **Recommended**: Pass `blockHeight` to auto-determine consensus_branch_id via network upgrade activation heights
* - **Advanced**: Pass `consensusBranchId` directly if you already know it (e.g., 0xC2D6D0B4 for NU5)
*
* @param txBytesOrTx - Either serialized Zcash transaction bytes or a decoded ZcashTransaction instance
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @param walletKeys - The wallet's root keys
* @param unspents - Chain, index, and value for each input
* @param options - Either `{ blockHeight: number }` or `{ consensusBranchId: number }`
* @returns A ZcashBitGoPsbt instance
*
* @example
* ```typescript
* // Round-trip with block height (recommended)
* const legacyBytes = psbt.getHalfSignedLegacyFormat();
* const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction(
* legacyBytes,
* "zec",
* walletKeys,
* unspents,
* { blockHeight: 1687105 } // NU5 activation height
* );
*
* // Or with explicit consensus branch ID
* const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction(
* legacyBytes,
* "zec",
* walletKeys,
* unspents,
* { consensusBranchId: 0xC2D6D0B4 } // NU5 branch ID
* );
* ```
*/
static fromHalfSignedLegacyTransaction(
txBytesOrTx: Uint8Array | ITransaction,
network: ZcashNetworkName,
walletKeys: WalletKeysArg,
unspents: HydrationUnspent[],
options: { blockHeight: number } | { consensusBranchId: number },
): ZcashBitGoPsbt {
const keys = RootWalletKeys.from(walletKeys);
const tx =
txBytesOrTx instanceof Uint8Array
? ZcashTransaction.fromBytes(txBytesOrTx)
: (txBytesOrTx as ZcashTransaction);

if ("blockHeight" in options) {
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_zcash_with_block_height(
tx.wasm,
network,
keys.wasm,
unspents,
options.blockHeight,
);
return new ZcashBitGoPsbt(wasm);
} else {
const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_zcash_with_branch_id(
tx.wasm,
network,
keys.wasm,
unspents,
options.consensusBranchId,
);
return new ZcashBitGoPsbt(wasm);
}
}

// --- Zcash-specific getters ---

/**
Expand Down
40 changes: 39 additions & 1 deletion packages/wasm-utxo/js/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,36 @@ export class Transaction extends TransactionBase<WasmTransaction> {
super(wasm);
}

/**
* Check if a coin is supported by this transaction class.
* Bitcoin-like transactions support all coins except Zcash and Dash.
*/
static supportsCoin(coin: CoinName): boolean {
return !ZcashTransaction.supportsCoin(coin) && !DashTransaction.supportsCoin(coin);
}

/**
* Create an empty transaction (version 1, locktime 0)
*/
static create(): Transaction {
return new Transaction(WasmTransaction.create());
}

static fromBytes(bytes: Uint8Array): Transaction {
static fromBytes(bytes: Uint8Array): Transaction;
static fromBytes(bytes: Uint8Array, coin: "zec" | "tzec"): ZcashTransaction;
static fromBytes(bytes: Uint8Array, coin: "dash" | "tdash"): DashTransaction;
static fromBytes(
bytes: Uint8Array,
coin: CoinName,
): Transaction | ZcashTransaction | DashTransaction;
static fromBytes(
bytes: Uint8Array,
coin?: CoinName,
): Transaction | ZcashTransaction | DashTransaction {
if (coin !== undefined) {
if (ZcashTransaction.supportsCoin(coin)) return ZcashTransaction.fromBytes(bytes);
if (DashTransaction.supportsCoin(coin)) return DashTransaction.fromBytes(bytes);
}
return new Transaction(WasmTransaction.from_bytes(bytes));
}

Expand Down Expand Up @@ -96,6 +118,14 @@ export class ZcashTransaction extends TransactionBase<WasmZcashTransaction> {
super(wasm);
}

/**
* Check if a coin is supported by this transaction class.
* Zcash transactions support Zcash mainnet and testnet.
*/
static supportsCoin(coin: CoinName): boolean {
return coin === "zec" || coin === "tzec";
}

static fromBytes(bytes: Uint8Array): ZcashTransaction {
return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes));
}
Expand All @@ -121,6 +151,14 @@ export class DashTransaction extends TransactionBase<WasmDashTransaction> {
super(wasm);
}

/**
* Check if a coin is supported by this transaction class.
* Dash transactions support Dash mainnet and testnet.
*/
static supportsCoin(coin: CoinName): boolean {
return coin === "dash" || coin === "tdash";
}

static fromBytes(bytes: Uint8Array): DashTransaction {
return new DashTransaction(WasmDashTransaction.from_bytes(bytes));
}
Expand Down
103 changes: 94 additions & 9 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,100 @@ impl BitGoPsbt {
unspents: &[HydrationUnspentInput],
) -> Result<Self, String> {
use miniscript::bitcoin::consensus::Decodable;
use miniscript::bitcoin::{PublicKey, Transaction};
use miniscript::bitcoin::Transaction;

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

let version = tx.version.0;
let lock_time = tx.lock_time.to_consensus_u32();

let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time));

Self::hydrate_psbt(&mut psbt, &tx, wallet_keys, unspents)?;

Ok(psbt)
}

/// Convert a half-signed legacy Zcash transaction to a PSBT with Zcash metadata.
///
/// This is the Zcash-specific inverse of `get_half_signed_legacy_format()`.
/// It decodes the Zcash wire format (which includes version_group_id, expiry_height, sapling_fields),
/// extracts partial signatures, and reconstructs a Zcash PSBT.
///
/// Unlike `from_half_signed_legacy_transaction`, this requires a `block_height` to determine
/// the correct `consensus_branch_id` via network upgrade activation height lookup.
///
/// # Arguments
/// * `tx_bytes` - Zcash transaction bytes (overwintered format)
/// * `network` - Zcash network (Zcash or ZcashTestnet)
/// * `wallet_keys` - The wallet's root keys
/// * `unspents` - Chain, index, value for each input
/// * `block_height` - Block height to determine consensus branch ID
///
/// # Returns
/// Thin wrapper over `from_half_signed_legacy_transaction_zcash_with_consensus_branch_id`.
/// Resolves consensus_branch_id from block height.
/// Convert a half-signed legacy Zcash transaction to a PSBT with explicit consensus branch ID.
///
/// This is similar to `from_half_signed_legacy_transaction_zcash`, but takes an explicit
/// `consensus_branch_id` instead of deriving it from block height. Use this when you
/// already know the consensus branch ID (e.g., 0xC2D6D0B4 for NU5, 0x76B809BB for Sapling).
///
/// # Arguments
/// * `tx_bytes` - Zcash transaction bytes (overwintered format)
/// * `network` - Zcash network (Zcash or ZcashTestnet)
/// * `wallet_keys` - The wallet's root keys
/// * `unspents` - Chain, index, value for each input
/// * `consensus_branch_id` - Explicit consensus branch ID for sighash computation
///
/// # Returns
/// A BitGoPsbt::Zcash instance with restored Sapling fields
/// Helper that accepts already-decoded Zcash transaction parts (avoiding re-parsing).
pub fn from_half_signed_legacy_transaction_zcash_with_consensus_branch_id_from_parts(
parts: &crate::zcash::transaction::ZcashTransactionParts,
network: Network,
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
unspents: &[HydrationUnspentInput],
consensus_branch_id: u32,
) -> Result<Self, String> {
let tx = &parts.transaction;
let version = tx.version.0;
let lock_time = tx.lock_time.to_consensus_u32();

// Create Zcash PSBT using explicit consensus_branch_id
let mut psbt = Self::new_zcash(
network,
wallet_keys,
consensus_branch_id,
Some(version),
Some(lock_time),
parts.version_group_id,
parts.expiry_height,
);

Self::hydrate_psbt(&mut psbt, tx, wallet_keys, unspents)?;

// Restore Sapling fields in the Zcash PSBT variant
if let BitGoPsbt::Zcash(ref mut zcash_psbt, _) = psbt {
zcash_psbt.sapling_fields = parts.sapling_fields.clone();
}

Ok(psbt)
}

/// Helper that accepts already-decoded Zcash transaction parts (avoiding re-parsing).
/// Private helper: hydrate inputs and outputs in an already-created PSBT.
/// Shared logic for both Bitcoin-like and Zcash variants.
fn hydrate_psbt(
psbt: &mut BitGoPsbt,
tx: &miniscript::bitcoin::Transaction,
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
unspents: &[HydrationUnspentInput],
) -> Result<(), String> {
use miniscript::bitcoin::PublicKey;

// Validate input count
if tx.input.len() != unspents.len() {
return Err(format!(
"Input count mismatch: tx has {} inputs, got {} unspents",
Expand All @@ -538,11 +627,6 @@ impl BitGoPsbt {
));
}

let version = tx.version.0;
let lock_time = tx.lock_time.to_consensus_u32();

let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time));

// Parse each input from the legacy tx
let input_results: Vec<legacy_txformat::LegacyInputResult> = tx
.input
Expand All @@ -554,6 +638,7 @@ impl BitGoPsbt {
})
.collect::<Result<Vec<_>, _>>()?;

// Hydrate inputs
for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() {
match (&input_results[i], unspent) {
(
Expand All @@ -573,7 +658,7 @@ impl BitGoPsbt {
psbt_wallet_input::WalletInputOptions {
sign_path: None,
sequence: Some(tx_in.sequence.0),
prev_tx: None, // psbt-lite: no nonWitnessUtxo
prev_tx: None,
},
)
.map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?;
Expand Down Expand Up @@ -652,12 +737,12 @@ impl BitGoPsbt {
}
}

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

Ok(psbt)
Ok(())
}

fn new_internal(
Expand Down
Loading
Loading