From 3e864cf4167908b274c85fc8365c5df30fe0fa46 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 3 Jul 2026 11:32:04 +0200 Subject: [PATCH 1/5] feat(wasm-utxo): add Network::requires_prev_tx_for_legacy_input predicate Rust-side source of truth for whether a legacy (non-segwit) transparent input needs the full previous transaction (non_witness_utxo) or can rely on witness_utxo alone. Mirrors requiresPrevTxForP2sh in js/fixedScriptWallet/prevTx.ts (added in T1-3654 / PR #306), generalized from P2SH to legacy inputs generally: ZIP-243 (Zcash) and BIP-143/FORKID (BCH family) sighashes commit the input amount regardless of script type, making non_witness_utxo cryptographically pointless for those coins. Refs: T1-3672 --- packages/wasm-utxo/src/networks.rs | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/wasm-utxo/src/networks.rs b/packages/wasm-utxo/src/networks.rs index dfe1f98a5b8..c63f7f56708 100644 --- a/packages/wasm-utxo/src/networks.rs +++ b/packages/wasm-utxo/src/networks.rs @@ -306,6 +306,30 @@ impl Network { !self.is_mainnet() } + /// Whether a non-segwit (legacy: P2PKH/P2SH/bare) transparent input on this network needs + /// the full previous transaction (`non_witness_utxo`) to be signed/finalized safely, or can + /// rely on `witness_utxo` alone. + /// + /// `false` for value-committing coins whose sighash already commits the input amount, + /// making `non_witness_utxo` cryptographically pointless (BIP174's rationale for requiring + /// it — protecting against a lying `witness_utxo` misreporting the spent amount — doesn't + /// apply): + /// - Zcash (`zec`/`tzec`): the ZIP-243 sighash commits the amount. + /// - BCH family (`bch`/`bcha`/`bsv`/`btg` + testnets): the replay-protected BIP-143/FORKID + /// sighash commits the amount. + /// + /// Mirrors `requiresPrevTxForP2sh` in `js/fixedScriptWallet/prevTx.ts` — keep both in sync. + pub fn requires_prev_tx_for_legacy_input(self) -> bool { + !matches!( + self.mainnet(), + Network::Zcash + | Network::BitcoinCash + | Network::Ecash + | Network::BitcoinSV + | Network::BitcoinGold + ) + } + /// Convert to bitcoin crate Network type for address encoding pub fn to_bitcoin_network(self) -> crate::bitcoin::Network { use crate::bitcoin::Network as BitcoinNetwork; @@ -403,6 +427,38 @@ mod tests { } } + #[test] + fn test_requires_prev_tx_for_legacy_input() { + // Mirrors the JS test matrix in test/fixedScript/prevTx.ts + let value_committing = [ + Network::Zcash, + Network::ZcashTestnet, + Network::BitcoinCash, + Network::BitcoinCashTestnet, + Network::Ecash, + Network::EcashTestnet, + Network::BitcoinSV, + Network::BitcoinSVTestnet, + Network::BitcoinGold, + Network::BitcoinGoldTestnet, + ]; + for network in value_committing { + assert!( + !network.requires_prev_tx_for_legacy_input(), + "{network} should not require prevTx" + ); + } + + for network in Network::ALL { + if !value_committing.contains(network) { + assert!( + network.requires_prev_tx_for_legacy_input(), + "{network} should require prevTx" + ); + } + } + } + #[test] fn test_display() { assert_eq!(Network::Bitcoin.to_string(), "Bitcoin"); From 1951034b9506fefc892df5dba3a9d9e88a0efecf Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 3 Jul 2026 11:32:04 +0200 Subject: [PATCH 2/5] refactor(wasm-utxo): move sighash_fork_id to Network, drop wrapper Move SIGHASH_FORKID lookup from fixed_script_wallet::get_sighash_fork_id to Network::sighash_fork_id as the single source of truth. Inline call sites in bitgo_psbt and drop the redundant wrapper and its re-export. Refs: T1-3672 --- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 16 +++--- .../fixed_script_wallet/bitgo_psbt/sighash.rs | 52 ++++++------------- packages/wasm-utxo/src/networks.rs | 15 ++++++ 3 files changed, 38 insertions(+), 45 deletions(-) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index d774c80b4f0..3cd659b3a38 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -21,7 +21,7 @@ pub use propkv::{ find_kv, get_zec_consensus_branch_id, BitGoKeyValue, ProprietaryKeySubtype, WasmUtxoVersionInfo, BITGO, }; -pub use sighash::{get_sighash_fork_id, validate_sighash_type}; +pub use sighash::validate_sighash_type; pub use zcash_psbt::{ decode_zcash_transaction_meta, ZcashBitGoPsbt, ZcashTransactionMeta, ZCASH_SAPLING_VERSION_GROUP_ID, @@ -703,7 +703,7 @@ impl BitGoPsbt { input: &FixedScriptInput, ) -> Result<(), String> { let ctx = SighashContext::Bitcoin { - fork_id: sighash::get_sighash_fork_id(self.network()), + fork_id: self.network().sighash_fork_id(), }; input.apply_signatures(self.psbt_mut(), index, &ctx) } @@ -1653,7 +1653,7 @@ impl BitGoPsbt { return Ok(()); } - let fork_id = sighash::get_sighash_fork_id(*network); + let fork_id = (*network).sighash_fork_id(); // Finalize with fork_id support for FORKID networks psbt.finalize_inp_mut_with_fork_id(secp, input_index, fork_id) @@ -1671,7 +1671,7 @@ impl BitGoPsbt { return Ok(()); } - let fork_id = sighash::get_sighash_fork_id(*network); + let fork_id = (*network).sighash_fork_id(); // Finalize with fork_id support for FORKID networks psbt.finalize_inp_mut_with_fork_id(secp, input_index, fork_id) @@ -2766,7 +2766,7 @@ impl BitGoPsbt { .map(|(_, v)| v) .unwrap_or(miniscript::bitcoin::Amount::ZERO); - let fork_id = sighash::get_sighash_fork_id(network); + let fork_id = network.sighash_fork_id(); // Compute sighash based on network type let mut cache = SighashCache::new(&psbt.unsigned_tx); @@ -2972,7 +2972,7 @@ impl BitGoPsbt { }; } - let fork_id = sighash::get_sighash_fork_id(network); + let fork_id = network.sighash_fork_id(); let message = if let Some(fork_id) = fork_id { // BCH-style BIP143 sighash with FORKID @@ -3062,7 +3062,7 @@ impl BitGoPsbt { } } - let fork_id = sighash::get_sighash_fork_id(*network); + let fork_id = (*network).sighash_fork_id(); // Fall back to ECDSA signature verification for legacy/SegWit inputs psbt_wallet_input::verify_ecdsa_signature( @@ -3113,7 +3113,7 @@ impl BitGoPsbt { } } - let fork_id = sighash::get_sighash_fork_id(*network); + let fork_id = (*network).sighash_fork_id(); psbt_wallet_input::verify_ecdsa_signature( secp, psbt, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs index 720c4c7093b..c64c5ec6c98 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs @@ -15,28 +15,6 @@ const SIGHASH_NONE: u32 = 0x02; const SIGHASH_SINGLE: u32 = 0x03; const SIGHASH_ANYONECANPAY: u32 = 0x80; -/// Returns the SIGHASH_FORKID value for networks that use it. -/// -/// Different networks use different fork IDs in their sighash computation: -/// - Bitcoin Cash, Ecash, Bitcoin SV: fork_id = 0 -/// - Bitcoin Gold: fork_id = 79 -/// - All other networks: None (don't use SIGHASH_FORKID) -/// -/// # Arguments -/// -/// * `network` - The network to get the fork ID for -/// -/// # Returns -/// -/// `Some(fork_id)` for FORKID networks, `None` otherwise -pub fn get_sighash_fork_id(network: Network) -> Option { - match network.mainnet() { - Network::BitcoinCash | Network::Ecash | Network::BitcoinSV => Some(0), - Network::BitcoinGold => Some(79), - _ => None, - } -} - /// Validates a sighash type for a given network /// /// Different networks have different valid sighash types: @@ -103,26 +81,26 @@ mod tests { use super::*; #[test] - fn test_get_sighash_fork_id() { + fn test_sighash_fork_id() { // Networks with fork_id = 0 - assert_eq!(get_sighash_fork_id(Network::BitcoinCash), Some(0)); - assert_eq!(get_sighash_fork_id(Network::BitcoinCashTestnet), Some(0)); - assert_eq!(get_sighash_fork_id(Network::Ecash), Some(0)); - assert_eq!(get_sighash_fork_id(Network::EcashTestnet), Some(0)); - assert_eq!(get_sighash_fork_id(Network::BitcoinSV), Some(0)); - assert_eq!(get_sighash_fork_id(Network::BitcoinSVTestnet), Some(0)); + assert_eq!(Network::BitcoinCash.sighash_fork_id(), Some(0)); + assert_eq!(Network::BitcoinCashTestnet.sighash_fork_id(), Some(0)); + assert_eq!(Network::Ecash.sighash_fork_id(), Some(0)); + assert_eq!(Network::EcashTestnet.sighash_fork_id(), Some(0)); + assert_eq!(Network::BitcoinSV.sighash_fork_id(), Some(0)); + assert_eq!(Network::BitcoinSVTestnet.sighash_fork_id(), Some(0)); // Bitcoin Gold has fork_id = 79 - assert_eq!(get_sighash_fork_id(Network::BitcoinGold), Some(79)); - assert_eq!(get_sighash_fork_id(Network::BitcoinGoldTestnet), Some(79)); + assert_eq!(Network::BitcoinGold.sighash_fork_id(), Some(79)); + assert_eq!(Network::BitcoinGoldTestnet.sighash_fork_id(), Some(79)); // Standard networks don't use FORKID - assert_eq!(get_sighash_fork_id(Network::Bitcoin), None); - assert_eq!(get_sighash_fork_id(Network::BitcoinTestnet3), None); - assert_eq!(get_sighash_fork_id(Network::Litecoin), None); - assert_eq!(get_sighash_fork_id(Network::Dogecoin), None); - assert_eq!(get_sighash_fork_id(Network::Dash), None); - assert_eq!(get_sighash_fork_id(Network::Zcash), None); + assert_eq!(Network::Bitcoin.sighash_fork_id(), None); + assert_eq!(Network::BitcoinTestnet3.sighash_fork_id(), None); + assert_eq!(Network::Litecoin.sighash_fork_id(), None); + assert_eq!(Network::Dogecoin.sighash_fork_id(), None); + assert_eq!(Network::Dash.sighash_fork_id(), None); + assert_eq!(Network::Zcash.sighash_fork_id(), None); } #[test] diff --git a/packages/wasm-utxo/src/networks.rs b/packages/wasm-utxo/src/networks.rs index c63f7f56708..9d15d1821b4 100644 --- a/packages/wasm-utxo/src/networks.rs +++ b/packages/wasm-utxo/src/networks.rs @@ -330,6 +330,21 @@ impl Network { ) } + /// Returns the SIGHASH_FORKID value for networks that use it (`Psbt::sign_forkid` / + /// `finalize_mut_with_fork_id`'s `fork_id` parameter), or `None` for plain-sighash networks + /// (`Psbt::sign` / plain finalize). + /// + /// - Bitcoin Cash, Ecash, Bitcoin SV: fork_id = 0 + /// - Bitcoin Gold: fork_id = 79 + /// - All other networks: `None` + pub fn sighash_fork_id(self) -> Option { + match self.mainnet() { + Network::BitcoinCash | Network::Ecash | Network::BitcoinSV => Some(0), + Network::BitcoinGold => Some(79), + _ => None, + } + } + /// Convert to bitcoin crate Network type for address encoding pub fn to_bitcoin_network(self) -> crate::bitcoin::Network { use crate::bitcoin::Network as BitcoinNetwork; From 4020a73015a0e6d64338dc3c6a99699e48aedfdb Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 3 Jul 2026 11:32:04 +0200 Subject: [PATCH 3/5] feat(wasm-utxo): add Zcash transparent signing + generalized single-key signing helpers Adds ZIP-243 transparent-input signing/finalization to WrapPsbt (sign_zcash_with_prv, sign_zcash_all_with_ecpair, finalize_mut_zcash, extract_zcash_transaction), and factors the core into shared non-wasm helpers in a new src/transparent_signing.rs module: SingleKeySigner, script_pubkey_from_descriptor, add_input_with_descriptor, sign_with_privkey, sign_zcash_with_privkey, finalize_and_encode_transaction, finalize_and_encode_zcash_transaction. These are plain functions over miniscript::bitcoin::psbt::Psbt with no wasm_bindgen surface, so they live outside src/wasm/ and are reusable by any consumer (e.g. the wasm-utxo-cli crate), not just WrapPsbt. script_pubkey_from_descriptor resolves a descriptor string that may embed private keys (e.g. pkh()) via Descriptor::parse_descriptor, for deriving an address directly from a signing descriptor. add_input_with_descriptor takes a definite descriptor string (not hardcoded to pkh) and works for all networks: it uses the checked PsbtExt::update_input_with_descriptor (BIP174-safe) when a non_witness_utxo is given, or the unchecked PsbtInputExt::update_with_descriptor_unchecked when omitted, which is only safe for value-committing sighash networks (new Network::requires_prev_tx_for_legacy_input predicate: Zcash/BCH-family) where a lying witness_utxo can't misreport the spent amount. sign_with_privkey/finalize_and_encode_transaction support all non-Zcash sighash families via Network::sighash_fork_id: plain Psbt::sign/ finalize_mut_with_fork_id(None) for BTC-like networks, sign_forkid/finalize_mut_with_fork_id(Some(id)) for the BCH family. ZcashTransactionParts::from_extracted_transaction/::extract_from_psbt live as associated functions on ZcashTransactionParts in src/zcash/transaction.rs. Refs: T1-3672 --- packages/wasm-utxo/src/lib.rs | 6 + packages/wasm-utxo/src/transparent_signing.rs | 249 +++++++++++++ packages/wasm-utxo/src/wasm/psbt.rs | 334 +++++++++++++----- packages/wasm-utxo/src/zcash/transaction.rs | 34 ++ 4 files changed, 542 insertions(+), 81 deletions(-) create mode 100644 packages/wasm-utxo/src/transparent_signing.rs diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 4628ec3422f..86584b6909c 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -13,6 +13,7 @@ pub mod paygo; pub mod psbt_ops; #[cfg(test)] mod test_utils; +pub mod transparent_signing; pub mod zcash; // re-export bitcoin from the miniscript crate @@ -25,6 +26,11 @@ pub use address::{ }; pub use networks::Network; +pub use transparent_signing::{ + add_input_with_descriptor, finalize_and_encode_transaction, + finalize_and_encode_zcash_transaction, script_pubkey_from_descriptor, sign_with_privkey, + sign_zcash_with_privkey, +}; pub mod wasm; pub use wasm::{ WasmBIP32, WasmECPair, WasmRootWalletKeys, WrapDescriptor, WrapMiniscript, WrapPsbt, diff --git a/packages/wasm-utxo/src/transparent_signing.rs b/packages/wasm-utxo/src/transparent_signing.rs new file mode 100644 index 00000000000..ade9ad04917 --- /dev/null +++ b/packages/wasm-utxo/src/transparent_signing.rs @@ -0,0 +1,249 @@ +//! Single-key transparent PSBT signing helpers. +//! +//! These operate on plain `miniscript::bitcoin::psbt::Psbt` and have no `wasm_bindgen` surface — +//! they're shared by `wasm::psbt::WrapPsbt`'s wasm-bindgen methods and directly by +//! `wasm-utxo-cli`'s `psbt` build commands (which never go through the wasm layer). No new +//! sighash code: these compose the existing `Psbt::sign`/`sign_forkid`/`sign_zcash` and +//! `PsbtExt` primitives that already back `fixed_script_wallet`. + +use crate::zcash::transaction::ZcashTransactionParts; +use miniscript::bitcoin::bip32::Fingerprint; +use miniscript::bitcoin::secp256k1::{Secp256k1, Signing, Verification}; +use miniscript::bitcoin::{ + psbt, Amount, OutPoint, PrivateKey, PublicKey, ScriptBuf, Sequence, Transaction, TxIn, TxOut, + Txid, XOnlyPublicKey, +}; +use miniscript::descriptor::{SinglePub, SinglePubKey}; +use miniscript::psbt::{PsbtExt, PsbtInputExt}; +use miniscript::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey, ToPublicKey}; +use std::str::FromStr; + +#[derive(Debug)] +pub(crate) struct SingleKeySigner { + privkey: PrivateKey, + pubkey: PublicKey, + _pubkey_xonly: XOnlyPublicKey, + pub(crate) fingerprint: Fingerprint, + fingerprint_xonly: Fingerprint, +} + +impl SingleKeySigner { + fn fingerprint(key: SinglePubKey) -> Fingerprint { + DescriptorPublicKey::Single(SinglePub { origin: None, key }).master_fingerprint() + } + + pub(crate) fn from_privkey( + privkey: PrivateKey, + secp: &Secp256k1, + ) -> SingleKeySigner { + let pubkey = privkey.public_key(secp); + let pubkey_xonly = pubkey.to_x_only_pubkey(); + SingleKeySigner { + privkey, + pubkey, + _pubkey_xonly: pubkey_xonly, + fingerprint: SingleKeySigner::fingerprint(SinglePubKey::FullKey(pubkey)), + fingerprint_xonly: SingleKeySigner::fingerprint(SinglePubKey::XOnly(pubkey_xonly)), + } + } +} + +impl psbt::GetKey for SingleKeySigner { + type Error = String; + + fn get_key( + &self, + key_request: psbt::KeyRequest, + _secp: &Secp256k1, + ) -> Result, Self::Error> { + match key_request { + // NOTE: this KeyRequest does not occur for taproot signatures + // even if the descriptor keys are definite, we will receive a bip32 request + // instead based on `DescriptorPublicKey::Single(SinglePub { origin: None, key, })` + psbt::KeyRequest::Pubkey(req_pubkey) => { + if req_pubkey == self.pubkey { + Ok(Some(self.privkey)) + } else { + Ok(None) + } + } + + psbt::KeyRequest::Bip32((fingerprint, _path)) => { + if fingerprint.eq(&self.fingerprint) || fingerprint.eq(&self.fingerprint_xonly) { + Ok(Some(self.privkey)) + } else { + Ok(None) + } + } + + _ => Ok(None), + } + } +} + +/// Compute the output script for a descriptor string, which may embed private keys (e.g. +/// `pkh()`, `wpkh()`) — used to derive an address directly from a signing +/// descriptor without a separate private-key argument. +/// +/// Uses `Descriptor::parse_descriptor`, which turns any embedded secret keys into their +/// corresponding public keys (discarding the resulting key map — this is a read-only address +/// derivation, not a signing operation). Resolves at derivation index 0; index is a no-op for +/// non-wildcard descriptors, which is the expected case for a descriptor naming concrete keys. +pub fn script_pubkey_from_descriptor(descriptor: &str) -> Result { + let secp = Secp256k1::new(); + let (desc, _keymap) = + Descriptor::parse_descriptor(&secp, descriptor).map_err(|e| e.to_string())?; + let definite = desc.at_derivation_index(0).map_err(|e| e.to_string())?; + Ok(definite.script_pubkey()) +} + +/// Add a witness_utxo input spending the given definite descriptor's output, populating +/// `bip32_derivation`/`tap_key_origins` from the descriptor so the input is signable via +/// [`sign_with_privkey`] / [`sign_zcash_with_privkey`]. +/// +/// `descriptor` is a definite descriptor string with concrete keys, e.g. `pkh()` or +/// `wpkh()`; not limited to P2PKH. +/// +/// `non_witness_utxo`, when given, is validated against `witness_utxo` and the descriptor via +/// the checked `PsbtExt::update_input_with_descriptor` (BIP174-safe). When omitted, falls back +/// to `PsbtInputExt::update_with_descriptor_unchecked` — safe only for value-committing sighash +/// networks where `non_witness_utxo` is cryptographically pointless (see +/// [`Network::requires_prev_tx_for_legacy_input`](crate::Network::requires_prev_tx_for_legacy_input)). +/// Callers must gate on that predicate themselves before omitting `non_witness_utxo`. +#[allow(clippy::too_many_arguments)] +pub fn add_input_with_descriptor( + psbt: &mut miniscript::bitcoin::Psbt, + index: usize, + txid: Txid, + vout: u32, + value: u64, + script_pubkey: ScriptBuf, + descriptor: &str, + sequence: u32, + non_witness_utxo: Option, +) -> Result<(), String> { + let desc = + Descriptor::::from_str(descriptor).map_err(|e| e.to_string())?; + + let tx_in = TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: ScriptBuf::new(), + sequence: Sequence(sequence), + witness: miniscript::bitcoin::Witness::default(), + }; + let has_non_witness_utxo = non_witness_utxo.is_some(); + let psbt_input = psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(value), + script_pubkey, + }), + non_witness_utxo, + ..Default::default() + }; + crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input)?; + + if has_non_witness_utxo { + psbt.update_input_with_descriptor(index, &desc) + .map_err(|e| e.to_string()) + } else { + psbt.inputs[index] + .update_with_descriptor_unchecked(&desc) + .map(|_| ()) + .map_err(|e| e.to_string()) + } +} + +/// Sign all inputs of a PSBT with a single private key. +/// +/// `fork_id` selects the sighash algorithm: `Some(id)` uses `Psbt::sign_forkid` (BCH-family / +/// BTG), `None` uses plain `Psbt::sign`. See [`Network::sighash_fork_id`](crate::Network::sighash_fork_id). +pub fn sign_with_privkey( + psbt: &mut miniscript::bitcoin::Psbt, + privkey: PrivateKey, + secp: &Secp256k1, + fork_id: Option, +) -> Result { + let signer = SingleKeySigner::from_privkey(privkey, secp); + match fork_id { + Some(id) => psbt.sign_forkid(&signer, secp, id), + None => psbt.sign(&signer, secp), + } + .map_err(|(_, errors)| errors) +} + +/// Finalize a fully-signed PSBT (using `finalize_mut_with_fork_id`, matching the `fork_id` used +/// to sign) and encode the extracted transaction. +pub fn finalize_and_encode_transaction( + mut psbt: miniscript::bitcoin::Psbt, + fork_id: Option, +) -> Result, String> { + use miniscript::bitcoin::consensus::Encodable; + + psbt.finalize_mut_with_fork_id(&Secp256k1::verification_only(), fork_id) + .map_err(|errors| { + format!( + "Failed to finalize PSBT: {}", + errors + .into_iter() + .map(|e| e.to_string()) + .collect::>() + .join(", ") + ) + })?; + let tx = psbt + .extract_tx() + .map_err(|e| format!("Failed to extract transaction: {}", e))?; + let mut bytes = Vec::new(); + tx.consensus_encode(&mut bytes) + .map_err(|e| format!("Failed to encode transaction: {}", e))?; + Ok(bytes) +} + +/// Sign all Zcash transparent inputs of a PSBT using the ZIP-243 sighash algorithm +/// (`Psbt::sign_zcash` in the rust-bitcoin fork). +pub fn sign_zcash_with_privkey( + psbt: &mut miniscript::bitcoin::Psbt, + privkey: PrivateKey, + secp: &Secp256k1, + consensus_branch_id: u32, + version_group_id: u32, + expiry_height: u32, +) -> Result { + let signer = SingleKeySigner::from_privkey(privkey, secp); + psbt.sign_zcash( + &signer, + secp, + consensus_branch_id, + version_group_id, + expiry_height, + ) + .map_err(|(_, errors)| errors) +} + +/// Finalize a fully-signed PSBT using ZIP-243 sighash verification (`finalize_mut_with_zcash`) +/// and encode it as Zcash overwintered transaction bytes. +pub fn finalize_and_encode_zcash_transaction( + mut psbt: miniscript::bitcoin::Psbt, + consensus_branch_id: u32, + version_group_id: u32, + expiry_height: u32, +) -> Result, String> { + psbt.finalize_mut_with_zcash( + &Secp256k1::verification_only(), + consensus_branch_id, + version_group_id, + expiry_height, + ) + .map_err(|errors| { + format!( + "Failed to finalize PSBT: {}", + errors + .into_iter() + .map(|e| e.to_string()) + .collect::>() + .join(", ") + ) + })?; + let parts = ZcashTransactionParts::extract_from_psbt(psbt, version_group_id, expiry_height)?; + crate::zcash::transaction::encode_zcash_transaction_parts(&parts) +} diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index f957c98e4d9..399c148f75e 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -1,86 +1,23 @@ use crate::address::networks::{from_output_script_with_network_and_format, AddressFormat}; use crate::error::WasmUtxoError; +use crate::transparent_signing::{sign_zcash_with_privkey, SingleKeySigner}; use crate::wasm::bip32::WasmBIP32; use crate::wasm::descriptor::WrapDescriptorEnum; use crate::wasm::ecpair::WasmECPair; use crate::wasm::psbt_ops::WasmPsbtOps; use crate::wasm::try_into_js_value::TryIntoJsValue; use crate::wasm::WrapDescriptor; -use miniscript::bitcoin::bip32::Fingerprint; +use crate::zcash::transaction::{ZcashTransactionParts, ZCASH_SAPLING_VERSION_GROUP_ID}; use miniscript::bitcoin::locktime::absolute::LockTime; -use miniscript::bitcoin::secp256k1::{Secp256k1, Signing}; +use miniscript::bitcoin::secp256k1::Secp256k1; use miniscript::bitcoin::transaction::{Transaction, Version}; -use miniscript::bitcoin::{ - bip32, psbt, Amount, OutPoint, PublicKey, ScriptBuf, Sequence, XOnlyPublicKey, -}; +use miniscript::bitcoin::{bip32, psbt, Amount, OutPoint, ScriptBuf, Sequence}; use miniscript::bitcoin::{PrivateKey, Psbt, Script, TxIn, TxOut, Txid}; -use miniscript::descriptor::{SinglePub, SinglePubKey}; use miniscript::psbt::PsbtExt; -use miniscript::{DescriptorPublicKey, ToPublicKey}; use std::str::FromStr; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::{JsError, JsValue}; -#[derive(Debug)] -struct SingleKeySigner { - privkey: PrivateKey, - pubkey: PublicKey, - _pubkey_xonly: XOnlyPublicKey, - fingerprint: Fingerprint, - fingerprint_xonly: Fingerprint, -} - -impl SingleKeySigner { - fn fingerprint(key: SinglePubKey) -> Fingerprint { - DescriptorPublicKey::Single(SinglePub { origin: None, key }).master_fingerprint() - } - - fn from_privkey(privkey: PrivateKey, secp: &Secp256k1) -> SingleKeySigner { - let pubkey = privkey.public_key(secp); - let pubkey_xonly = pubkey.to_x_only_pubkey(); - SingleKeySigner { - privkey, - pubkey, - _pubkey_xonly: pubkey_xonly, - fingerprint: SingleKeySigner::fingerprint(SinglePubKey::FullKey(pubkey)), - fingerprint_xonly: SingleKeySigner::fingerprint(SinglePubKey::XOnly(pubkey_xonly)), - } - } -} - -impl psbt::GetKey for SingleKeySigner { - type Error = String; - - fn get_key( - &self, - key_request: psbt::KeyRequest, - _secp: &Secp256k1, - ) -> Result, Self::Error> { - match key_request { - // NOTE: this KeyRequest does not occur for taproot signatures - // even if the descriptor keys are definite, we will receive a bip32 request - // instead based on `DescriptorPublicKey::Single(SinglePub { origin: None, key, })` - psbt::KeyRequest::Pubkey(req_pubkey) => { - if req_pubkey == self.pubkey { - Ok(Some(self.privkey)) - } else { - Ok(None) - } - } - - psbt::KeyRequest::Bip32((fingerprint, _path)) => { - if fingerprint.eq(&self.fingerprint) || fingerprint.eq(&self.fingerprint_xonly) { - Ok(Some(self.privkey)) - } else { - Ok(None) - } - } - - _ => Ok(None), - } - } -} - // ============================================================================ // PSBT Introspection Types // ============================================================================ @@ -591,6 +528,31 @@ impl WrapPsbt { .map_err(WasmUtxoError::from_errors) } + /// Finalize all Zcash transparent inputs using ZIP-243 sighash verification. + /// + /// Use this instead of `finalize_mut()` for Zcash PSBTs signed with + /// `sign_zcash_with_prv` / `sign_zcash_all_with_ecpair`. + /// + /// # Arguments + /// * `consensus_branch_id` - Zcash network upgrade branch ID + /// * `version_group_id` - Version group ID (default: Sapling `0x892F2085`) + /// * `expiry_height` - Transaction expiry height (default: 0) + pub fn finalize_mut_zcash( + &mut self, + consensus_branch_id: u32, + version_group_id: Option, + expiry_height: Option, + ) -> Result<(), WasmUtxoError> { + self.0 + .finalize_mut_with_zcash( + &Secp256k1::verification_only(), + consensus_branch_id, + version_group_id.unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID), + expiry_height.unwrap_or(0), + ) + .map_err(WasmUtxoError::from_errors) + } + /// Extract the final transaction from a finalized PSBT /// /// This method should be called after all inputs have been finalized. @@ -615,6 +577,87 @@ impl WrapPsbt { get_outputs_with_address_from_psbt(&self.0, network) } + /// Sign all Zcash transparent inputs with a raw private key using ZIP-243 sighash. + /// + /// # Arguments + /// * `prv` - Raw private key bytes + /// * `consensus_branch_id` - Zcash network upgrade branch ID + /// * `version_group_id` - Version group ID (default: Sapling `0x892F2085`) + /// * `expiry_height` - Transaction expiry height (default: 0) + pub fn sign_zcash_with_prv( + &mut self, + prv: Vec, + consensus_branch_id: u32, + version_group_id: Option, + expiry_height: Option, + ) -> Result { + let privkey = PrivateKey::from_slice(&prv, miniscript::bitcoin::network::Network::Bitcoin) + .map_err(|_| WasmUtxoError::new("Invalid private key"))?; + let secp = Secp256k1::new(); + sign_zcash_with_privkey( + &mut self.0, + privkey, + &secp, + consensus_branch_id, + version_group_id.unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID), + expiry_height.unwrap_or(0), + ) + .map_err(|errors| WasmUtxoError::from_errors(errors.into_values())) + .and_then(|r| r.try_to_js_value()) + } + + /// Sign all Zcash transparent inputs with a WasmECPair key using ZIP-243 sighash. + /// + /// # Arguments + /// * `key` - The WasmECPair key to sign with + /// * `consensus_branch_id` - Zcash network upgrade branch ID + /// * `version_group_id` - Version group ID (default: Sapling `0x892F2085`) + /// * `expiry_height` - Transaction expiry height (default: 0) + pub fn sign_zcash_all_with_ecpair( + &mut self, + key: &WasmECPair, + consensus_branch_id: u32, + version_group_id: Option, + expiry_height: Option, + ) -> Result { + let privkey = key.get_private_key()?; + let secp = Secp256k1::new(); + let private_key = PrivateKey::new(privkey, miniscript::bitcoin::network::Network::Bitcoin); + sign_zcash_with_privkey( + &mut self.0, + private_key, + &secp, + consensus_branch_id, + version_group_id.unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID), + expiry_height.unwrap_or(0), + ) + .map_err(|errors| WasmUtxoError::from_errors(errors.into_values())) + .and_then(|r| r.try_to_js_value()) + } + + /// Extract the finalized PSBT as a Zcash overwintered transaction. + /// + /// Must be called after `finalize_mut_zcash()`. + /// + /// # Arguments + /// * `version_group_id` - Version group ID (default: Sapling `0x892F2085`) + /// * `expiry_height` - Transaction expiry height (default: 0) + pub fn extract_zcash_transaction( + &self, + version_group_id: Option, + expiry_height: Option, + ) -> Result { + let parts = ZcashTransactionParts::extract_from_psbt( + self.0.clone(), + version_group_id.unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID), + expiry_height.unwrap_or(0), + ) + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts( + parts, + )) + } + pub fn get_partial_signatures(&self, input_index: usize) -> Result { use crate::wasm::try_into_js_value::{collect_partial_signatures, TryIntoJsValue}; @@ -978,11 +1021,12 @@ mod tests { use crate::wasm::psbt::SingleKeySigner; use crate::Network; use base64::prelude::*; - use miniscript::bitcoin::bip32::{DerivationPath, Fingerprint, KeySource}; + use miniscript::bitcoin::bip32::{DerivationPath, KeySource}; use miniscript::bitcoin::psbt::{SigningKeys, SigningKeysMap}; use miniscript::bitcoin::secp256k1::Secp256k1; use miniscript::bitcoin::{PrivateKey, Psbt}; - use miniscript::psbt::PsbtExt; + use miniscript::descriptor::{SinglePub, SinglePubKey}; + use miniscript::psbt::{PsbtExt, PsbtInputExt}; use miniscript::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey, ToPublicKey}; use std::str::FromStr; @@ -993,29 +1037,46 @@ mod tests { Psbt::deserialize(&psbt).map_err(|e| WasmUtxoError::new(&format!("Invalid PSBT: {}", e))) } + const TEST_SEED: &str = "too many secrets"; + + /// BitGo test-wallet seed → master xpriv → WIF round-trip (obvious, reproducible test key). + fn test_privkey_from_seed(seed: &str) -> PrivateKey { + use miniscript::bitcoin::bip32::Xpriv; + use miniscript::bitcoin::hashes::{sha256, Hash}; + use miniscript::bitcoin::Network; + let seed_hash = sha256::Hash::hash(seed.as_bytes()).to_byte_array(); + let xpriv = Xpriv::new_master(Network::Testnet, &seed_hash).expect("xpriv from test seed"); + let privkey = PrivateKey::new(xpriv.private_key, Network::Bitcoin); + PrivateKey::from_str(&privkey.to_wif()).expect("WIF round-trip") + } + #[test] pub fn test_wrap_privkey() { - let desc = "tr(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b,pk(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b))"; - let desc = Descriptor::::from_str(desc).unwrap(); + let secp = Secp256k1::new(); + let prv = test_privkey_from_seed(TEST_SEED); + let xonly = prv.public_key(&secp).to_x_only_pubkey(); + let desc = format!("tr({xonly},pk({xonly}))"); + let desc = Descriptor::::from_str(&desc).unwrap(); let psbt = "cHNidP8BAKYCAAAAAgEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAAD9////AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAP3///8CgBoGAAAAAAAWABRTtvjcap+5t7odMosMnHl97YJClYAaBgAAAAAAIlEg1S2GuUvFU+Ve4XFLV65ffhuYsGeDkpaER6lQFjONAmEAAAAAAAEBK0BCDwAAAAAAIlEg1S2GuUvFU+Ve4XFLV65ffhuYsGeDkpaER6lQFjONAmEAAQErQEIPAAAAAAAiUSDVLYa5S8VT5V7hcUtXrl9+G5iwZ4OSloRHqVAWM40CYQAAAA=="; let mut psbt = psbt_from_base64(psbt).unwrap(); - psbt.update_input_with_descriptor(0, &desc).unwrap(); + psbt.inputs[0].tap_key_origins.clear(); + psbt.inputs[0] + .update_with_descriptor_unchecked(&desc) + .unwrap(); println!("{:?}", psbt.inputs[0].tap_key_origins); - let prv = - PrivateKey::from_str("KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv").unwrap(); - let pk = prv.public_key(&Secp256k1::new()).to_x_only_pubkey(); - let secp = Secp256k1::new(); + let pk = prv.public_key(&secp).to_x_only_pubkey(); let sks = SingleKeySigner::from_privkey(prv, &secp); + let expected_fp = DescriptorPublicKey::Single(SinglePub { + origin: None, + key: SinglePubKey::XOnly(xonly), + }) + .master_fingerprint(); psbt.inputs[0] .tap_key_origins .values() .for_each(|key_source| { - let key_source_ref: KeySource = ( - Fingerprint::from_hex("aeee1e6a").unwrap(), - DerivationPath::from(vec![]), - ); + let key_source_ref: KeySource = (expected_fp, DerivationPath::from(vec![])); assert_eq!(key_source.1, key_source_ref); - assert_eq!(sks.fingerprint, key_source.1 .0,); }); let mut expected_keys = SigningKeysMap::new(); expected_keys.insert(0, SigningKeys::Schnorr(vec![pk])); @@ -1033,6 +1094,117 @@ mod tests { .unwrap(); } + #[test] + fn test_build_sign_extract_zcash_transparent_p2pkh() { + use crate::transparent_signing::{add_input_with_descriptor, sign_zcash_with_privkey}; + use crate::zcash::transaction::{decode_zcash_transaction_parts, ZcashTransactionParts}; + use miniscript::bitcoin::locktime::absolute::LockTime; + use miniscript::bitcoin::transaction::Version; + use miniscript::bitcoin::{psbt, Amount, ScriptBuf, Transaction, TxOut, Txid}; + use miniscript::psbt::PsbtExt; + + // NU5 mainnet consensus branch ID + const CONSENSUS_BRANCH_ID: u32 = 0xc2d6d0b4; + const VERSION_GROUP_ID: u32 = 0x892F2085; + const EXPIRY_HEIGHT: u32 = 1_700_000; + + let secp = Secp256k1::new(); + let privkey = test_privkey_from_seed(TEST_SEED); + let pubkey = privkey.public_key(&secp); + let script_pubkey = ScriptBuf::builder() + .push_opcode(miniscript::bitcoin::opcodes::all::OP_DUP) + .push_opcode(miniscript::bitcoin::opcodes::all::OP_HASH160) + .push_slice(pubkey.pubkey_hash()) + .push_opcode(miniscript::bitcoin::opcodes::all::OP_EQUALVERIFY) + .push_opcode(miniscript::bitcoin::opcodes::all::OP_CHECKSIG) + .into_script(); + + let prev_txid = + Txid::from_str("a8c685478265f4c14dada651969c45a65e1aeb8cd6791f2f5bb6a1d9952104d9") + .unwrap(); + + let tx = Transaction { + version: Version(4), + lock_time: LockTime::ZERO, + input: vec![], + output: vec![], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + add_input_with_descriptor( + &mut psbt, + 0, + prev_txid, + 0, + 50_000_000, + script_pubkey.clone(), + &format!("pkh({})", pubkey), + 0xFFFFFFFE, + None, + ) + .unwrap(); + crate::psbt_ops::insert_output( + &mut psbt, + 0, + TxOut { + value: Amount::from_sat(49_990_000), + script_pubkey, + }, + psbt::Output::default(), + ) + .unwrap(); + + sign_zcash_with_privkey( + &mut psbt, + privkey, + &secp, + CONSENSUS_BRANCH_ID, + VERSION_GROUP_ID, + EXPIRY_HEIGHT, + ) + .unwrap(); + + psbt.finalize_mut_with_zcash( + &Secp256k1::verification_only(), + CONSENSUS_BRANCH_ID, + VERSION_GROUP_ID, + EXPIRY_HEIGHT, + ) + .unwrap(); + + let parts_a = + ZcashTransactionParts::extract_from_psbt(psbt.clone(), VERSION_GROUP_ID, EXPIRY_HEIGHT) + .unwrap(); + let parts_b = + ZcashTransactionParts::extract_from_psbt(psbt, VERSION_GROUP_ID, EXPIRY_HEIGHT) + .unwrap(); + + assert!(parts_a.is_overwintered); + assert_eq!(parts_a.version_group_id, Some(VERSION_GROUP_ID)); + assert_eq!(parts_a.expiry_height, Some(EXPIRY_HEIGHT)); + assert_eq!(parts_a.transaction.input.len(), 1); + assert_eq!(parts_a.transaction.output.len(), 1); + + let encoded_a = + crate::zcash::transaction::encode_zcash_transaction_parts(&parts_a).unwrap(); + let encoded_b = + crate::zcash::transaction::encode_zcash_transaction_parts(&parts_b).unwrap(); + assert_eq!(encoded_a, encoded_b, "extraction should be deterministic"); + + // Round-trip through decode and assert a stable txid. + let decoded = decode_zcash_transaction_parts(&encoded_a).unwrap(); + let redecoded_bytes = + crate::zcash::transaction::encode_zcash_transaction_parts(&decoded).unwrap(); + assert_eq!(encoded_a, redecoded_bytes); + + use miniscript::bitcoin::hashes::{sha256d, Hash}; + let txid_a = sha256d::Hash::hash(&encoded_a); + let txid_roundtrip = sha256d::Hash::hash(&redecoded_bytes); + assert_eq!( + txid_a, txid_roundtrip, + "txid must be stable across round-trip" + ); + } + // Compile-time check to ensure the macro stays in sync with Network::ALL #[test] fn test_all_networks_macro_is_complete() { diff --git a/packages/wasm-utxo/src/zcash/transaction.rs b/packages/wasm-utxo/src/zcash/transaction.rs index 99ee5f683bc..bd02b0f35ea 100644 --- a/packages/wasm-utxo/src/zcash/transaction.rs +++ b/packages/wasm-utxo/src/zcash/transaction.rs @@ -27,6 +27,40 @@ pub struct ZcashTransactionParts { pub sapling_fields: Vec, } +impl ZcashTransactionParts { + /// Wrap an already-extracted Bitcoin-compatible transaction as Zcash overwintered + /// transaction parts, mirroring `ZcashBitGoPsbt::extract_tx`. + pub fn from_extracted_transaction( + transaction: Transaction, + version_group_id: u32, + expiry_height: u32, + ) -> Self { + ZcashTransactionParts { + transaction, + is_overwintered: true, + version_group_id: Some(version_group_id), + expiry_height: Some(expiry_height), + sapling_fields: vec![0u8; 11], + } + } + + /// Extract a finalized PSBT as Zcash overwintered transaction parts. + pub fn extract_from_psbt( + psbt: miniscript::bitcoin::psbt::Psbt, + version_group_id: u32, + expiry_height: u32, + ) -> Result { + let tx = psbt + .extract_tx() + .map_err(|e| format!("Failed to extract transaction: {}", e))?; + Ok(Self::from_extracted_transaction( + tx, + version_group_id, + expiry_height, + )) + } +} + /// Zcash transaction metadata extracted from transaction bytes /// /// This struct provides the Zcash-specific fields without requiring From fcd71bdb1af55f9c5b46638d2694aa5af8a2b61b Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 3 Jul 2026 11:32:04 +0200 Subject: [PATCH 4/5] feat(wasm-utxo-cli): add address from-descriptor subcommand Print the address for a descriptor (WIF/hex/xpub-based), which may embed a private key (e.g. pkh()) via wasm-utxo's script_pubkey_from_descriptor. General utility for deriving addresses from descriptors on the command line, not tied to any particular coin or script type. Refs: T1-3672 --- packages/wasm-utxo/cli/README.md | 23 +++++++++++++++++++++++ packages/wasm-utxo/cli/src/address.rs | 27 +++++++++++++++++++++++++-- packages/wasm-utxo/cli/src/input.rs | 12 ++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/wasm-utxo/cli/README.md b/packages/wasm-utxo/cli/README.md index 4ed3effacaf..d59c2df9bb8 100644 --- a/packages/wasm-utxo/cli/README.md +++ b/packages/wasm-utxo/cli/README.md @@ -68,6 +68,29 @@ wasm-utxo-cli address encode 0014e8df018c7e326cc253faac7e46cdc51e68542c42 # Output: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq ``` +#### Derive an address from a descriptor + +```bash +wasm-utxo-cli address from-descriptor --network +``` + +The descriptor may name a plain public key or embed a private key directly (e.g. a WIF) — +either way, the resulting address is derived from the corresponding public key. Works for any +network, not just Bitcoin. + +**Examples:** + +```bash +# BTC: derive a P2PKH address from a public key +wasm-utxo-cli address from-descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" --network btc +# Output: 1GwwqAWsJzDHMZ9ceJqrJDfch4gsro4c3x + +# Zcash: derive a t-address directly from a WIF private key (e.g. for zebrad's miner_address on +# regtest — Zcash regtest reuses testnet address prefixes, so pass --network tzec) +wasm-utxo-cli address from-descriptor "pkh(KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv)" --network tzec +# Output: tmRfJALRQfyWP6SPxFTxiHhSHYhxn7rwkLv +``` + ### PSBT Operations #### Parse and inspect a PSBT diff --git a/packages/wasm-utxo/cli/src/address.rs b/packages/wasm-utxo/cli/src/address.rs index c83ba42877e..da51f1f7a23 100644 --- a/packages/wasm-utxo/cli/src/address.rs +++ b/packages/wasm-utxo/cli/src/address.rs @@ -1,7 +1,10 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use clap::Subcommand; use wasm_utxo::bitcoin::Script; -use wasm_utxo::{from_output_script_with_network, to_output_script_with_network, Network}; +use wasm_utxo::{ + from_output_script_with_network, script_pubkey_from_descriptor, to_output_script_with_network, + Network, +}; use crate::network::NetworkArg; @@ -23,6 +26,15 @@ pub enum AddressCommand { #[arg(short, long, value_enum)] network: NetworkArg, }, + /// Print the address for a descriptor, which may embed a private key (e.g. `pkh()`, + /// `wpkh()`) + FromDescriptor { + /// Descriptor, e.g. `pkh()` + descriptor: String, + /// Network (btc, tbtc, ltc, bch, zec, tzec, etc.) + #[arg(short, long, value_enum)] + network: NetworkArg, + }, } pub fn handle_command(command: AddressCommand) -> Result<()> { @@ -44,5 +56,16 @@ pub fn handle_command(command: AddressCommand) -> Result<()> { println!("{}", address); Ok(()) } + AddressCommand::FromDescriptor { + descriptor, + network, + } => { + let network: Network = network.into(); + let script = script_pubkey_from_descriptor(&descriptor).map_err(|e| anyhow!(e))?; + let address = from_output_script_with_network(&script, network) + .context("Failed to encode address")?; + println!("{}", address); + Ok(()) + } } } diff --git a/packages/wasm-utxo/cli/src/input.rs b/packages/wasm-utxo/cli/src/input.rs index 02afbad530e..7eae2a7cdd7 100644 --- a/packages/wasm-utxo/cli/src/input.rs +++ b/packages/wasm-utxo/cli/src/input.rs @@ -3,6 +3,18 @@ use base64::Engine; use std::fs; use std::io::{self, Read}; use std::path::PathBuf; +use std::str::FromStr; +use wasm_utxo::bitcoin::network::Network as BitcoinNetwork; +use wasm_utxo::bitcoin::PrivateKey; + +/// Parse a private key given as WIF or raw hex bytes. +pub fn parse_private_key(s: &str) -> Result { + if let Ok(pk) = PrivateKey::from_str(s) { + return Ok(pk); + } + let bytes = hex::decode(s).context("private key must be WIF or hex")?; + PrivateKey::from_slice(&bytes, BitcoinNetwork::Bitcoin).context("invalid private key bytes") +} /// Decode input bytes, attempting to interpret as base64, hex, or raw bytes pub fn decode_input(raw_bytes: &[u8]) -> Result> { From bbc43fab62f7e22f52f692c7c9d4b98255bb33ec Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 3 Jul 2026 11:32:04 +0200 Subject: [PATCH 5/5] feat(wasm-utxo-cli): add psbt create/add-input/add-output/sign subcommands Four composable subcommands under `psbt`, replacing the earlier single-shot build-sign command: `psbt create`, `psbt add-input`, `psbt add-output`, `psbt sign`. Each reads/writes a PSBT as hex via stdin/stdout (or a file path), so they pipe together, e.g.: psbt create | psbt add-input - --network tzec --txid ... --descriptor pkh() \ | psbt add-output - --address ... --network tzec \ | psbt sign - --network tzec --privkey ... --consensus-branch-id 0xc2d6d0b4 `add-input` and `sign` work for all networks, not just Zcash, using the wasm-utxo library helpers added in the parent commit: - `add-input` requires `--prev-tx` (a full previous transaction) unless `--network` is a value-committing network (Zcash/BCH-family) whose sighash already commits the input amount. - `sign` dispatches the sighash algorithm on `--network`: plain, FORKID (BCH family), or Zcash ZIP-243. Manually verified end-to-end for all three sighash families. This lets the indexer-utxo regtest fixture generator build+sign a transparent Zcash transaction off-node for zebrad's sendrawtransaction. Refs: T1-3672 --- packages/wasm-utxo/cli/README.md | 48 +++++++ packages/wasm-utxo/cli/src/input.rs | 11 ++ packages/wasm-utxo/cli/src/psbt/add_input.rs | 69 +++++++++ packages/wasm-utxo/cli/src/psbt/add_output.rs | 48 +++++++ packages/wasm-utxo/cli/src/psbt/common.rs | 18 +++ packages/wasm-utxo/cli/src/psbt/create.rs | 18 +++ packages/wasm-utxo/cli/src/psbt/mod.rs | 132 +++++++++++++++++- packages/wasm-utxo/cli/src/psbt/sign.rs | 69 +++++++++ 8 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 packages/wasm-utxo/cli/src/psbt/add_input.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/add_output.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/common.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/create.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/sign.rs diff --git a/packages/wasm-utxo/cli/README.md b/packages/wasm-utxo/cli/README.md index d59c2df9bb8..0dff389cdb8 100644 --- a/packages/wasm-utxo/cli/README.md +++ b/packages/wasm-utxo/cli/README.md @@ -125,6 +125,54 @@ The output displays a hierarchical tree view of the PSBT structure, including: - Per-output fields (scripts, derivation paths) - Decoded transaction details +#### Build and sign a transparent transaction + +Four composable subcommands — `create`, `add-input`, `add-output`, `sign` — each read a PSBT +from a file or stdin (`-`) and print the result as hex, so they pipe together into a full +build-and-sign flow for a single-key transparent (non-segwit) transaction. Works for any +network; the sighash algorithm used by `sign` (plain, FORKID, or Zcash ZIP-243) is selected by +`--network`. + +```bash +wasm-utxo-cli psbt create [--version ] [--lock-time ] +wasm-utxo-cli psbt add-input --network --txid --vout \ + --value --script --descriptor [--prev-tx ] +wasm-utxo-cli psbt add-output (--address
--network | --script ) --value +wasm-utxo-cli psbt sign --network --privkey [--consensus-branch-id ] +``` + +`add-input` requires `--prev-tx` (the full previous transaction, hex-encoded) unless +`--network` is a value-committing network (Zcash, BCH family) whose sighash already commits the +spent amount — see `Network::requires_prev_tx_for_legacy_input` in the `wasm-utxo` library. + +**Examples:** + +```bash +# BTC: spend a P2PKH coinbase-style output, providing the full previous transaction +wasm-utxo-cli psbt create \ + | wasm-utxo-cli psbt add-input - --network btc \ + --txid e6a6e7f5551af932bc3813d920c52d61ec39fbad2e5585a018cce7dcbdd4ec72 --vout 0 \ + --value 50000000 --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac \ + --descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" \ + --prev-tx 010000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff0180f0fa02000000001976a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac00000000 \ + | wasm-utxo-cli psbt add-output - --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac --value 49990000 \ + | wasm-utxo-cli psbt sign - --network btc --privkey KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv +# Output: a plain-sighash signed BTC transaction, hex-encoded + +# Zcash: spend a transparent P2PKH coinbase output for regtest fixture generation +# (e.g. for a zebrad-mined UTXO to broadcast via sendrawtransaction). No --prev-tx needed — +# ZIP-243 sighash commits the input amount. +wasm-utxo-cli psbt create --lock-time 0 \ + | wasm-utxo-cli psbt add-input - --network tzec \ + --txid a8c685478265f4c14dada651969c45a65e1aeb8cd6791f2f5bb6a1d9952104d9 --vout 0 \ + --value 50000000 --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac \ + --descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" \ + | wasm-utxo-cli psbt add-output - --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac --value 49990000 \ + | wasm-utxo-cli psbt sign - --network tzec --privkey KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv \ + --consensus-branch-id 0xc2d6d0b4 +# Output: a signed Zcash overwintered (NU5) transaction, hex-encoded +``` + ### Supported Networks The CLI supports the following networks (use with `--network` flag): diff --git a/packages/wasm-utxo/cli/src/input.rs b/packages/wasm-utxo/cli/src/input.rs index 7eae2a7cdd7..f0242027266 100644 --- a/packages/wasm-utxo/cli/src/input.rs +++ b/packages/wasm-utxo/cli/src/input.rs @@ -16,6 +16,17 @@ pub fn parse_private_key(s: &str) -> Result { PrivateKey::from_slice(&bytes, BitcoinNetwork::Bitcoin).context("invalid private key bytes") } +/// Parse a `u32` given as decimal or `0x`-prefixed hex. +pub fn parse_u32_flexible(s: &str) -> Result { + let s = s.trim(); + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + u32::from_str_radix(hex, 16).with_context(|| format!("invalid hex u32: {s}")) + } else { + s.parse::() + .with_context(|| format!("invalid u32: {s}")) + } +} + /// Decode input bytes, attempting to interpret as base64, hex, or raw bytes pub fn decode_input(raw_bytes: &[u8]) -> Result> { // Try to interpret as text first (for base64/hex encoded input) diff --git a/packages/wasm-utxo/cli/src/psbt/add_input.rs b/packages/wasm-utxo/cli/src/psbt/add_input.rs new file mode 100644 index 00000000000..8116069c041 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/add_input.rs @@ -0,0 +1,69 @@ +use anyhow::{anyhow, bail, Context, Result}; +use std::path::PathBuf; +use std::str::FromStr; +use wasm_utxo::add_input_with_descriptor; +use wasm_utxo::bitcoin::consensus::Decodable; +use wasm_utxo::bitcoin::{ScriptBuf, Transaction, Txid}; + +use super::common::{print_psbt, read_psbt}; +use crate::network::NetworkArg; + +#[allow(clippy::too_many_arguments)] +pub fn handle_add_input_command( + path: PathBuf, + network: NetworkArg, + txid: String, + vout: u32, + value: u64, + script: String, + descriptor: String, + sequence: u32, + prev_tx: Option, +) -> Result<()> { + let network: wasm_utxo::Network = network.into(); + let mut psbt = read_psbt(&path)?; + + let txid = Txid::from_str(&txid).context("invalid --txid")?; + let script_pubkey = ScriptBuf::from(hex::decode(&script).context("invalid --script hex")?); + + let non_witness_utxo = match prev_tx { + Some(hex_str) => { + let bytes = hex::decode(&hex_str).context("invalid --prev-tx hex")?; + let tx = Transaction::consensus_decode(&mut bytes.as_slice()) + .context("invalid --prev-tx transaction")?; + if tx.compute_txid() != txid { + bail!( + "--prev-tx txid {} does not match --txid {}", + tx.compute_txid(), + txid + ); + } + Some(tx) + } + None if network.requires_prev_tx_for_legacy_input() => { + bail!( + "--prev-tx is required for network {network} (only value-committing networks \ + like Zcash/BCH-family can omit it)" + ); + } + None => None, + }; + + let index = psbt.inputs.len(); + add_input_with_descriptor( + &mut psbt, + index, + txid, + vout, + value, + script_pubkey, + &descriptor, + sequence, + non_witness_utxo, + ) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("failed to add input {index}"))?; + + print_psbt(&psbt); + Ok(()) +} diff --git a/packages/wasm-utxo/cli/src/psbt/add_output.rs b/packages/wasm-utxo/cli/src/psbt/add_output.rs new file mode 100644 index 00000000000..68b371c57bd --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/add_output.rs @@ -0,0 +1,48 @@ +use anyhow::{anyhow, bail, Context, Result}; +use std::path::PathBuf; +use wasm_utxo::bitcoin::psbt::Output as PsbtOutput; +use wasm_utxo::bitcoin::{Amount, ScriptBuf, TxOut}; +use wasm_utxo::psbt_ops::insert_output; +use wasm_utxo::to_output_script_with_network; + +use super::common::{print_psbt, read_psbt}; +use crate::network::NetworkArg; + +pub fn handle_add_output_command( + path: PathBuf, + network: Option, + address: Option, + script: Option, + value: u64, +) -> Result<()> { + let mut psbt = read_psbt(&path)?; + + let script_pubkey = match (address, script) { + (Some(address), None) => { + let network = network + .ok_or_else(|| anyhow!("--network is required when using --address"))? + .into(); + to_output_script_with_network(&address, network).context("invalid --address")? + } + (None, Some(script)) => { + ScriptBuf::from(hex::decode(&script).context("invalid --script hex")?) + } + _ => bail!("expected exactly one of --address or --script"), + }; + + let index = psbt.outputs.len(); + insert_output( + &mut psbt, + index, + TxOut { + value: Amount::from_sat(value), + script_pubkey, + }, + PsbtOutput::default(), + ) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("failed to add output {index}"))?; + + print_psbt(&psbt); + Ok(()) +} diff --git a/packages/wasm-utxo/cli/src/psbt/common.rs b/packages/wasm-utxo/cli/src/psbt/common.rs new file mode 100644 index 00000000000..a3825639cb2 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/common.rs @@ -0,0 +1,18 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; +use wasm_utxo::bitcoin::psbt::Psbt; + +use crate::input::{decode_input, read_input_bytes}; + +/// Read and deserialize a PSBT from a file path or stdin (`-`), auto-detecting +/// hex/base64/raw encoding. +pub fn read_psbt(path: &PathBuf) -> Result { + let raw_bytes = read_input_bytes(path, "PSBT")?; + let bytes = decode_input(&raw_bytes)?; + Psbt::deserialize(&bytes).context("Failed to parse PSBT") +} + +/// Serialize a PSBT and print it as hex to stdout. +pub fn print_psbt(psbt: &Psbt) { + println!("{}", hex::encode(psbt.serialize())); +} diff --git a/packages/wasm-utxo/cli/src/psbt/create.rs b/packages/wasm-utxo/cli/src/psbt/create.rs new file mode 100644 index 00000000000..5740fb27664 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/create.rs @@ -0,0 +1,18 @@ +use anyhow::Result; +use wasm_utxo::bitcoin::locktime::absolute::LockTime; +use wasm_utxo::bitcoin::psbt::Psbt; +use wasm_utxo::bitcoin::transaction::{Transaction, Version}; + +use super::common::print_psbt; + +pub fn handle_create_command(version: i32, lock_time: u32) -> Result<()> { + let tx = Transaction { + version: Version(version), + lock_time: LockTime::from_consensus(lock_time), + input: vec![], + output: vec![], + }; + let psbt = Psbt::from_unsigned_tx(tx).expect("empty transaction should be valid"); + print_psbt(&psbt); + Ok(()) +} diff --git a/packages/wasm-utxo/cli/src/psbt/mod.rs b/packages/wasm-utxo/cli/src/psbt/mod.rs index 7489d51ab33..034da488698 100644 --- a/packages/wasm-utxo/cli/src/psbt/mod.rs +++ b/packages/wasm-utxo/cli/src/psbt/mod.rs @@ -1,16 +1,22 @@ use anyhow::Result; -use clap::Subcommand; +use clap::{ArgGroup, Subcommand}; +use std::path::PathBuf; use crate::network::NetworkArg; +mod add_input; +mod add_output; +mod common; +mod create; mod parse; +mod sign; #[derive(Subcommand)] pub enum PsbtCommand { /// Parse a PSBT file and display its contents Parse { /// Path to the PSBT file (use '-' to read from stdin) - path: std::path::PathBuf, + path: PathBuf, /// Network for address formatting #[arg(long, short, value_enum)] network: NetworkArg, @@ -21,6 +27,90 @@ pub enum PsbtCommand { #[arg(long)] raw: bool, }, + /// Create an empty PSBT. Prints the PSBT as hex to stdout. + Create { + /// Transaction version (default: 4, required for Zcash overwintered txs) + #[arg(long, default_value_t = 4)] + version: i32, + /// Transaction lock time (default: 0) + #[arg(long, default_value_t = 0)] + lock_time: u32, + }, + /// Add an input spending `{txid, vout, value, scriptPubKey}` to a PSBT, given a definite + /// descriptor for the output (e.g. `pkh()`, `wpkh()`). Prints the updated + /// PSBT as hex to stdout. + AddInput { + /// Path to the PSBT file (use '-' to read from stdin) + path: PathBuf, + /// Network (determines whether --prev-tx is required; e.g. tzec for Zcash regtest) + #[arg(long, short, value_enum)] + network: NetworkArg, + /// Transaction ID of the output being spent + #[arg(long)] + txid: String, + /// Output index being spent + #[arg(long)] + vout: u32, + /// Value in satoshis of the output being spent + #[arg(long)] + value: u64, + /// scriptPubKey of the output being spent, hex-encoded + #[arg(long)] + script: String, + /// Definite descriptor for the output being spent, with concrete keys + /// (e.g. `pkh()`, `wpkh()`); populates bip32_derivation/tap_key_origins + #[arg(long)] + descriptor: String, + /// Sequence number (default: 0xFFFFFFFE) + #[arg(long, default_value_t = 0xFFFFFFFE)] + sequence: u32, + /// Full previous transaction, hex-encoded (BIP174-safe path). Required unless --network + /// is a value-committing network (Zcash, BCH-family) whose sighash already commits the + /// input amount, making it safe to sign from --value/--script alone. + #[arg(long = "prev-tx")] + prev_tx: Option, + }, + /// Add an output to a PSBT. Prints the updated PSBT as hex to stdout. + #[command(group(ArgGroup::new("target").required(true).args(["address", "script"])))] + AddOutput { + /// Path to the PSBT file (use '-' to read from stdin) + path: PathBuf, + /// Output address (requires --network) + #[arg(long)] + address: Option, + /// Output script, hex-encoded + #[arg(long)] + script: Option, + /// Value in satoshis + #[arg(long)] + value: u64, + /// Network for --address (e.g. tzec for Zcash regtest, which reuses testnet prefixes) + #[arg(long, short, value_enum)] + network: Option, + }, + /// Sign all inputs with a single private key, then finalize and extract. The sighash + /// algorithm is selected by --network: plain for BTC-like networks, FORKID for the + /// BCH family, or Zcash ZIP-243. Prints the signed wire hex to stdout (overwintered + /// format for Zcash). + Sign { + /// Path to the PSBT file (use '-' to read from stdin) + path: PathBuf, + /// Network (selects the sighash algorithm: plain, FORKID, or Zcash ZIP-243) + #[arg(long, short, value_enum)] + network: NetworkArg, + /// Controlling private key for all inputs (WIF or hex) + #[arg(long)] + privkey: String, + /// Zcash consensus branch ID, hex (0x...) or decimal (required for Zcash, unused otherwise) + #[arg(long)] + consensus_branch_id: Option, + /// Zcash version group ID, hex or decimal (default: Sapling 0x892F2085; Zcash only) + #[arg(long)] + version_group_id: Option, + /// Transaction expiry height (default: 0, no expiry; Zcash only) + #[arg(long, default_value_t = 0)] + expiry_height: u32, + }, } pub fn handle_command(command: PsbtCommand) -> Result<()> { @@ -31,5 +121,43 @@ pub fn handle_command(command: PsbtCommand) -> Result<()> { raw, network, } => parse::handle_parse_command(path, no_color, raw, network.into()), + PsbtCommand::Create { version, lock_time } => { + create::handle_create_command(version, lock_time) + } + PsbtCommand::AddInput { + path, + network, + txid, + vout, + value, + script, + descriptor, + sequence, + prev_tx, + } => add_input::handle_add_input_command( + path, network, txid, vout, value, script, descriptor, sequence, prev_tx, + ), + PsbtCommand::AddOutput { + path, + address, + script, + value, + network, + } => add_output::handle_add_output_command(path, network, address, script, value), + PsbtCommand::Sign { + path, + network, + privkey, + consensus_branch_id, + version_group_id, + expiry_height, + } => sign::handle_sign_command( + path, + network, + privkey, + consensus_branch_id, + version_group_id, + expiry_height, + ), } } diff --git a/packages/wasm-utxo/cli/src/psbt/sign.rs b/packages/wasm-utxo/cli/src/psbt/sign.rs new file mode 100644 index 00000000000..bf88da6dd31 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/sign.rs @@ -0,0 +1,69 @@ +use anyhow::{anyhow, bail, Result}; +use std::path::PathBuf; +use wasm_utxo::bitcoin::secp256k1::Secp256k1; +use wasm_utxo::zcash::transaction::ZCASH_SAPLING_VERSION_GROUP_ID; +use wasm_utxo::{ + finalize_and_encode_transaction, finalize_and_encode_zcash_transaction, sign_with_privkey, + sign_zcash_with_privkey, Network, +}; + +use super::common::read_psbt; +use crate::input::{parse_private_key, parse_u32_flexible}; +use crate::network::NetworkArg; + +#[allow(clippy::too_many_arguments)] +pub fn handle_sign_command( + path: PathBuf, + network: NetworkArg, + privkey: String, + consensus_branch_id: Option, + version_group_id: Option, + expiry_height: u32, +) -> Result<()> { + let network: Network = network.into(); + let mut psbt = read_psbt(&path)?; + let privkey = parse_private_key(&privkey)?; + let secp = Secp256k1::new(); + + let tx_bytes = if network.mainnet() == Network::Zcash { + let consensus_branch_id = consensus_branch_id + .ok_or_else(|| anyhow!("--consensus-branch-id is required for network {network}")) + .and_then(|s| parse_u32_flexible(&s))?; + let version_group_id = version_group_id + .as_deref() + .map(parse_u32_flexible) + .transpose()? + .unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID); + + sign_zcash_with_privkey( + &mut psbt, + privkey, + &secp, + consensus_branch_id, + version_group_id, + expiry_height, + ) + .map_err(|errors| anyhow!("signing failed: {:?}", errors))?; + + finalize_and_encode_zcash_transaction( + psbt, + consensus_branch_id, + version_group_id, + expiry_height, + ) + .map_err(|e| anyhow!(e))? + } else { + if consensus_branch_id.is_some() || version_group_id.is_some() || expiry_height != 0 { + bail!("--consensus-branch-id/--version-group-id/--expiry-height only apply to Zcash"); + } + + let fork_id = network.sighash_fork_id(); + sign_with_privkey(&mut psbt, privkey, &secp, fork_id) + .map_err(|errors| anyhow!("signing failed: {:?}", errors))?; + + finalize_and_encode_transaction(psbt, fork_id).map_err(|e| anyhow!(e))? + }; + + println!("{}", hex::encode(tx_bytes)); + Ok(()) +}