From 29230c2f353dbc56f8c5334436905db01b716544 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 28 Jul 2026 11:47:17 +0530 Subject: [PATCH] feat(wasm-utxo): add orchard (no-circuit) dep + Ironwood PCZT serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` (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 --- packages/wasm-utxo/Cargo.lock | 53 +- packages/wasm-utxo/Cargo.toml | 18 +- packages/wasm-utxo/deny.toml | 9 +- packages/wasm-utxo/src/zcash/ironwood_pczt.rs | 461 ++++++++++++++++++ packages/wasm-utxo/src/zcash/mod.rs | 2 + 5 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 packages/wasm-utxo/src/zcash/ironwood_pczt.rs diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 09213c85afe..694b57cb095 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -524,6 +524,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -868,7 +877,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -960,6 +969,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -993,7 +1014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1709,7 +1730,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2001,6 +2022,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2083,9 +2116,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", @@ -2333,7 +2366,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2739,7 +2772,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3166,13 +3199,17 @@ dependencies = [ "bech32", "blake2", "crossbeam-epoch", + "ff", "getrandom 0.2.16", "hex", "js-sys", "miniscript", "musig2", "num-bigint", + "orchard", "pastey", + "postcard", + "rand", "rstest", "serde", "serde_json", @@ -3214,7 +3251,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index efe766e9b0c..880a9cbd639 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -22,7 +22,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [ [features] default = [] -inspect = ["dep:num-bigint", "dep:serde", "dep:serde_json", "dep:hex"] +inspect = ["dep:num-bigint", "dep:serde_json", "dep:hex"] [dependencies] wasm-bindgen = "0.2" @@ -37,10 +37,24 @@ musig2 = { version = "0.3.1", default-features = false, features = ["k256"] } getrandom = { version = "0.2", features = ["js"] } pastey = "0.1" num-bigint = { version = "0.4", optional = true } -serde = { version = "1.0", features = ["derive"], optional = true } +# serde is non-optional: the Ironwood PCZT serialization (always compiled) derives Serialize/ +# Deserialize for its postcard mirror struct. +serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", optional = true } hex = { version = "0.4", optional = true } +# Orchard/Ironwood shielded-bundle construction (PCZT Constructor/Signer/Extractor). +# `default-features = false` DROPS the `circuit` feature, so halo2_proofs/halo2_gadgets +# (the heavy prover) are NOT pulled in — only Sinsemilla/pasta/note-encryption. Proving is +# delegated to an external service. +orchard = { version = "0.15", default-features = false, features = ["std"] } +rand = "0.8" +# Compact deterministic encoding of the Ironwood PCZT witness (proof-service payload / PSBT carry). +postcard = { version = "1.0", default-features = false, features = ["use-std"] } +# `ff::PrimeField::to_repr` for the one PCZT witness field (`alpha`, a Pallas scalar) that orchard +# exposes only as a curve scalar. Already in-tree via orchard/pasta_curves. +ff = "0.13" + # Pinned to avoid RUSTSEC-2026-0204 (invalid pointer dereference in fmt::Pointer) crossbeam-epoch = ">=0.9.20" diff --git a/packages/wasm-utxo/deny.toml b/packages/wasm-utxo/deny.toml index 53029c6bbdd..b16fa95048c 100644 --- a/packages/wasm-utxo/deny.toml +++ b/packages/wasm-utxo/deny.toml @@ -4,7 +4,13 @@ multiple-versions = "deny" # Highlight bech32 specifically (ensures it's caught if duplicated) highlight = "all" # Skip hex-conservative duplicate (miniscript uses 1.0.1 directly, bitcoin uses 0.2.2 via bitcoin_hashes) -skip = [{ crate = "hex-conservative@0.2.2" }] +# Skip thiserror/thiserror-impl 1.x duplicate (reddsa/redjubjub pin 1.x; postcard's cobs and +# zcash_script/zebra-chain deps use 2.x) - both are transitive, no shared version to unify on. +skip = [ + { crate = "hex-conservative@0.2.2" }, + { crate = "thiserror@1.0.69" }, + { crate = "thiserror-impl@1.0.69" }, +] # Allow git sources (needed for miniscript) [sources] @@ -21,6 +27,7 @@ allow = [ "Unicode-3.0", "BSD-3-Clause", "Unlicense", + "BSD-2-Clause", ] # Clarify license for unlicensed crate [[licenses.clarify]] diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs new file mode 100644 index 00000000000..792f542bf2c --- /dev/null +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -0,0 +1,461 @@ +//! Serialization of an `orchard` **PCZT** (Partially Constructed Zcash Transaction) Ironwood +//! bundle — the witness payload exchanged with the external Ironwood proof service and carried +//! through the PSBT. +//! +//! `orchard::pczt::Bundle` is not itself serde-serializable, so this module bridges it to a +//! compact wire form: a 1-byte [`FORMAT_VERSION`] followed by 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 it via +//! `parse(...)`. Both `wasm-utxo` (build/combine) and the proof service (prove) use this format; +//! see `docs/ironwood-proof-service-contract.md`. +//! +//! Fields orchard exposes only as `>32`-byte arrays are stored as `Vec` (serde derives arrays +//! only up to length 32) and length-checked on the way back in. `zip32_derivation` is not a +//! `parse()` input and is intentionally dropped — the prover does not need it. + +use std::collections::BTreeMap; + +use ff::PrimeField; +use serde::{Deserialize, Serialize}; + +use orchard::bundle::BundleVersion; +use orchard::note::{NoteVersion, Nullifier}; +use orchard::pczt::{ + Action as PcztAction, Bundle as PcztBundle, Output as PcztOutput, Spend as PcztSpend, +}; +use orchard::value::Sign; +use orchard::{ProtocolVersion, ValuePool}; + +/// Wire format version; bump on any layout change (deserialize rejects unknown versions). +pub const FORMAT_VERSION: u8 = 0x01; + +/// Errors from Ironwood PCZT (de)serialization. +#[derive(Debug, strum::IntoStaticStr)] +pub enum IronwoodPcztError { + /// The `postcard` payload could not be encoded/decoded. + Codec(String), + /// Unknown/unsupported [`FORMAT_VERSION`] byte. + UnsupportedVersion(u8), + /// Empty input (no version byte). + Empty, + /// A byte field had the wrong length: (field, expected, actual). + BadLength(&'static str, usize, usize), + /// An unrepresentable (value_pool, protocol_version) combination. + BadBundleVersion(u8, u8), + /// A field that must decode to a valid curve/commitment value did not. + BadFieldEncoding(&'static str), + /// orchard rejected the reconstructed bundle. + Parse(String), +} + +impl core::fmt::Display for IronwoodPcztError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Codec(e) => write!(f, "ironwood-pczt: postcard codec error: {e}"), + Self::UnsupportedVersion(v) => { + write!(f, "ironwood-pczt: unsupported format version {v}") + } + Self::Empty => write!(f, "ironwood-pczt: empty input"), + Self::BadLength(field, exp, act) => { + write!( + f, + "ironwood-pczt: field {field} must be {exp} bytes, got {act}" + ) + } + Self::BadBundleVersion(vp, pv) => { + write!( + f, + "ironwood-pczt: unrepresentable bundle version (pool {vp}, protocol {pv})" + ) + } + Self::BadFieldEncoding(field) => { + write!(f, "ironwood-pczt: invalid encoding for {field}") + } + Self::Parse(e) => write!(f, "ironwood-pczt: orchard rejected the bundle: {e}"), + } + } +} + +crate::impl_wasm_error_code!(IronwoodPcztError); + +// ---- Mirror structs (serde ⇄ postcard) ---- + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct BundleWire { + flags: u8, + value_pool: u8, // 0 = Orchard, 1 = Ironwood + protocol_version: u8, // 0 = InsecureV1, 1 = V2, 2 = V3 + value_sum_magnitude: u64, + value_sum_negative: bool, + anchor: [u8; 32], + zkproof: Option>, + bsk: Option<[u8; 32]>, + actions: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct ActionWire { + cv_net: [u8; 32], + rcv: Option<[u8; 32]>, + spend: SpendWire, + output: OutputWire, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct SpendWire { + nullifier: [u8; 32], + rk: [u8; 32], + spend_auth_sig: Option>, // 64 + recipient: Option>, // 43 + value: Option, + rho: Option<[u8; 32]>, + rseed: Option<[u8; 32]>, + fvk: Option>, // 96 + witness: Option, + alpha: Option<[u8; 32]>, + dummy_sk: Option<[u8; 32]>, + proprietary: BTreeMap>, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct WitnessWire { + position: u32, + auth_path: Vec<[u8; 32]>, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct OutputWire { + cmx: [u8; 32], + ephemeral_key: [u8; 32], + enc_ciphertext: Vec, // 580 + out_ciphertext: Vec, // 80 + recipient: Option>, + value: Option, + rseed: Option<[u8; 32]>, + ock: Option<[u8; 32]>, + user_address: Option, + proprietary: BTreeMap>, +} + +// ---- Public API ---- + +/// Serialize an `orchard` PCZT Ironwood bundle to its wire form. +pub fn serialize_pczt(bundle: &PcztBundle) -> Result, IronwoodPcztError> { + let wire = bundle_to_wire(bundle); + let mut out = Vec::with_capacity(1 + 4096); + out.push(FORMAT_VERSION); + let body = postcard::to_stdvec(&wire).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + out.extend_from_slice(&body); + Ok(out) +} + +/// Reconstruct an `orchard` PCZT Ironwood bundle from its wire form. +pub fn deserialize_pczt(bytes: &[u8]) -> Result { + let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?; + if version != FORMAT_VERSION { + return Err(IronwoodPcztError::UnsupportedVersion(version)); + } + let wire: BundleWire = + postcard::from_bytes(body).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + wire_to_bundle(wire) +} + +// ---- orchard bundle -> wire ---- + +fn bundle_to_wire(bundle: &PcztBundle) -> BundleWire { + let (magnitude, sign) = bundle.value_sum().magnitude_sign(); + BundleWire { + flags: bundle.flag_byte(), + value_pool: match bundle.bundle_version().value_pool() { + ValuePool::Orchard => 0, + ValuePool::Ironwood => 1, + }, + protocol_version: match bundle.bundle_version().protocol_version() { + ProtocolVersion::InsecureV1 => 0, + ProtocolVersion::V2 => 1, + ProtocolVersion::V3 => 2, + }, + value_sum_magnitude: magnitude, + value_sum_negative: matches!(sign, Sign::Negative), + anchor: bundle.anchor().to_bytes(), + zkproof: bundle.zkproof().as_ref().map(|p| p.as_ref().to_vec()), + bsk: bundle.bsk().as_ref().map(<[u8; 32]>::from), + actions: bundle.actions().iter().map(action_to_wire).collect(), + } +} + +fn action_to_wire(a: &PcztAction) -> ActionWire { + ActionWire { + cv_net: a.cv_net().to_bytes(), + rcv: a.rcv().as_ref().map(|r| r.to_bytes()), + spend: spend_to_wire(a.spend()), + output: output_to_wire(a.output()), + } +} + +fn spend_to_wire(s: &PcztSpend) -> SpendWire { + SpendWire { + nullifier: s.nullifier().to_bytes(), + rk: <[u8; 32]>::from(s.rk()), + spend_auth_sig: s + .spend_auth_sig() + .as_ref() + .map(|sig| <[u8; 64]>::from(sig).to_vec()), + recipient: s + .recipient() + .as_ref() + .map(|a| a.to_raw_address_bytes().to_vec()), + value: s.value().as_ref().map(|v| v.inner()), + rho: s.rho().as_ref().map(|r| r.to_bytes()), + rseed: s.rseed().as_ref().map(|r| *r.as_bytes()), + fvk: s.fvk().as_ref().map(|f| f.to_bytes().to_vec()), + witness: s.witness().as_ref().map(|w| WitnessWire { + position: w.position(), + auth_path: w.auth_path().iter().map(|h| h.to_bytes()).collect(), + }), + alpha: s.alpha().as_ref().map(|a| a.to_repr()), + dummy_sk: s.dummy_sk().as_ref().map(|k| *k.to_bytes()), + proprietary: s.proprietary().clone(), + } +} + +fn output_to_wire(o: &PcztOutput) -> OutputWire { + let enc = o.encrypted_note(); + OutputWire { + cmx: o.cmx().to_bytes(), + ephemeral_key: enc.epk_bytes, + enc_ciphertext: enc.enc_ciphertext.to_vec(), + out_ciphertext: enc.out_ciphertext.to_vec(), + recipient: o + .recipient() + .as_ref() + .map(|a| a.to_raw_address_bytes().to_vec()), + value: o.value().as_ref().map(|v| v.inner()), + rseed: o.rseed().as_ref().map(|r| *r.as_bytes()), + ock: o.ock().as_ref().map(|k| k.0), + user_address: o.user_address().clone(), + proprietary: o.proprietary().clone(), + } +} + +// ---- wire -> orchard bundle ---- + +fn arr(v: &[u8], field: &'static str) -> Result<[u8; N], IronwoodPcztError> { + v.try_into() + .map_err(|_| IronwoodPcztError::BadLength(field, N, v.len())) +} + +fn wire_to_bundle(w: BundleWire) -> Result { + let bundle_version = match (w.value_pool, w.protocol_version) { + (0, 0) => BundleVersion::orchard_insecure_v1(), + (0, 1) => BundleVersion::orchard_v2(), + (0, 2) => BundleVersion::orchard_v3(), + (1, 2) => BundleVersion::ironwood_v3(), + (vp, pv) => return Err(IronwoodPcztError::BadBundleVersion(vp, pv)), + }; + // The per-note version is fixed by the bundle version (Orchard→V2, Ironwood→V3); it is not + // carried on the wire. + let note_version = bundle_version.note_version(); + + let actions = w + .actions + .into_iter() + .map(|a| wire_to_action(a, note_version)) + .collect::, _>>()?; + + PcztBundle::parse( + actions, + w.flags, + bundle_version, + (w.value_sum_magnitude, w.value_sum_negative), + w.anchor, + w.zkproof, + w.bsk, + ) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +fn wire_to_action( + a: ActionWire, + note_version: NoteVersion, +) -> Result { + // The output note's rho is the paired spend's nullifier. + let spend_nullifier = Option::::from(Nullifier::from_bytes(&a.spend.nullifier)) + .ok_or(IronwoodPcztError::BadFieldEncoding("nullifier"))?; + let spend = wire_to_spend(a.spend, note_version)?; + let output = wire_to_output(a.output, spend_nullifier, note_version)?; + PcztAction::parse(a.cv_net, spend, output, a.rcv) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +fn wire_to_spend(s: SpendWire, note_version: NoteVersion) -> Result { + let spend_auth_sig = s + .spend_auth_sig + .as_deref() + .map(|v| arr::<64>(v, "spend_auth_sig")) + .transpose()?; + let recipient = s + .recipient + .as_deref() + .map(|v| arr::<43>(v, "spend.recipient")) + .transpose()?; + let fvk = s.fvk.as_deref().map(|v| arr::<96>(v, "fvk")).transpose()?; + let witness = s + .witness + .map(|w| -> Result<_, IronwoodPcztError> { + let path: [[u8; 32]; 32] = w + .auth_path + .try_into() + .map_err(|_| IronwoodPcztError::BadFieldEncoding("witness.auth_path"))?; + Ok((w.position, path)) + }) + .transpose()?; + + PcztSpend::parse( + s.nullifier, + s.rk, + spend_auth_sig, + recipient, + s.value, + s.rho, + s.rseed, + fvk, + witness, + s.alpha, + None, // zip32_derivation: wallet metadata, not needed by the prover + s.dummy_sk, + note_version, + s.proprietary, + ) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +fn wire_to_output( + o: OutputWire, + spend_nullifier: Nullifier, + note_version: NoteVersion, +) -> Result { + let recipient = o + .recipient + .as_deref() + .map(|v| arr::<43>(v, "output.recipient")) + .transpose()?; + let enc_ciphertext = arr::<580>(&o.enc_ciphertext, "enc_ciphertext")?; + let out_ciphertext = arr::<80>(&o.out_ciphertext, "out_ciphertext")?; + + PcztOutput::parse( + spend_nullifier, + o.cmx, + o.ephemeral_key, + enc_ciphertext.to_vec(), + out_ciphertext.to_vec(), + recipient, + o.value, + o.rseed, + o.ock, + None, // zip32_derivation + o.user_address, + note_version, + o.proprietary, + ) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use orchard::builder::{Builder, BundleType}; + use orchard::keys::{FullViewingKey, Scope, SpendingKey}; + use orchard::tree::Anchor; + use orchard::value::NoteValue; + use rand::rngs::OsRng; + + /// Construct a shielding PCZT (one Ironwood output, dummy spend) via the orchard Constructor. + fn sample_pczt() -> PcztBundle { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let bundle_version = BundleVersion::ironwood_v3(); + let flags = bundle_version.default_flags(); + let mut builder = Builder::new( + BundleType::UNPADDED, + bundle_version, + flags, + Anchor::empty_tree(), + ) + .unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(100_000_000), + [0u8; 512], + ) + .unwrap(); + builder.build_for_pczt(OsRng).unwrap().0 + } + + #[test] + fn bundle_roundtrip_is_byte_stable() { + let bundle = sample_pczt(); + let bytes = serialize_pczt(&bundle).unwrap(); + assert_eq!(bytes[0], FORMAT_VERSION); + + // Byte-stability alone can't catch a field that's dropped symmetrically by both + // directions, so also assert against sample_pczt()'s known values. + let wire: BundleWire = postcard::from_bytes(&bytes[1..]).unwrap(); + assert_eq!(wire.anchor, Anchor::empty_tree().to_bytes()); + let bundle_version = BundleVersion::ironwood_v3(); + assert_eq!( + wire.flags, + bundle_version + .default_flags() + .to_byte(bundle_version) + .unwrap() + ); + assert_eq!(wire.actions.len(), 1); + assert_eq!(wire.value_sum_magnitude, 100_000_000); + assert!(wire.value_sum_negative); + assert_ne!(wire.actions[0].output.cmx, [0u8; 32]); + + // Reconstruct, re-serialize: the second encoding must be byte-identical, proving the + // orchard <-> wire mapping round-trips losslessly. + let bundle2 = deserialize_pczt(&bytes).unwrap(); + let bytes2 = serialize_pczt(&bundle2).unwrap(); + assert_eq!(bytes, bytes2, "serialize∘deserialize is byte-stable"); + + // And a third pass, for good measure. + let bundle3 = deserialize_pczt(&bytes2).unwrap(); + assert_eq!(serialize_pczt(&bundle3).unwrap(), bytes); + } + + #[test] + fn rejects_unknown_version() { + let bundle = sample_pczt(); + let mut bytes = serialize_pczt(&bundle).unwrap(); + bytes[0] = 0xff; + assert!(matches!( + deserialize_pczt(&bytes), + Err(IronwoodPcztError::UnsupportedVersion(0xff)) + )); + } + + #[test] + fn rejects_empty() { + assert!(matches!( + deserialize_pczt(&[]), + Err(IronwoodPcztError::Empty) + )); + } + + #[test] + fn rejects_malformed_body() { + let bundle = sample_pczt(); + let bytes = serialize_pczt(&bundle).unwrap(); + let truncated = &bytes[..bytes.len() / 2]; + assert!(matches!( + deserialize_pczt(truncated), + Err(IronwoodPcztError::Codec(_)) + )); + } +} diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 3bf672cbb0c..f61bb67c739 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -10,6 +10,8 @@ //! Tests verify parity with `zebra-chain` crate. pub mod blake2b; +/// Serialization of the orchard PCZT Ironwood bundle (proof-service payload / PSBT carry). +pub mod ironwood_pczt; pub mod transaction; pub mod unified_address; pub mod v6;