Skip to content

Commit 4020a73

Browse files
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(<privkey>)) 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
1 parent 1951034 commit 4020a73

4 files changed

Lines changed: 542 additions & 81 deletions

File tree

packages/wasm-utxo/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod paygo;
1313
pub mod psbt_ops;
1414
#[cfg(test)]
1515
mod test_utils;
16+
pub mod transparent_signing;
1617
pub mod zcash;
1718

1819
// re-export bitcoin from the miniscript crate
@@ -25,6 +26,11 @@ pub use address::{
2526
};
2627

2728
pub use networks::Network;
29+
pub use transparent_signing::{
30+
add_input_with_descriptor, finalize_and_encode_transaction,
31+
finalize_and_encode_zcash_transaction, script_pubkey_from_descriptor, sign_with_privkey,
32+
sign_zcash_with_privkey,
33+
};
2834
pub mod wasm;
2935
pub use wasm::{
3036
WasmBIP32, WasmECPair, WasmRootWalletKeys, WrapDescriptor, WrapMiniscript, WrapPsbt,
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
//! Single-key transparent PSBT signing helpers.
2+
//!
3+
//! These operate on plain `miniscript::bitcoin::psbt::Psbt` and have no `wasm_bindgen` surface —
4+
//! they're shared by `wasm::psbt::WrapPsbt`'s wasm-bindgen methods and directly by
5+
//! `wasm-utxo-cli`'s `psbt` build commands (which never go through the wasm layer). No new
6+
//! sighash code: these compose the existing `Psbt::sign`/`sign_forkid`/`sign_zcash` and
7+
//! `PsbtExt` primitives that already back `fixed_script_wallet`.
8+
9+
use crate::zcash::transaction::ZcashTransactionParts;
10+
use miniscript::bitcoin::bip32::Fingerprint;
11+
use miniscript::bitcoin::secp256k1::{Secp256k1, Signing, Verification};
12+
use miniscript::bitcoin::{
13+
psbt, Amount, OutPoint, PrivateKey, PublicKey, ScriptBuf, Sequence, Transaction, TxIn, TxOut,
14+
Txid, XOnlyPublicKey,
15+
};
16+
use miniscript::descriptor::{SinglePub, SinglePubKey};
17+
use miniscript::psbt::{PsbtExt, PsbtInputExt};
18+
use miniscript::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey, ToPublicKey};
19+
use std::str::FromStr;
20+
21+
#[derive(Debug)]
22+
pub(crate) struct SingleKeySigner {
23+
privkey: PrivateKey,
24+
pubkey: PublicKey,
25+
_pubkey_xonly: XOnlyPublicKey,
26+
pub(crate) fingerprint: Fingerprint,
27+
fingerprint_xonly: Fingerprint,
28+
}
29+
30+
impl SingleKeySigner {
31+
fn fingerprint(key: SinglePubKey) -> Fingerprint {
32+
DescriptorPublicKey::Single(SinglePub { origin: None, key }).master_fingerprint()
33+
}
34+
35+
pub(crate) fn from_privkey<C: Signing>(
36+
privkey: PrivateKey,
37+
secp: &Secp256k1<C>,
38+
) -> SingleKeySigner {
39+
let pubkey = privkey.public_key(secp);
40+
let pubkey_xonly = pubkey.to_x_only_pubkey();
41+
SingleKeySigner {
42+
privkey,
43+
pubkey,
44+
_pubkey_xonly: pubkey_xonly,
45+
fingerprint: SingleKeySigner::fingerprint(SinglePubKey::FullKey(pubkey)),
46+
fingerprint_xonly: SingleKeySigner::fingerprint(SinglePubKey::XOnly(pubkey_xonly)),
47+
}
48+
}
49+
}
50+
51+
impl psbt::GetKey for SingleKeySigner {
52+
type Error = String;
53+
54+
fn get_key<C: Signing>(
55+
&self,
56+
key_request: psbt::KeyRequest,
57+
_secp: &Secp256k1<C>,
58+
) -> Result<Option<PrivateKey>, Self::Error> {
59+
match key_request {
60+
// NOTE: this KeyRequest does not occur for taproot signatures
61+
// even if the descriptor keys are definite, we will receive a bip32 request
62+
// instead based on `DescriptorPublicKey::Single(SinglePub { origin: None, key, })`
63+
psbt::KeyRequest::Pubkey(req_pubkey) => {
64+
if req_pubkey == self.pubkey {
65+
Ok(Some(self.privkey))
66+
} else {
67+
Ok(None)
68+
}
69+
}
70+
71+
psbt::KeyRequest::Bip32((fingerprint, _path)) => {
72+
if fingerprint.eq(&self.fingerprint) || fingerprint.eq(&self.fingerprint_xonly) {
73+
Ok(Some(self.privkey))
74+
} else {
75+
Ok(None)
76+
}
77+
}
78+
79+
_ => Ok(None),
80+
}
81+
}
82+
}
83+
84+
/// Compute the output script for a descriptor string, which may embed private keys (e.g.
85+
/// `pkh(<privkey>)`, `wpkh(<privkey>)`) — used to derive an address directly from a signing
86+
/// descriptor without a separate private-key argument.
87+
///
88+
/// Uses `Descriptor::parse_descriptor`, which turns any embedded secret keys into their
89+
/// corresponding public keys (discarding the resulting key map — this is a read-only address
90+
/// derivation, not a signing operation). Resolves at derivation index 0; index is a no-op for
91+
/// non-wildcard descriptors, which is the expected case for a descriptor naming concrete keys.
92+
pub fn script_pubkey_from_descriptor(descriptor: &str) -> Result<ScriptBuf, String> {
93+
let secp = Secp256k1::new();
94+
let (desc, _keymap) =
95+
Descriptor::parse_descriptor(&secp, descriptor).map_err(|e| e.to_string())?;
96+
let definite = desc.at_derivation_index(0).map_err(|e| e.to_string())?;
97+
Ok(definite.script_pubkey())
98+
}
99+
100+
/// Add a witness_utxo input spending the given definite descriptor's output, populating
101+
/// `bip32_derivation`/`tap_key_origins` from the descriptor so the input is signable via
102+
/// [`sign_with_privkey`] / [`sign_zcash_with_privkey`].
103+
///
104+
/// `descriptor` is a definite descriptor string with concrete keys, e.g. `pkh(<pubkey>)` or
105+
/// `wpkh(<pubkey>)`; not limited to P2PKH.
106+
///
107+
/// `non_witness_utxo`, when given, is validated against `witness_utxo` and the descriptor via
108+
/// the checked `PsbtExt::update_input_with_descriptor` (BIP174-safe). When omitted, falls back
109+
/// to `PsbtInputExt::update_with_descriptor_unchecked` — safe only for value-committing sighash
110+
/// networks where `non_witness_utxo` is cryptographically pointless (see
111+
/// [`Network::requires_prev_tx_for_legacy_input`](crate::Network::requires_prev_tx_for_legacy_input)).
112+
/// Callers must gate on that predicate themselves before omitting `non_witness_utxo`.
113+
#[allow(clippy::too_many_arguments)]
114+
pub fn add_input_with_descriptor(
115+
psbt: &mut miniscript::bitcoin::Psbt,
116+
index: usize,
117+
txid: Txid,
118+
vout: u32,
119+
value: u64,
120+
script_pubkey: ScriptBuf,
121+
descriptor: &str,
122+
sequence: u32,
123+
non_witness_utxo: Option<Transaction>,
124+
) -> Result<(), String> {
125+
let desc =
126+
Descriptor::<DefiniteDescriptorKey>::from_str(descriptor).map_err(|e| e.to_string())?;
127+
128+
let tx_in = TxIn {
129+
previous_output: OutPoint { txid, vout },
130+
script_sig: ScriptBuf::new(),
131+
sequence: Sequence(sequence),
132+
witness: miniscript::bitcoin::Witness::default(),
133+
};
134+
let has_non_witness_utxo = non_witness_utxo.is_some();
135+
let psbt_input = psbt::Input {
136+
witness_utxo: Some(TxOut {
137+
value: Amount::from_sat(value),
138+
script_pubkey,
139+
}),
140+
non_witness_utxo,
141+
..Default::default()
142+
};
143+
crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input)?;
144+
145+
if has_non_witness_utxo {
146+
psbt.update_input_with_descriptor(index, &desc)
147+
.map_err(|e| e.to_string())
148+
} else {
149+
psbt.inputs[index]
150+
.update_with_descriptor_unchecked(&desc)
151+
.map(|_| ())
152+
.map_err(|e| e.to_string())
153+
}
154+
}
155+
156+
/// Sign all inputs of a PSBT with a single private key.
157+
///
158+
/// `fork_id` selects the sighash algorithm: `Some(id)` uses `Psbt::sign_forkid` (BCH-family /
159+
/// BTG), `None` uses plain `Psbt::sign`. See [`Network::sighash_fork_id`](crate::Network::sighash_fork_id).
160+
pub fn sign_with_privkey<C: Signing + Verification>(
161+
psbt: &mut miniscript::bitcoin::Psbt,
162+
privkey: PrivateKey,
163+
secp: &Secp256k1<C>,
164+
fork_id: Option<u32>,
165+
) -> Result<psbt::SigningKeysMap, psbt::SigningErrors> {
166+
let signer = SingleKeySigner::from_privkey(privkey, secp);
167+
match fork_id {
168+
Some(id) => psbt.sign_forkid(&signer, secp, id),
169+
None => psbt.sign(&signer, secp),
170+
}
171+
.map_err(|(_, errors)| errors)
172+
}
173+
174+
/// Finalize a fully-signed PSBT (using `finalize_mut_with_fork_id`, matching the `fork_id` used
175+
/// to sign) and encode the extracted transaction.
176+
pub fn finalize_and_encode_transaction(
177+
mut psbt: miniscript::bitcoin::Psbt,
178+
fork_id: Option<u32>,
179+
) -> Result<Vec<u8>, String> {
180+
use miniscript::bitcoin::consensus::Encodable;
181+
182+
psbt.finalize_mut_with_fork_id(&Secp256k1::verification_only(), fork_id)
183+
.map_err(|errors| {
184+
format!(
185+
"Failed to finalize PSBT: {}",
186+
errors
187+
.into_iter()
188+
.map(|e| e.to_string())
189+
.collect::<Vec<_>>()
190+
.join(", ")
191+
)
192+
})?;
193+
let tx = psbt
194+
.extract_tx()
195+
.map_err(|e| format!("Failed to extract transaction: {}", e))?;
196+
let mut bytes = Vec::new();
197+
tx.consensus_encode(&mut bytes)
198+
.map_err(|e| format!("Failed to encode transaction: {}", e))?;
199+
Ok(bytes)
200+
}
201+
202+
/// Sign all Zcash transparent inputs of a PSBT using the ZIP-243 sighash algorithm
203+
/// (`Psbt::sign_zcash` in the rust-bitcoin fork).
204+
pub fn sign_zcash_with_privkey<C: Signing + Verification>(
205+
psbt: &mut miniscript::bitcoin::Psbt,
206+
privkey: PrivateKey,
207+
secp: &Secp256k1<C>,
208+
consensus_branch_id: u32,
209+
version_group_id: u32,
210+
expiry_height: u32,
211+
) -> Result<psbt::SigningKeysMap, psbt::SigningErrors> {
212+
let signer = SingleKeySigner::from_privkey(privkey, secp);
213+
psbt.sign_zcash(
214+
&signer,
215+
secp,
216+
consensus_branch_id,
217+
version_group_id,
218+
expiry_height,
219+
)
220+
.map_err(|(_, errors)| errors)
221+
}
222+
223+
/// Finalize a fully-signed PSBT using ZIP-243 sighash verification (`finalize_mut_with_zcash`)
224+
/// and encode it as Zcash overwintered transaction bytes.
225+
pub fn finalize_and_encode_zcash_transaction(
226+
mut psbt: miniscript::bitcoin::Psbt,
227+
consensus_branch_id: u32,
228+
version_group_id: u32,
229+
expiry_height: u32,
230+
) -> Result<Vec<u8>, String> {
231+
psbt.finalize_mut_with_zcash(
232+
&Secp256k1::verification_only(),
233+
consensus_branch_id,
234+
version_group_id,
235+
expiry_height,
236+
)
237+
.map_err(|errors| {
238+
format!(
239+
"Failed to finalize PSBT: {}",
240+
errors
241+
.into_iter()
242+
.map(|e| e.to_string())
243+
.collect::<Vec<_>>()
244+
.join(", ")
245+
)
246+
})?;
247+
let parts = ZcashTransactionParts::extract_from_psbt(psbt, version_group_id, expiry_height)?;
248+
crate::zcash::transaction::encode_zcash_transaction_parts(&parts)
249+
}

0 commit comments

Comments
 (0)