Skip to content

feat(wasm-utxo): wire v6 (Ironwood) build/sign/combine into ZcashBitG… - #342

Draft
veetragjain wants to merge 4 commits into
veetragjain/cshld-1293-wasm-utxo-implement-ironwood-pczt-constructor-combine-bridgefrom
veetragjain/cshld-1294-wasm-utxo-wire-v6-ironwood-buildsigncombine-into
Draft

feat(wasm-utxo): wire v6 (Ironwood) build/sign/combine into ZcashBitG…#342
veetragjain wants to merge 4 commits into
veetragjain/cshld-1293-wasm-utxo-implement-ironwood-pczt-constructor-combine-bridgefrom
veetragjain/cshld-1294-wasm-utxo-wire-v6-ironwood-buildsigncombine-into

Conversation

@veetragjain

Copy link
Copy Markdown
Contributor

…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.

veetragjain and others added 4 commits July 28, 2026 11:07
…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>
@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

CSHLD-1294

Comment on lines +46 to +51
/// 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of ProprietaryKeySubtype — they were added in this PR and never shipped, so there's nothing to migrate; they move to BITGO/ZEC/V6 at 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 = 0x00 under the legacy BITGO prefix 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. What marks a PSBT as v6. Today it's "ZecV6Params present." After the split, pick one field as the marker — VersionGroupId is the natural one (the Ironwood VGID), so deserialize_v6 should key off its presence, not expiry.
  2. Always write both fields, even when expiry is 0. expiry_height == 0 ("no expiry") is a valid value; the packed blob stores it explicitly, so set must write the ExpiryHeight key 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 OttoAllmendinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_zkproofcombine 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_params correctly 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_script to 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants