diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index e9b6e5f224c..d5c7f744e1f 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3208,6 +3208,7 @@ dependencies = [ "nonempty", "num-bigint", "orchard", + "pasta_curves", "pastey", "postcard", "rand", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index e0804815496..823a3722d00 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -85,6 +85,10 @@ hex = "0.4" wasm-bindgen-test = "0.3" rstest = "0.26.1" pastey = "0.1" +# Pallas field arithmetic, used only in the ironwood_build witness tests to synthesize arbitrary +# canonical field elements (leaf/sibling hashes) without needing a real note-commitment tree. +# Version pinned to match orchard 0.15's own dependency (see Cargo.lock). +pasta_curves = "0.5" [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] # 11.2 is the first release with the `Transaction::V6` / Ironwood (`ironwood_shielded_data`) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodWitness.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodWitness.ts new file mode 100644 index 00000000000..5e802100f5b --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodWitness.ts @@ -0,0 +1,51 @@ +import { + IronwoodWitness as WasmIronwoodWitness, + ironwood_build_witness, +} from "../wasm/wasm_utxo.js"; + +/** + * A validated Merkle witness for an Ironwood/Orchard note commitment. + * + * Build with {@link ZcashIronwoodWitness.build}: `cmx` is the note commitment being + * witnessed, `authPath` must be exactly 32 sibling hashes (32 bytes each, leaf-to-root + * order), and `anchor` is the expected note-commitment-tree root. Throws if any input + * isn't a canonical field element, or if the witness doesn't recompute to `anchor`. + * + * wasm-utxo has no chain state, so the raw sibling-hash path must be supplied by the + * caller (typically BitGo's backend, querying a Zcash-aware service). + */ +export class ZcashIronwoodWitness { + private constructor(private _wasm: WasmIronwoodWitness) {} + + /** + * Build and validate a Merkle witness for an Ironwood/Orchard note commitment. + * @throws If any input isn't a canonical field element, or the witness doesn't + * recompute to `anchor` + */ + static build( + cmx: Uint8Array, + position: number, + authPath: Uint8Array[], + anchor: Uint8Array, + ): ZcashIronwoodWitness { + return new ZcashIronwoodWitness(ironwood_build_witness(cmx, position, authPath, anchor)); + } + + /** The leaf's position in the note commitment tree. */ + get position(): number { + return this._wasm.position; + } + + /** + * The 32 sibling hashes (leaf-to-root order), flattened into a single 1024-byte array + * (32 hashes × 32 bytes each). + */ + get authPath(): Uint8Array { + return this._wasm.auth_path; + } + + /** @internal */ + get wasm(): WasmIronwoodWitness { + return this._wasm; + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index c77a48a8136..0b210393af7 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -62,6 +62,9 @@ export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js"; // Zcash v6 (Ironwood / NU6.3) transaction export { ZcashV6Transaction } from "./ZcashV6Transaction.js"; +// Zcash v6 (Ironwood / NU6.3) Merkle witness +export { ZcashIronwoodWitness } from "./ZcashIronwoodWitness.js"; + import type { ScriptType } from "./scriptType.js"; /** diff --git a/packages/wasm-utxo/src/error.rs b/packages/wasm-utxo/src/error.rs index 93c39010496..20ec919b96a 100644 --- a/packages/wasm-utxo/src/error.rs +++ b/packages/wasm-utxo/src/error.rs @@ -25,6 +25,7 @@ pub enum WasmUtxoError { Parse(ParseTransactionError), UnifiedAddress(crate::zcash::unified_address::UnifiedAddressError), ZcashV6(crate::zcash::v6::ZcashV6Error), + Ironwood(crate::zcash::ironwood_build::IronwoodBuildError), } impl std::error::Error for WasmUtxoError {} @@ -36,6 +37,7 @@ impl fmt::Display for WasmUtxoError { WasmUtxoError::Parse(e) => write!(f, "{}", e), WasmUtxoError::UnifiedAddress(e) => write!(f, "{}", e), WasmUtxoError::ZcashV6(e) => write!(f, "{}", e), + WasmUtxoError::Ironwood(e) => write!(f, "{}", e), } } } @@ -47,6 +49,7 @@ impl WasmErrorCode for WasmUtxoError { WasmUtxoError::Parse(e) => e.code(), WasmUtxoError::UnifiedAddress(e) => e.code(), WasmUtxoError::ZcashV6(e) => e.code(), + WasmUtxoError::Ironwood(e) => e.code(), } } } @@ -99,6 +102,12 @@ impl From for WasmUtxoError { } } +impl From for WasmUtxoError { + fn from(err: crate::zcash::ironwood_build::IronwoodBuildError) -> Self { + WasmUtxoError::Ironwood(err) + } +} + impl WasmUtxoError { pub fn new(s: &str) -> WasmUtxoError { WasmUtxoError::StringError(s.to_string()) diff --git a/packages/wasm-utxo/src/wasm/zcash.rs b/packages/wasm-utxo/src/wasm/zcash.rs index b73c8fab0bd..1d83c741e0c 100644 --- a/packages/wasm-utxo/src/wasm/zcash.rs +++ b/packages/wasm-utxo/src/wasm/zcash.rs @@ -42,6 +42,78 @@ pub fn zcash_ironwood_version_group_id() -> u32 { crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID } +/// A validated Merkle witness for an Ironwood/Orchard note commitment, from +/// [`ironwood_build_witness`]. +/// +/// Not yet installed anywhere — see [`ironwood_build_witness`]'s docs. +#[wasm_bindgen] +pub struct IronwoodWitness { + inner: crate::zcash::ironwood_build::IronwoodWitness, +} + +#[wasm_bindgen] +impl IronwoodWitness { + /// The leaf's position in the note commitment tree. + #[wasm_bindgen(getter)] + pub fn position(&self) -> u32 { + self.inner.position + } + + /// The 32 sibling hashes (leaf-to-root order), flattened into a single 1024-byte array + /// (32 hashes × 32 bytes each). + #[wasm_bindgen(getter)] + pub fn auth_path(&self) -> Vec { + self.inner.auth_path.concat() + } +} + +/// Build and validate a Merkle witness for an Ironwood/Orchard note commitment. +/// +/// `cmx` is the note commitment being witnessed; `auth_path` must be exactly 32 sibling hashes (32 +/// bytes each), leaf-to-root order; `anchor` is the expected note-commitment-tree root. Throws if +/// any input isn't a canonical field element, or if the witness doesn't recompute to `anchor`. +/// +/// This crate has no chain state, so the raw sibling-hash path must be supplied by the caller +/// (typically BitGo's backend, querying a Zcash-aware service). The returned witness is not yet +/// installed anywhere — hook it into a spend once real-spend construction exists. +#[wasm_bindgen] +pub fn ironwood_build_witness( + cmx: &[u8], + position: u32, + auth_path: Vec, + anchor: &[u8], +) -> Result { + use crate::zcash::ironwood_build::{build_ironwood_witness, IRONWOOD_MERKLE_DEPTH}; + + let cmx: [u8; 32] = cmx + .try_into() + .map_err(|_| WasmUtxoError::new(&format!("cmx must be 32 bytes, got {}", cmx.len())))?; + let anchor: [u8; 32] = anchor.try_into().map_err(|_| { + WasmUtxoError::new(&format!("anchor must be 32 bytes, got {}", anchor.len())) + })?; + if auth_path.len() != IRONWOOD_MERKLE_DEPTH { + return Err(WasmUtxoError::new(&format!( + "authPath must have exactly {} entries, got {}", + IRONWOOD_MERKLE_DEPTH, + auth_path.len() + ))); + } + let mut path = [[0u8; 32]; IRONWOOD_MERKLE_DEPTH]; + for (i, entry) in auth_path.iter().enumerate() { + let bytes = entry.to_vec(); + path[i] = bytes.try_into().map_err(|_| { + WasmUtxoError::new(&format!( + "authPath[{i}] must be 32 bytes, got {}", + entry.length() + )) + })?; + } + + Ok(IronwoodWitness { + inner: build_ironwood_witness(&cmx, position, &path, &anchor)?, + }) +} + /// A parsed ZIP-316 Unified Address. /// /// Decode once with [`ZcashUnifiedAddress::parse`], then read each component through diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 5146141d424..0432b727337 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -31,8 +31,9 @@ use rand::{CryptoRng, RngCore}; use orchard::builder::{Builder, BundleType}; use orchard::bundle::BundleVersion; use orchard::keys::OutgoingViewingKey; +use orchard::note::ExtractedNoteCommitment; use orchard::pczt::Bundle as PcztBundle; -use orchard::tree::Anchor; +use orchard::tree::{Anchor, MerkleHashOrchard, MerklePath}; use orchard::value::NoteValue; use orchard::{Action as OrchardAction, Address}; @@ -56,6 +57,21 @@ pub type OvkBytes = [u8; OVK_SIZE]; /// A ZIP-302 memo field. pub type MemoBytes = [u8; MEMO_SIZE]; +/// Merkle depth of the Ironwood/Orchard note commitment tree. +pub const IRONWOOD_MERKLE_DEPTH: usize = 32; + +/// Raw sibling hashes for one Merkle authentication path (leaf → anchor), caller-supplied, +/// leaf-to-root order. +pub type WitnessAuthPath = [[u8; 32]; IRONWOOD_MERKLE_DEPTH]; + +/// A validated Merkle witness for a note commitment, ready to be installed into a spend once +/// real-spend construction exists (see module docs — that wiring is out of scope for now). +#[derive(Debug, PartialEq)] +pub struct IronwoodWitness { + pub position: u32, + pub auth_path: WitnessAuthPath, +} + /// Errors produced while constructing or combining an Ironwood shielded bundle. /// /// The variant name is surfaced to JS as `err.code` (e.g. `"IronwoodBuildError.BadRecipient"`) @@ -96,6 +112,10 @@ pub enum IronwoodBuildError { BadKey(String), /// Local proof generation (`orchard-proving` feature) failed. Prove(String), + /// A witness `cmx` or `auth_path` entry is not a canonical Pallas field element. + BadWitnessPath, + /// The witness recomputed a root that does not match the expected anchor. + WitnessAnchorMismatch, } impl core::fmt::Display for IronwoodBuildError { @@ -132,6 +152,15 @@ impl core::fmt::Display for IronwoodBuildError { ), Self::BadKey(e) => write!(f, "ironwood-build: invalid key: {e}"), Self::Prove(e) => write!(f, "ironwood-build: proof generation failed: {e}"), + Self::BadWitnessPath => write!( + f, + "ironwood-build: invalid witness path (cmx or an auth_path entry is not a \ + canonical field element)" + ), + Self::WitnessAnchorMismatch => write!( + f, + "ironwood-build: witness path does not recompute to the expected anchor" + ), } } } @@ -226,6 +255,48 @@ pub fn construct_shield_pczt( Ok(bundle) } +/// Build and validate a Merkle witness for note commitment `cmx` at `position` with sibling +/// hashes `auth_path`, checking it recomputes to `expected_anchor`. +/// +/// This crate has no chain state (no note-commitment-tree, no lightwalletd connection), so the raw +/// sibling-hash path must always be supplied by the caller. This function only builds and validates +/// the witness structure from those caller-supplied details — it does not install the witness +/// anywhere (see module docs). +/// +/// Returns [`IronwoodBuildError::BadWitnessPath`] if `cmx` or any `auth_path` entry is not a +/// canonical field element, [`IronwoodBuildError::BadAnchor`] if `expected_anchor` isn't canonical, +/// or [`IronwoodBuildError::WitnessAnchorMismatch`] if the recomputed root doesn't match +/// `expected_anchor` — catching a bad caller-supplied path immediately rather than failing later at +/// the external prover. +pub fn build_ironwood_witness( + cmx: &[u8; 32], + position: u32, + auth_path: &WitnessAuthPath, + expected_anchor: &AnchorBytes, +) -> Result { + let cmx_parsed = + Option::::from(ExtractedNoteCommitment::from_bytes(cmx)) + .ok_or(IronwoodBuildError::BadWitnessPath)?; + let anchor = + Option::from(Anchor::from_bytes(*expected_anchor)).ok_or(IronwoodBuildError::BadAnchor)?; + + let mut auth_path_hashes = [MerkleHashOrchard::from_cmx(&cmx_parsed); IRONWOOD_MERKLE_DEPTH]; + for (dst, src) in auth_path_hashes.iter_mut().zip(auth_path.iter()) { + *dst = Option::from(MerkleHashOrchard::from_bytes(src)) + .ok_or(IronwoodBuildError::BadWitnessPath)?; + } + + let path = MerklePath::from_parts(position, auth_path_hashes); + if path.root(cmx_parsed) != anchor { + return Err(IronwoodBuildError::WitnessAnchorMismatch); + } + + Ok(IronwoodWitness { + position, + auth_path: *auth_path, + }) +} + /// IO Finalizer / Signer: derive the binding signing key and sign the dummy spends. /// /// `sighash` is the ZIP-244 shielded sig digest computed over the *complete* v6 transaction @@ -539,6 +610,98 @@ mod tests { assert_eq!(data.value_balance, -(amount as i64)); } + // ---- Witness builder ---- + + /// An arbitrary canonical Pallas field element, keyed off `seed` — a stand-in for a leaf + /// commitment or sibling hash. [`build_ironwood_witness`] only cares that its inputs are + /// canonical field elements, not that they come from a real note-commitment tree, so a + /// synthesized value works just as well as one derived from a real note for these tests. + fn field_bytes(seed: u64) -> [u8; 32] { + use ff::PrimeField; + pasta_curves::pallas::Base::from(seed).to_repr() + } + + /// A self-consistent `(cmx, position, auth_path, anchor)` fixture: `anchor` is computed via + /// `orchard`'s own `MerklePath::root`, the same primitive [`build_ironwood_witness`] wraps. + fn witness_fixture(position: u32) -> ([u8; 32], WitnessAuthPath, AnchorBytes) { + let cmx = field_bytes(0); + let auth_path: WitnessAuthPath = std::array::from_fn(|i| field_bytes(i as u64 + 1)); + + let cmx_parsed = + Option::::from(ExtractedNoteCommitment::from_bytes(&cmx)) + .unwrap(); + let sibling_hashes: [MerkleHashOrchard; IRONWOOD_MERKLE_DEPTH] = + auth_path.map(|s| Option::from(MerkleHashOrchard::from_bytes(&s)).unwrap()); + let anchor = MerklePath::from_parts(position, sibling_hashes) + .root(cmx_parsed) + .to_bytes(); + + (cmx, auth_path, anchor) + } + + #[test] + fn build_ironwood_witness_accepts_a_self_consistent_path() { + let (cmx, auth_path, anchor) = witness_fixture(5); + let witness = build_ironwood_witness(&cmx, 5, &auth_path, &anchor).unwrap(); + assert_eq!( + witness, + IronwoodWitness { + position: 5, + auth_path + } + ); + } + + #[test] + fn build_ironwood_witness_rejects_a_corrupted_auth_path_entry() { + let (cmx, mut auth_path, anchor) = witness_fixture(5); + auth_path[0] = field_bytes(999); + assert!(matches!( + build_ironwood_witness(&cmx, 5, &auth_path, &anchor), + Err(IronwoodBuildError::WitnessAnchorMismatch) + )); + } + + #[test] + fn build_ironwood_witness_rejects_the_wrong_anchor() { + let (cmx, auth_path, _anchor) = witness_fixture(5); + let wrong_anchor = Anchor::empty_tree().to_bytes(); + assert!(matches!( + build_ironwood_witness(&cmx, 5, &auth_path, &wrong_anchor), + Err(IronwoodBuildError::WitnessAnchorMismatch) + )); + } + + #[test] + fn build_ironwood_witness_rejects_non_canonical_cmx() { + let (_cmx, auth_path, anchor) = witness_fixture(5); + let non_canonical = [0xffu8; 32]; + assert!(matches!( + build_ironwood_witness(&non_canonical, 5, &auth_path, &anchor), + Err(IronwoodBuildError::BadWitnessPath) + )); + } + + #[test] + fn build_ironwood_witness_rejects_non_canonical_auth_path_entry() { + let (cmx, mut auth_path, anchor) = witness_fixture(5); + auth_path[3] = [0xffu8; 32]; + assert!(matches!( + build_ironwood_witness(&cmx, 5, &auth_path, &anchor), + Err(IronwoodBuildError::BadWitnessPath) + )); + } + + #[test] + fn build_ironwood_witness_rejects_non_canonical_anchor() { + let (cmx, auth_path, _anchor) = witness_fixture(5); + let non_canonical_anchor = [0xffu8; 32]; + assert!(matches!( + build_ironwood_witness(&cmx, 5, &auth_path, &non_canonical_anchor), + Err(IronwoodBuildError::BadAnchor) + )); + } + /// End-to-end (build → sighash → sign dummy spend → inject proof → combine), no circuit: /// the resulting v6 tx round-trips through the codec and its txid is stable. A canonical-length /// placeholder proof stands in for the external prover (the codec/txid never inspect proof diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodWitness.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodWitness.ts new file mode 100644 index 00000000000..a5b797a6902 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodWitness.ts @@ -0,0 +1,63 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { ZcashIronwoodWitness } from "../../js/fixedScriptWallet/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesZcash = path.resolve(__dirname, "../fixtures/zcash"); + +const fixture = JSON.parse( + fs.readFileSync(path.join(fixturesZcash, "ironwood_witness.json"), "utf8"), +) as { + cmx: string; + position: number; + authPath: string; + anchor: string; + wrongAnchor: string; +}; + +function splitAuthPath(hex: string): Uint8Array[] { + const bytes = Buffer.from(hex, "hex"); + const siblings: Uint8Array[] = []; + for (let i = 0; i < 32; i++) { + siblings.push(new Uint8Array(bytes.subarray(i * 32, (i + 1) * 32))); + } + return siblings; +} + +describe("ZcashIronwoodWitness.build", function () { + it("builds and validates a witness that recomputes to the expected anchor", function () { + const witness = ZcashIronwoodWitness.build( + Buffer.from(fixture.cmx, "hex"), + fixture.position, + splitAuthPath(fixture.authPath), + Buffer.from(fixture.anchor, "hex"), + ); + + assert.strictEqual(witness.position, fixture.position); + assert.strictEqual(Buffer.from(witness.authPath).toString("hex"), fixture.authPath); + }); + + it("throws when the path does not recompute to the given anchor", function () { + assert.throws(() => + ZcashIronwoodWitness.build( + Buffer.from(fixture.cmx, "hex"), + fixture.position, + splitAuthPath(fixture.authPath), + Buffer.from(fixture.wrongAnchor, "hex"), + ), + ); + }); + + it("throws when authPath does not have exactly 32 entries", function () { + assert.throws(() => + ZcashIronwoodWitness.build( + Buffer.from(fixture.cmx, "hex"), + fixture.position, + splitAuthPath(fixture.authPath).slice(0, 31), + Buffer.from(fixture.anchor, "hex"), + ), + ); + }); +}); diff --git a/packages/wasm-utxo/test/fixtures/zcash/ironwood_witness.json b/packages/wasm-utxo/test/fixtures/zcash/ironwood_witness.json new file mode 100644 index 00000000000..9f76cc08659 --- /dev/null +++ b/packages/wasm-utxo/test/fixtures/zcash/ironwood_witness.json @@ -0,0 +1,8 @@ +{ + "comment": "Self-consistent (cmx, position, authPath, anchor) fixture computed via orchard::tree::MerklePath::root (the same primitive build_ironwood_witness wraps) over synthetic-but-canonical Pallas field elements — not a real note-commitment tree. wrongAnchor is a different, still-canonical anchor (Anchor::empty_tree) used to exercise the mismatch case.", + "cmx": "0000000000000000000000000000000000000000000000000000000000000000", + "position": 5, + "authPath": "0100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000f0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000013000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000150000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001700000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000019000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001d000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000001f000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000", + "anchor": "439840f0253290357d04a9f9bef881405bf1be308ce893ab18e326bb50a88719", + "wrongAnchor": "ae2935f1dfd8a24aed7c70df7de3a668eb7a49b1319880dde2bbd9031ae5d82f" +}