feat(wasm-utxo): wire v6 (Ironwood) build/sign/combine into ZcashBitG… - #342
Conversation
…rent) Extend the v6 (Ironwood/NU6.3) codec with the two ZIP-244 signature-hash computations needed to sign a v6 transaction. Previously the codec could only compute the ZIP-244 txid. - `compute_v6_sig_digest` — shielded SIGHASH_ALL digest (the message the Ironwood binding signature signs). - `compute_v6_transparent_sighash` — per-input transparent SIGHASH_ALL digest, folding in the ironwood_digest. Both share a single ZIP-244 §S.2 core that differs only in the per-input (txin) sub-digest: empty for a shielded signature, populated for the transparent input being signed. Adds the `ZTxTrAmountsHash`, `ZTxTrScriptsHash`, and `Zcash___TxInHash` personalizations, matching the `zcash_primitives` reference (amounts/scripts use the count-less `Array` encoding; txid sub-digests stay plain concatenation). Tests: - A golden oracle that verifies the REAL ECDSA signature of the signed `v6_shield1zec` transparent input against `compute_v6_transparent_sighash` (independent: the signature was produced by an external signer). Proves the transparent digest is byte-correct against a real signed v6 tx. - Regression coverage for determinism, that the digest commits to input amounts/scriptPubKeys, transparent-vs-shielded difference, per-input variation, and the no-transparent-inputs fallback (== txid). No new dependencies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…zation Introduce the `orchard` crate WITHOUT the `circuit` feature (Sinsemilla / pasta / note-encryption only — no halo2 prover) plus `rand`, `postcard`, and `ff`, and add the `zcash::ironwood_pczt` module that (de)serializes an `orchard::pczt::Bundle`. `orchard::pczt::Bundle` is not serde-serializable, so this bridges it to a compact wire form: a 1-byte format version + a `postcard`-encoded mirror struct whose fields are exactly what orchard's `Bundle/Action/Spend/ Output::parse(...)` consume. Serialize reads the bundle's public getters; deserialize reconstructs via `parse(...)`. This is the witness payload exchanged with the external Ironwood proof service and carried through the PSBT (proving is delegated; no halo2 in wasm-utxo). Notes: - `>32`-byte fields are stored as `Vec<u8>` (serde derives arrays only up to length 32) and length-checked on the way back in. - `note_version` is derived from the bundle version rather than carried on the wire; `zip32_derivation` (wallet metadata the prover doesn't need) is dropped. - `serde` is now a non-optional dependency (the module derives Serialize/Deserialize); the `inspect` feature drops its `dep:serde`. Verified: compiles for wasm32-unknown-unknown and native; a build -> serialize -> deserialize -> re-serialize round-trip is byte-stable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `zcash::ironwood_build`, the bridge between the `orchard` PCZT roles and the v6 `IronwoodBundle` wire type, for building transparent -> Ironwood shielding transactions without linking the halo2 prover: - `construct_shield_pczt` (Constructor): one output note + dummy spend as an `orchard::pczt::Bundle`, action data fixed, no proof/sigs. - `finalize_shield_io` (IO Finalizer/Signer): derive `bsk`, sign dummy spends over the shielded sighash. - `pczt_action_data`: effects-only view (commitments/ciphertexts/flags/value/ anchor) for computing the ZIP-244 sighash and txid. - `combine` (Transaction Extractor): given the signed+proven PCZT, verify spend-auth sigs, apply the binding signature, map to an encodable `IronwoodBundle`. The Prover role is delegated to an external service; its proof bytes are spliced into the serialized PCZT via `ironwood_pczt::with_zkproof`. Tests (all no-circuit): build->finalize->combine produces an encodable v6 tx with a stable txid; the combined tx cross-checks against zebra-chain; and a golden oracle reconstructs the orchard bundle from the real on-chain `shield1zec` action data and verifies both the real dummy spend-auth signature and the real binding signature against `compute_v6_sig_digest` — the shielded twin of PR1's transparent sighash oracle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oPsbt 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. 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 and assert its v6_transparent_sighash both equals the codec-golden sighash and verifies the transaction's real ECDSA signature — proving the PSBT threads the true spent-output value + scriptCode into the sighash (a path the synthetic and zebra checks cannot cover). - propkv round-trip unit test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| /// Serialized Zcash Ironwood (v6) PCZT bundle carried through the PSBT: action data + | ||
| /// witness at build time, later carrying the externally-generated `zkproof`. | ||
| ZecIronwoodPczt = 0x07, | ||
| /// Zcash v6 (Ironwood) header params that are not derivable from the transparent skeleton: | ||
| /// 8 bytes = versionGroupId (u32 LE) ‖ expiryHeight (u32 LE). Its presence marks a PSBT as v6. | ||
| ZecV6Params = 0x08, |
There was a problem hiding this comment.
this list is becoming a little disorganized, I wonder if we should namespace it somehow and put all the zcash thing under the same prefix
There was a problem hiding this comment.
Strong +1 on namespacing — and there's a concrete reason it matters here: the subtype is a hard-limited subspace. In rust-bitcoin 0.32 (bitcoin-0.32.8-bitgo.2), ProprietaryKey<Subtype = u8> is bounded Subtype: Copy + From<u8> + Into<u8> and the subtype serializes as a single byte, so every BitGo key — MuSig2, PayGo, BIP322, WasmUtxo, and now Zcash — is competing for the same 256-value BITGO space. We can't widen the subtype without hand-rolling raw PSBT keys and abandoning ProprietaryKey. The right lever is the other field: the identifier prefix, which is an arbitrary Vec<u8>. Giving a domain its own prefix hands it a fresh, private 0x00–0xFF subtype space.
Proposal for this PR — put the v6 keys under a dedicated prefix and revert the two additions to the shared enum:
/// Zcash v6 (Ironwood) proprietary namespace — its own private subtype space,
/// so v6 keys don't consume slots in the shared BITGO space.
pub const BITGO_ZEC_V6: &[u8] = b"BITGO/ZEC/V6";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum ZecV6KeySubtype {
ConsensusBranchId = 0x00,
IronwoodPczt = 0x01,
V6Params = 0x02,
}ZecIronwoodPczt/ZecV6Params(0x07/0x08) come back out ofProprietaryKeySubtype— they were added in this PR and never shipped, so there's nothing to migrate; they move toBITGO/ZEC/V6at 0x01/0x02.- The v6 consensus branch id moves under the namespace too (
BITGO/ZEC/V6/ 0x00), read/written by v6-only accessors. - v4 is untouched. The shipped
ZecConsensusBranchId = 0x00under the legacyBITGOprefix stays exactly as-is — v4 and v6 are distinct construction paths, so no dual-read or migration is needed for in-the-wild v4 PSBTs.
Note the two 0x00 branch-id keys are unambiguous because the prefix differs: BITGO/0x00 (v4) vs BITGO/ZEC/V6/0x00 (v6). Keeping a separate ZecV6KeySubtype enum (rather than extending ProprietaryKeySubtype) keeps the two subtype spaces from being looked up against the wrong prefix.
Happy to push this as a follow-up commit if you'd like it in before merge.
Generated by Claude Code
There was a problem hiding this comment.
Addendum: once the v6 keys have their own subtype space, it's worth splitting ZecV6Params (the packed versionGroupId ‖ expiryHeight blob) into two single-field keys. Spending a subtype per field is free under the namespace, and each accessor becomes a self-describing u32 instead of manual offset-slicing + a len() == 8 guard:
pub enum ZecV6KeySubtype {
ConsensusBranchId = 0x00,
IronwoodPczt = 0x01,
VersionGroupId = 0x02,
ExpiryHeight = 0x03,
}
fn set_u32(psbt: &mut Psbt, st: ZecV6KeySubtype, v: u32) {
set_zec(psbt, st, v.to_le_bytes().to_vec());
}
fn get_u32(psbt: &Psbt, st: ZecV6KeySubtype) -> Option<u32> {
let v = get_zec(psbt, st)?;
Some(u32::from_le_bytes(v.as_slice().try_into().ok()?))
}No more [0..4]/[4..8] slicing or packed-length check, and a future header field is just another subtype rather than a repack of a growing blob.
Two things to keep right when splitting:
- What marks a PSBT as v6. Today it's "
ZecV6Paramspresent." After the split, pick one field as the marker —VersionGroupIdis the natural one (the Ironwood VGID), sodeserialize_v6should key off its presence, not expiry. - Always write both fields, even when expiry is 0.
expiry_height == 0("no expiry") is a valid value; the packed blob stores it explicitly, sosetmust write theExpiryHeightkey unconditionally — otherwise you can't tell "0" from "absent" on read.
Minor: two keys repeat the ~12-byte prefix, so it's ~15 bytes larger on the wire than the packed form — negligible.
Aside: since VersionGroupId is constant for Ironwood v6, you could drop it and let ConsensusBranchId's presence under the v6 prefix be the marker — but storing it is cheap and leaves room for a future non-Ironwood v6 variant, so I'd keep it.
Generated by Claude Code
OttoAllmendinger
left a comment
There was a problem hiding this comment.
Review
(Namespacing of the proprietary keys is covered in the propkv.rs thread — this covers the rest.)
Overview
Wires the Ironwood PCZT bridge into ZcashBitGoPsbt: proprietary subtypes for the shielded state, the v6 constructor/txid/sighash/sign/combine methods, and plain-PSBT serialize/deserialize that round-trips the v6 state without the v4 tx-replacement dance. Two strong tests — a synthetic end-to-end (build→sign→combine + zebra cross-check) and a PSBT-level golden oracle verifying the real shield1zec signature against the PSBT-derived sighash.
✅ Correction to my #341 finding (bsk leak) — the production flow is safe
Reviewing combine_ironwood_proof here resolves the bsk concern I raised on #339/#341. The real flow is: add_ironwood_output stores the Constructor PCZT (no bsk); the external prover receives that; then combine_ironwood_proof(proof) runs finalize_shield_io (derives bsk) → with_zkproof → combine all locally, after the proof returns. So bsk is created and consumed inside one local call and is never serialized into anything sent to the prover. The leak-prone sequence I flagged was inferred from #341's test ordering (finalize → serialize → hand to prover → combine); the actual consumer inverts that safely. Recommend aligning the #341 test/docstring to this order so the bridge doesn't read as if bsk ships to the prover. Net: I'd downgrade that finding from "security" to "tighten the #341 example."
Issues
1. (Correctness) finalized_transparent_tx doesn't enforce the 2-of-3 threshold.
It emits OP_0 <sig>… <redeemScript> from whatever partial_sigs happen to be present. With only one signature collected it silently produces an incomplete, unspendable scriptSig rather than erroring — the failure only surfaces at broadcast. Worth asserting the required number of sigs per input (≥ m) before assembling.
2. (API nit) combine_ironwood_proof ignores its rng for the binding signature.
The rng: R param is moved into finalize_shield_io, then the binding signature hardcodes rand::rngs::OsRng (per the comment). A caller passing a seeded RNG for reproducibility still gets OS-sourced binding-sig randomness. Consider taking &mut R and reusing it, or documenting that binding randomness is always OS-sourced.
Minor
get_zec_v6_paramscorrectly length-checks (== 8) and overwrite semantics are tested — good. (If you take the split-into-two-keys suggestion from the propkv thread, this check goes away entirely.)- The golden test overloads
redeem_scriptto carry a P2PKH scriptPubKey as the scriptCode — fine for the test, but a one-line comment noting it's a P2PKH stand-in (not a real P2SH redeem script) would prevent confusion.
Praise
add_v6_transparent_signature verifying each signature against the sighash before inserting is a nice guardrail; the PSBT-level golden oracle genuinely closes a gap the synthetic test can't (a real external signature over the true spent-output value/script); and the plain-PSBT round-trip via a marker proprietary key is clean.
Generated by Claude Code
…oPsbt
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:
BitGoPsbt::new_zcash_v6_at_height: a builder so transparent inputs/outputs use the existing add_wallet_input / add_wallet_output machinery.
Tests (native):