Skip to content

Commit 2f18ae5

Browse files
veetragjainclaude
andcommitted
feat(wasm-utxo): wire v6 (Ironwood) build/sign/combine into ZcashBitGoPsbt
Add a dedicated Zcash v6 (Ironwood / NU6.3) shielding flow on ZcashBitGoPsbt, mirroring the microservice build → sign → combine PSBT lifecycle. The ~20 generic BitGoPsbt::Zcash dispatch arms are left untouched; v6 uses dedicated methods. propkv: add ProprietaryKeySubtype::ZecIronwoodPczt (0x07, the serialized orchard PCZT) and ZecV6Params (0x08, versionGroupId + expiryHeight); the latter marks a PSBT as v6 so it round-trips through a plain PSBT serialization. ZcashBitGoPsbt v6 methods: - new_v6 / new_v6_at_height: version-6, Ironwood VGID, NU6.3 branch. - add_ironwood_output (Constructor): build the shielded note as an orchard PCZT and store it in the PSBT. - ironwood_action_data / v6_txid / v6_transparent_sighash: derive the ZIP-244 txid and per-input transparent sighash from the transparent skeleton + stored PCZT action data. - add_v6_transparent_signature: verify a client/HSM signature against the v6 sighash and insert it into partial_sigs. - combine_ironwood_proof (Extractor): finalize the transparent inputs, splice in the external prover's zkproof, apply the binding signature, and encode the broadcast-ready v6 transaction. - serialize_v6 / deserialize_v6: round-trip the v6 PSBT (transparent skeleton + PCZT + params). BitGoPsbt::new_zcash_v6_at_height: a builder so transparent inputs/outputs use the existing add_wallet_input / add_wallet_output machinery. Hardening (from review): reject v6 PSBTs on the v4/Sapling-shaped paths instead of silently producing a bad result; enforce the 2-of-3 signature threshold in finalized_transparent_tx instead of pushing every collected signature (which overflows OP_CHECKMULTISIG's exact-two pop); check new_v6_at_height's height against NU6.3 activation instead of any post-Overwinter height; validate version_group_id on deserialize_v6 instead of trusting it; replace panicking index access across parallel psbt.inputs/unsigned_tx.input vectors with errors (a WASM panic aborts rather than raising a JS exception); error instead of silently overwriting an existing note in add_ironwood_output; reject signing keys absent from the input's redeem script in add_v6_transparent_signature. Tests (native): - build a 2-of-3 P2SH → Ironwood shield PSBT, round-trip it, sign the transparent input over the ZIP-244 sighash, combine with a placeholder proof, and assert the result decodes, keeps a stable txid across signing, and is accepted by zebra-chain. - golden oracle: reproduce the on-chain shield1zec transaction inside a PSBT, sourced from the v6_shield1zec_details.json fixture, and assert its v6_transparent_sighash both equals the codec-golden sighash, verifies the transaction's real ECDSA signature, and that the PSBT-derived v6_txid matches the real on-chain txid. - propkv round-trip unit test. - regression tests for each hardening fix above, including that the v4 paths reject a v6 PSBT and leave partial_sigs empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c7b0dff commit 2f18ae5

5 files changed

Lines changed: 1379 additions & 2 deletions

File tree

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,31 @@ impl BitGoPsbt {
497497
))
498498
}
499499

500+
/// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with the consensus branch id resolved
501+
/// from block height. Delegates to [`ZcashBitGoPsbt::new_v6_at_height`].
502+
///
503+
/// The transparent inputs/outputs are added with the usual [`Self::add_wallet_input`] /
504+
/// [`Self::add_wallet_output`] machinery; the shielded output and the v6 sign/combine steps use
505+
/// the dedicated methods on [`ZcashBitGoPsbt`].
506+
pub fn new_zcash_v6_at_height(
507+
network: Network,
508+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
509+
block_height: u32,
510+
lock_time: Option<u32>,
511+
expiry_height: Option<u32>,
512+
) -> Result<Self, String> {
513+
Ok(BitGoPsbt::Zcash(
514+
ZcashBitGoPsbt::new_v6_at_height(
515+
network,
516+
wallet_keys,
517+
block_height,
518+
lock_time,
519+
expiry_height,
520+
)?,
521+
network,
522+
))
523+
}
524+
500525
/// Create a new empty PSBT with the same network parameters as an existing PSBT
501526
///
502527
/// This is useful for reconstructing PSBTs - it copies:
@@ -1966,6 +1991,7 @@ impl BitGoPsbt {
19661991
input_index: usize,
19671992
privkey: &secp256k1::SecretKey,
19681993
) -> Result<(), String> {
1994+
self.ensure_not_ironwood_v6()?;
19691995
use miniscript::bitcoin::PublicKey;
19701996

19711997
// Get network before mutable borrow
@@ -2291,6 +2317,7 @@ impl BitGoPsbt {
22912317
input_index: usize,
22922318
privkey: &secp256k1::SecretKey,
22932319
) -> Result<(), String> {
2320+
self.ensure_not_ironwood_v6()?;
22942321
let psbt = self.psbt();
22952322
if input_index >= psbt.inputs.len() {
22962323
return Err(format!(
@@ -2394,6 +2421,21 @@ impl BitGoPsbt {
23942421
}
23952422
}
23962423
BitGoPsbt::Zcash(ref mut zcash_psbt, _network) => {
2424+
// v6 (Ironwood) inputs are signed over the ZIP-244 digest, not ZIP-243. Signing
2425+
// here would put a signature into `partial_sigs` that no verifier can satisfy, and
2426+
// nothing downstream re-checks it — so refuse. `SignError` carries no message, so
2427+
// callers going through the `String`-returning wrappers get the detail from
2428+
// `ensure_not_ironwood_v6`. Use `add_v6_transparent_signature` instead.
2429+
if zcash_psbt.is_ironwood_v6() {
2430+
return Err((
2431+
Default::default(),
2432+
std::collections::BTreeMap::from_iter([(
2433+
0,
2434+
miniscript::bitcoin::psbt::SignError::UnknownOutputType,
2435+
)]),
2436+
));
2437+
}
2438+
23972439
// Extract consensus branch ID from PSBT proprietary map
23982440
let branch_id =
23992441
propkv::get_zec_consensus_branch_id(&zcash_psbt.psbt).ok_or_else(|| {
@@ -2420,6 +2462,18 @@ impl BitGoPsbt {
24202462
}
24212463
}
24222464

2465+
/// Reject v6 (Ironwood) PSBTs on the v4/Sapling-shaped paths, with a message explaining which
2466+
/// dedicated method to use instead. [`Self::sign`] applies the same rule, but its `SignError`
2467+
/// return type carries no message, so the `String`-returning wrappers check here as well.
2468+
pub(crate) fn ensure_not_ironwood_v6(&self) -> Result<(), String> {
2469+
match self {
2470+
BitGoPsbt::Zcash(z, _) if z.is_ironwood_v6() => {
2471+
Err(zcash_psbt::V6_NOT_SUPPORTED_BY_V4_PATH.to_string())
2472+
}
2473+
_ => Ok(()),
2474+
}
2475+
}
2476+
24232477
/// Sign all non-MuSig2 inputs with the provided xpriv in a single pass.
24242478
///
24252479
/// This is more efficient than calling `sign_with_privkey` for each input individually
@@ -2438,6 +2492,7 @@ impl BitGoPsbt {
24382492
&mut self,
24392493
xpriv: &miniscript::bitcoin::bip32::Xpriv,
24402494
) -> Result<miniscript::bitcoin::psbt::SigningKeysMap, String> {
2495+
self.ensure_not_ironwood_v6()?;
24412496
let secp = secp256k1::Secp256k1::new();
24422497

24432498
// Sign all inputs - miniscript handles this efficiently
@@ -2503,6 +2558,7 @@ impl BitGoPsbt {
25032558
input_index: usize,
25042559
xpriv: &miniscript::bitcoin::bip32::Xpriv,
25052560
) -> Result<(), String> {
2561+
self.ensure_not_ironwood_v6()?;
25062562
let psbt = self.psbt();
25072563
if input_index >= psbt.inputs.len() {
25082564
return Err(format!(
@@ -2840,6 +2896,13 @@ impl BitGoPsbt {
28402896
sighash::SighashCacheZcashExt,
28412897
};
28422898

2899+
// v6 (Ironwood) inputs are signed over the ZIP-244 digest, not ZIP-243. Signing here would
2900+
// insert a signature into `partial_sigs` that no verifier can ever satisfy, and nothing
2901+
// downstream re-checks it — so refuse instead.
2902+
if version_group_id == crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID {
2903+
return Err(zcash_psbt::V6_NOT_SUPPORTED_BY_V4_PATH.to_string());
2904+
}
2905+
28432906
// Get input value for sighash computation
28442907
let input = &psbt.inputs[input_index];
28452908
let prevout = psbt.unsigned_tx.input[input_index].previous_output;

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

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,101 @@ pub fn set_zec_consensus_branch_id(psbt: &mut miniscript::bitcoin::psbt::Psbt, b
246246
psbt.proprietary.insert(key, value);
247247
}
248248

249+
/// Zcash v6 (Ironwood) proprietary namespace — its own private subtype space, so v6 keys don't
250+
/// consume slots in the shared `BITGO` space (which is a hard-limited single byte shared by every
251+
/// other BitGo proprietary key: MuSig2, PayGo, BIP322, WasmUtxo, etc).
252+
pub const BITGO_ZEC_V6: &[u8] = b"BITGO/ZEC/V6";
253+
254+
/// Subtypes within the [`BITGO_ZEC_V6`] namespace.
255+
///
256+
/// This mirrors the v4 `ZecConsensusBranchId` (0x00 under the legacy `BITGO` prefix), but v4 is
257+
/// untouched: the two 0x00 branch-id keys are unambiguous because their prefixes differ.
258+
///
259+
/// Note that a v6 PSBT still carries the legacy `BITGO`/`ZecConsensusBranchId` key as well, because
260+
/// [`ZcashBitGoPsbt::new`] writes it for every Zcash PSBT. The v6 code paths read only the key in
261+
/// this namespace; the legacy one is redundant but harmless, and keeping it means the shared
262+
/// `new` constructor needs no v6 special case.
263+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264+
#[repr(u8)]
265+
pub enum ZecV6KeySubtype {
266+
ConsensusBranchId = 0x00,
267+
IronwoodPczt = 0x01,
268+
VersionGroupId = 0x02,
269+
ExpiryHeight = 0x03,
270+
}
271+
272+
fn set_zec_v6(
273+
psbt: &mut miniscript::bitcoin::psbt::Psbt,
274+
subtype: ZecV6KeySubtype,
275+
value: Vec<u8>,
276+
) {
277+
let key = ProprietaryKey {
278+
prefix: BITGO_ZEC_V6.to_vec(),
279+
subtype: subtype as u8,
280+
key: vec![],
281+
};
282+
psbt.proprietary.insert(key, value);
283+
}
284+
285+
fn get_zec_v6(psbt: &miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype) -> Option<Vec<u8>> {
286+
find_kv_iter(&psbt.proprietary, BITGO_ZEC_V6, Some(subtype as u8))
287+
.next()
288+
.map(|(_, v)| v.clone())
289+
}
290+
291+
fn set_zec_v6_u32(
292+
psbt: &mut miniscript::bitcoin::psbt::Psbt,
293+
subtype: ZecV6KeySubtype,
294+
value: u32,
295+
) {
296+
set_zec_v6(psbt, subtype, value.to_le_bytes().to_vec());
297+
}
298+
299+
fn get_zec_v6_u32(psbt: &miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype) -> Option<u32> {
300+
let bytes = get_zec_v6(psbt, subtype)?;
301+
Some(u32::from_le_bytes(bytes.as_slice().try_into().ok()?))
302+
}
303+
304+
/// Store the Zcash v6 (Ironwood) consensus branch ID under the `BITGO_ZEC_V6` namespace.
305+
pub fn set_zec_v6_consensus_branch_id(psbt: &mut miniscript::bitcoin::psbt::Psbt, branch_id: u32) {
306+
set_zec_v6_u32(psbt, ZecV6KeySubtype::ConsensusBranchId, branch_id);
307+
}
308+
309+
/// Fetch the Zcash v6 (Ironwood) consensus branch ID from the `BITGO_ZEC_V6` namespace, if present.
310+
pub fn get_zec_v6_consensus_branch_id(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<u32> {
311+
get_zec_v6_u32(psbt, ZecV6KeySubtype::ConsensusBranchId)
312+
}
313+
314+
/// Store a serialized Ironwood (v6) PCZT bundle in the PSBT global proprietary map, under the
315+
/// `BITGO_ZEC_V6` namespace's `IronwoodPczt` subtype. Overwrites any existing value.
316+
pub fn set_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt, bytes: Vec<u8>) {
317+
set_zec_v6(psbt, ZecV6KeySubtype::IronwoodPczt, bytes);
318+
}
319+
320+
/// Fetch the serialized Ironwood (v6) PCZT bundle from the PSBT global proprietary map, if present.
321+
pub fn get_ironwood_pczt(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<Vec<u8>> {
322+
get_zec_v6(psbt, ZecV6KeySubtype::IronwoodPczt)
323+
}
324+
325+
/// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the
326+
/// `BITGO_ZEC_V6` namespace. `version_group_id`'s presence marks the PSBT as v6; `expiry_height` is
327+
/// always written too (even when 0, a valid "no expiry" value, so it can be told apart from absent).
328+
pub fn set_zec_v6_params(
329+
psbt: &mut miniscript::bitcoin::psbt::Psbt,
330+
version_group_id: u32,
331+
expiry_height: u32,
332+
) {
333+
set_zec_v6_u32(psbt, ZecV6KeySubtype::VersionGroupId, version_group_id);
334+
set_zec_v6_u32(psbt, ZecV6KeySubtype::ExpiryHeight, expiry_height);
335+
}
336+
337+
/// Fetch the Zcash v6 (Ironwood) header params `(version_group_id, expiry_height)`, if present.
338+
pub fn get_zec_v6_params(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<(u32, u32)> {
339+
let vgid = get_zec_v6_u32(psbt, ZecV6KeySubtype::VersionGroupId)?;
340+
let expiry = get_zec_v6_u32(psbt, ZecV6KeySubtype::ExpiryHeight)?;
341+
Some((vgid, expiry))
342+
}
343+
249344
#[cfg(test)]
250345
mod tests {
251346
use super::*;
@@ -310,6 +405,65 @@ mod tests {
310405
assert_eq!(NetworkUpgrade::Nu6.branch_id(), 0xc8e71055);
311406
}
312407

408+
#[test]
409+
fn test_ironwood_pczt_and_v6_params_roundtrip() {
410+
use miniscript::bitcoin::psbt::Psbt;
411+
use miniscript::bitcoin::Transaction;
412+
413+
let tx = Transaction {
414+
version: miniscript::bitcoin::transaction::Version::non_standard(6),
415+
lock_time: miniscript::bitcoin::locktime::absolute::LockTime::ZERO,
416+
input: vec![],
417+
output: vec![],
418+
};
419+
let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
420+
421+
assert_eq!(get_ironwood_pczt(&psbt), None);
422+
assert_eq!(get_zec_v6_params(&psbt), None);
423+
424+
set_ironwood_pczt(&mut psbt, vec![1, 2, 3, 4]);
425+
set_zec_v6_params(&mut psbt, 0xD884B698, 42);
426+
427+
assert_eq!(get_ironwood_pczt(&psbt), Some(vec![1, 2, 3, 4]));
428+
assert_eq!(get_zec_v6_params(&psbt), Some((0xD884B698, 42)));
429+
430+
// Overwrite semantics.
431+
set_ironwood_pczt(&mut psbt, vec![9]);
432+
set_zec_v6_params(&mut psbt, 0xD884B698, 0);
433+
assert_eq!(get_ironwood_pczt(&psbt), Some(vec![9]));
434+
// expiry_height == 0 is a valid value, distinct from "absent".
435+
assert_eq!(get_zec_v6_params(&psbt), Some((0xD884B698, 0)));
436+
437+
// These keys live under the private BITGO_ZEC_V6 namespace, not the shared BITGO space.
438+
for key in psbt.proprietary.keys() {
439+
assert_eq!(key.prefix, BITGO_ZEC_V6);
440+
}
441+
}
442+
443+
#[test]
444+
fn test_zec_v6_consensus_branch_id_roundtrip() {
445+
use miniscript::bitcoin::psbt::Psbt;
446+
use miniscript::bitcoin::Transaction;
447+
448+
let tx = Transaction {
449+
version: miniscript::bitcoin::transaction::Version::non_standard(6),
450+
lock_time: miniscript::bitcoin::locktime::absolute::LockTime::ZERO,
451+
input: vec![],
452+
output: vec![],
453+
};
454+
let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
455+
456+
assert_eq!(get_zec_v6_consensus_branch_id(&psbt), None);
457+
458+
set_zec_v6_consensus_branch_id(&mut psbt, 0x736b_bdac);
459+
assert_eq!(get_zec_v6_consensus_branch_id(&psbt), Some(0x736b_bdac));
460+
461+
// Coexists with the legacy (v4) key at the same subtype 0x00, disambiguated by prefix.
462+
set_zec_consensus_branch_id(&mut psbt, 0xc2d6_d0b4);
463+
assert_eq!(get_zec_consensus_branch_id(&psbt), Some(0xc2d6_d0b4));
464+
assert_eq!(get_zec_v6_consensus_branch_id(&psbt), Some(0x736b_bdac));
465+
}
466+
313467
#[test]
314468
fn test_version_info_serialization() {
315469
let version_info =

0 commit comments

Comments
 (0)