diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index d5c7f744e1f..5bd51fb3031 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3202,6 +3202,7 @@ dependencies = [ "ff", "getrandom 0.2.16", "hex", + "incrementalmerkletree", "js-sys", "miniscript", "musig2", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 823a3722d00..c3ee0a130e6 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -73,6 +73,12 @@ 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" +# incrementalmerkletree::{Hashable, Level} — needed to fold a shard's leaves up to its local root +# (empty_leaf/empty_root/combine) in build_ironwood_witness_from_shard. orchard's public API does +# NOT re-export these (only MerklePath/MerkleHashOrchard), so this must be a direct dependency. +# Pinned to match orchard 0.15.0's own resolved pin (see Cargo.lock) so MerkleHashOrchard's +# `impl Hashable` resolves against the same crate version. +incrementalmerkletree = "0.8" # Pinned to avoid RUSTSEC-2026-0204 (invalid pointer dereference in fmt::Pointer) crossbeam-epoch = ">=0.9.20" diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodWitness.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodWitness.ts index 5e802100f5b..1d9330ba09c 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodWitness.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodWitness.ts @@ -1,6 +1,7 @@ import { IronwoodWitness as WasmIronwoodWitness, ironwood_build_witness, + ironwood_build_witness_from_shard, } from "../wasm/wasm_utxo.js"; /** @@ -31,6 +32,24 @@ export class ZcashIronwoodWitness { return new ZcashIronwoodWitness(ironwood_build_witness(cmx, position, authPath, anchor)); } + /** + * Build and validate a Merkle witness from one shard's leaves plus the sibling path above it, + * instead of a caller-precomputed 32-entry path. + * @throws Under the same conditions as {@link build}, plus if `shardHeight`/`upperPath`/ + * `shardLeaves` are inconsistent with each other or don't cover `position` + */ + static buildFromShard( + position: number, + shardHeight: number, + shardLeaves: Uint8Array[], + upperPath: Uint8Array[], + anchor: Uint8Array, + ): ZcashIronwoodWitness { + return new ZcashIronwoodWitness( + ironwood_build_witness_from_shard(position, shardHeight, shardLeaves, upperPath, anchor), + ); + } + /** The leaf's position in the note commitment tree. */ get position(): number { return this._wasm.position; diff --git a/packages/wasm-utxo/src/wasm/zcash.rs b/packages/wasm-utxo/src/wasm/zcash.rs index bc4342d1e92..c28cdbe9001 100644 --- a/packages/wasm-utxo/src/wasm/zcash.rs +++ b/packages/wasm-utxo/src/wasm/zcash.rs @@ -114,6 +114,57 @@ pub fn ironwood_build_witness( }) } +/// Build and validate an Ironwood witness from one shard's leaves plus the sibling path above it, +/// instead of a caller-precomputed 32-entry path (see `ironwood_build_witness`). +/// +/// `shardHeight` chooses how large the shard is (up to `2^shardHeight` leaves); `shardLeaves` must +/// be the dense, contiguous prefix of real leaves for that shard (index = shard-local position); +/// `upperPath` must have exactly `32 - shardHeight` entries. Throws under the same conditions as +/// `ironwood_build_witness`, plus if `shardHeight`/`upperPath`/`shardLeaves` are inconsistent with +/// each other or don't cover `position`. +#[wasm_bindgen] +pub fn ironwood_build_witness_from_shard( + position: u32, + shard_height: u8, + shard_leaves: Vec, + upper_path: Vec, + anchor: &[u8], +) -> Result { + use crate::zcash::ironwood_build::{build_ironwood_witness_from_shard, ShardWitnessInput}; + + fn to_arrays(v: &[js_sys::Uint8Array], label: &str) -> Result, WasmUtxoError> { + v.iter() + .enumerate() + .map(|(i, u)| { + u.to_vec().try_into().map_err(|_| { + WasmUtxoError::new(&format!( + "{label}[{i}] must be 32 bytes, got {}", + u.length() + )) + }) + }) + .collect() + } + + let anchor: [u8; 32] = anchor.try_into().map_err(|_| { + WasmUtxoError::new(&format!("anchor must be 32 bytes, got {}", anchor.len())) + })?; + let shard_leaves = to_arrays(&shard_leaves, "shardLeaves")?; + let upper_path = to_arrays(&upper_path, "upperPath")?; + + Ok(IronwoodWitness { + inner: build_ironwood_witness_from_shard( + &ShardWitnessInput { + position, + shard_height, + shard_leaves: &shard_leaves, + upper_path: &upper_path, + }, + &anchor, + )?, + }) +} + /// Resolve the Orchard/Ironwood receiver of a ZIP-316 unified address for `coin`'s network, as /// its raw 43 bytes (diversifier + `pk_d`) — there is no scriptPubKey for a shielded output, so /// this can't return script bytes uniformly and returns raw receiver bytes instead. diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 0432b727337..1f41439f855 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -72,6 +72,33 @@ pub struct IronwoodWitness { pub auth_path: WitnessAuthPath, } +/// Height (in levels) of a caller-supplied shard: it holds up to `2^shard_height` leaves. +/// `build_ironwood_witness_from_shard` computes the bottom `shard_height` auth-path levels from +/// them; the remaining `IRONWOOD_MERKLE_DEPTH - shard_height` levels are supplied directly via +/// `upper_path`. `0` degenerates to "no shard" — equivalent to calling `build_ironwood_witness` +/// directly with the caller's `upper_path` as the full 32-entry path. +pub type ShardHeight = u8; + +/// Caller-supplied shard data plus the sibling path above it, for +/// `build_ironwood_witness_from_shard`. +pub struct ShardWitnessInput<'a> { + /// The leaf's position in the *whole* note-commitment tree (same units/semantics as + /// `build_ironwood_witness`'s `position`) — NOT a shard-local index. The shard-local index is + /// derived internally as `position & ((1 << shard_height) - 1)`. + pub position: u32, + pub shard_height: ShardHeight, + /// The shard's leaf commitments, left-to-right, local position 0..len — a dense, contiguous + /// prefix (the note-commitment tree fills strictly left-to-right, so within any shard the only + /// legitimate "gap" is a suffix of not-yet-committed leaves, never an interior hole). Any local + /// position from `shard_leaves.len()` up to `2^shard_height - 1` is treated as the tree's empty + /// leaf. + pub shard_leaves: &'a [[u8; 32]], + /// Sibling hashes from the shard's local root up to (but excluding) the anchor, leaf-to-root + /// order — same convention as `WitnessAuthPath`. Must have exactly + /// `IRONWOOD_MERKLE_DEPTH - shard_height` entries. + pub upper_path: &'a [[u8; 32]], +} + /// Errors produced while constructing or combining an Ironwood shielded bundle. /// /// The variant name is surfaced to JS as `err.code` (e.g. `"IronwoodBuildError.BadRecipient"`) @@ -116,6 +143,14 @@ pub enum IronwoodBuildError { BadWitnessPath, /// The witness recomputed a root that does not match the expected anchor. WitnessAnchorMismatch, + /// `shard_height` exceeds `IRONWOOD_MERKLE_DEPTH` (32). + ShardHeightOutOfRange, + /// `upper_path.len() != IRONWOOD_MERKLE_DEPTH - shard_height`. + BadUpperPathLength, + /// `shard_leaves.len() > 2^shard_height` — more leaves than the shard can hold. + TooManyShardLeaves, + /// The target leaf's shard-local position is not covered by `shard_leaves`. + TargetLeafNotInShard, } impl core::fmt::Display for IronwoodBuildError { @@ -161,6 +196,22 @@ impl core::fmt::Display for IronwoodBuildError { f, "ironwood-build: witness path does not recompute to the expected anchor" ), + Self::ShardHeightOutOfRange => write!( + f, + "ironwood-build: shard_height exceeds the tree depth ({IRONWOOD_MERKLE_DEPTH})" + ), + Self::BadUpperPathLength => write!( + f, + "ironwood-build: upper_path length must equal {IRONWOOD_MERKLE_DEPTH} - shard_height" + ), + Self::TooManyShardLeaves => write!( + f, + "ironwood-build: shard_leaves.len() exceeds the shard's capacity (2^shard_height)" + ), + Self::TargetLeafNotInShard => write!( + f, + "ironwood-build: the target leaf's shard-local position is not covered by shard_leaves" + ), } } } @@ -297,6 +348,92 @@ pub fn build_ironwood_witness( }) } +/// Build and validate an Ironwood witness from one shard's leaves plus the sibling path above it, +/// instead of a caller-precomputed full 32-entry path. Computes only the bottom `shard_height` +/// auth-path entries itself, then delegates all canonical-encoding and anchor checks to +/// `build_ironwood_witness` — no validation is duplicated. +pub fn build_ironwood_witness_from_shard( + input: &ShardWitnessInput, + expected_anchor: &AnchorBytes, +) -> Result { + if input.shard_height as usize > IRONWOOD_MERKLE_DEPTH { + return Err(IronwoodBuildError::ShardHeightOutOfRange); + } + if input.upper_path.len() != IRONWOOD_MERKLE_DEPTH - input.shard_height as usize { + return Err(IronwoodBuildError::BadUpperPathLength); + } + // u64 throughout (rather than usize) so this doesn't overflow when shard_height == 32 on a + // 32-bit (wasm32) target, where `1usize << 32` would panic/wrap. + let shard_capacity: u64 = 1u64 << input.shard_height; + if input.shard_leaves.len() as u64 > shard_capacity { + return Err(IronwoodBuildError::TooManyShardLeaves); + } + let local_position = (input.position as u64) & (shard_capacity - 1); + // (shard_height == 0 => shard_capacity == 1 => local_position == 0 always) + if local_position >= input.shard_leaves.len() as u64 { + return Err(IronwoodBuildError::TargetLeafNotInShard); + } + + let parsed_leaves: Vec = input + .shard_leaves + .iter() + .map(|b| { + Option::from(MerkleHashOrchard::from_bytes(b)).ok_or(IronwoodBuildError::BadWitnessPath) + }) + .collect::>()?; + + let bottom = shard_local_auth_path(&parsed_leaves, input.shard_height, local_position); + let mut full_path: Vec<[u8; 32]> = bottom.iter().map(MerkleHashOrchard::to_bytes).collect(); + full_path.extend_from_slice(input.upper_path); + let full_path: WitnessAuthPath = full_path.try_into().expect("length checked above"); + + build_ironwood_witness( + &input.shard_leaves[local_position as usize], + input.position, + &full_path, + expected_anchor, + ) +} + +/// Fold a shard's leaves bottom-up, returning the `shard_height` sibling hashes on the path from +/// `local_position` to the shard's local root. `level_nodes` at the start of level `l` holds the +/// correctly-combined nodes at that level for indices `[0, level_nodes.len())`; anything at or +/// beyond that index is unpopulated, so `Hashable::empty_root(level)` is correct there (cost is +/// `O(shard_leaves.len())`, no `2^shard_height`-sized array is ever materialized). +fn shard_local_auth_path( + shard_leaves: &[MerkleHashOrchard], + shard_height: u8, + mut local_position: u64, +) -> Vec { + use incrementalmerkletree::{Hashable, Level}; + + let mut level_nodes = shard_leaves.to_vec(); + let mut path = Vec::with_capacity(shard_height as usize); + + for l in 0..shard_height { + let level = Level::from(l); + let sibling_idx = (local_position ^ 1) as usize; + path.push( + level_nodes + .get(sibling_idx) + .copied() + .unwrap_or_else(|| MerkleHashOrchard::empty_root(level)), + ); + + let mut next = Vec::with_capacity(level_nodes.len().div_ceil(2)); + for pair in level_nodes.chunks(2) { + let right = pair + .get(1) + .copied() + .unwrap_or_else(|| MerkleHashOrchard::empty_root(level)); + next.push(MerkleHashOrchard::combine(level, &pair[0], &right)); + } + level_nodes = next; + local_position >>= 1; + } + 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 @@ -702,6 +839,315 @@ mod tests { )); } + // ---- Shard witness builder ---- + + /// A self-consistent `(shard_leaves, upper_path, expected_anchor)` fixture for + /// `build_ironwood_witness_from_shard`. `shard_height`/`leaf_count`/`position` are the caller + /// inputs; `expected_anchor` is computed by brute-force materializing the full `2^shard_height` + /// leaf array (padding missing leaves with `MerkleHashOrchard::empty_leaf()`) and folding it + /// bottom-up by direct indexing — independent of `shard_local_auth_path`'s + /// streaming/prefix-only implementation, so this also catches bugs in that function. + fn shard_fixture( + shard_height: u8, + leaf_count: usize, + position: u32, + ) -> (Vec<[u8; 32]>, Vec<[u8; 32]>, AnchorBytes) { + use incrementalmerkletree::{Hashable, Level}; + + let shard_capacity = 1usize << shard_height; + assert!(leaf_count <= shard_capacity); + let shard_leaves: Vec<[u8; 32]> = (0..leaf_count) + .map(|i| field_bytes(i as u64 + 100)) + .collect(); + let upper_len = IRONWOOD_MERKLE_DEPTH - shard_height as usize; + let upper_path: Vec<[u8; 32]> = (0..upper_len) + .map(|i| field_bytes(i as u64 + 1000)) + .collect(); + + let mut level: Vec = (0..shard_capacity) + .map(|i| { + shard_leaves + .get(i) + .map(|b| Option::from(MerkleHashOrchard::from_bytes(b)).unwrap()) + .unwrap_or_else(MerkleHashOrchard::empty_leaf) + }) + .collect(); + let mut local_position = position as usize & (shard_capacity - 1); + let mut bottom_path = Vec::with_capacity(shard_height as usize); + for l in 0..shard_height { + let lvl = Level::from(l); + bottom_path.push(level[local_position ^ 1]); + let mut next = Vec::with_capacity(level.len() / 2); + for pair in level.chunks(2) { + next.push(MerkleHashOrchard::combine(lvl, &pair[0], &pair[1])); + } + level = next; + local_position >>= 1; + } + + let mut full_path_vec: Vec<[u8; 32]> = bottom_path + .iter() + .map(MerkleHashOrchard::to_bytes) + .collect(); + full_path_vec.extend_from_slice(&upper_path); + let full_path: WitnessAuthPath = full_path_vec.try_into().unwrap(); + + let cmx = shard_leaves[position as usize & (shard_capacity - 1)]; + let cmx_parsed = + Option::::from(ExtractedNoteCommitment::from_bytes(&cmx)) + .unwrap(); + let sibling_hashes: [MerkleHashOrchard; IRONWOOD_MERKLE_DEPTH] = + full_path.map(|s| Option::from(MerkleHashOrchard::from_bytes(&s)).unwrap()); + let anchor = MerklePath::from_parts(position, sibling_hashes) + .root(cmx_parsed) + .to_bytes(); + + (shard_leaves, upper_path, anchor) + } + + #[test] + fn build_ironwood_witness_from_shard_full_shard_interior_position() { + let (shard_leaves, upper_path, anchor) = shard_fixture(4, 16, 23); + let witness = build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 23, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path, + }, + &anchor, + ) + .unwrap(); + assert_eq!(witness.position, 23); + } + + #[test] + fn build_ironwood_witness_from_shard_edge_positions() { + for position in [0u32, 15u32] { + let (shard_leaves, upper_path, anchor) = shard_fixture(4, 16, position); + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path, + }, + &anchor, + ) + .unwrap(); + } + } + + #[test] + fn build_ironwood_witness_from_shard_partial_shard_tip() { + // 10 of 16 possible leaves; target the last supplied leaf, so some low-level siblings + // fall in the empty tail (index 9's sibling, 8, is real) while others don't (index 9 at + // the next level up pairs with the empty-padded slot). + let (shard_leaves, upper_path, anchor) = shard_fixture(4, 10, 9); + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 9, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path, + }, + &anchor, + ) + .unwrap(); + } + + #[test] + fn build_ironwood_witness_from_shard_height_zero_degenerates_to_full_path() { + let (shard_leaves, upper_path, anchor) = shard_fixture(0, 1, 5); + let full_path: WitnessAuthPath = upper_path.clone().try_into().unwrap(); + let direct = build_ironwood_witness(&shard_leaves[0], 5, &full_path, &anchor).unwrap(); + let via_shard = build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 5, + shard_height: 0, + shard_leaves: &shard_leaves, + upper_path: &upper_path, + }, + &anchor, + ) + .unwrap(); + assert_eq!(direct, via_shard); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_empty_shard() { + let (_, upper_path, anchor) = shard_fixture(4, 10, 9); + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 9, + shard_height: 4, + shard_leaves: &[], + upper_path: &upper_path + }, + &anchor, + ), + Err(IronwoodBuildError::TargetLeafNotInShard) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_shard_height_out_of_range() { + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 0, + shard_height: 33, + shard_leaves: &[], + upper_path: &[] + }, + &[0u8; 32], + ), + Err(IronwoodBuildError::ShardHeightOutOfRange) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_bad_upper_path_length() { + let (shard_leaves, upper_path, anchor) = shard_fixture(4, 16, 7); + // One entry too few. + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 7, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path[..upper_path.len() - 1], + }, + &anchor, + ), + Err(IronwoodBuildError::BadUpperPathLength) + )); + // One entry too many. + let mut too_long = upper_path.clone(); + too_long.push(field_bytes(9999)); + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 7, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &too_long + }, + &anchor, + ), + Err(IronwoodBuildError::BadUpperPathLength) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_too_many_shard_leaves() { + let (mut shard_leaves, upper_path, anchor) = shard_fixture(4, 16, 7); + shard_leaves.push(field_bytes(12345)); + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 7, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path + }, + &anchor, + ), + Err(IronwoodBuildError::TooManyShardLeaves) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_target_leaf_not_in_shard() { + // 10 leaves supplied, but position's local index (12) falls outside that prefix. + let (shard_leaves, upper_path, anchor) = shard_fixture(4, 10, 9); + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 12, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path + }, + &anchor, + ), + Err(IronwoodBuildError::TargetLeafNotInShard) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_non_canonical_shard_leaf() { + let (mut shard_leaves, upper_path, anchor) = shard_fixture(4, 16, 7); + shard_leaves[3] = [0xffu8; 32]; + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 7, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path + }, + &anchor, + ), + Err(IronwoodBuildError::BadWitnessPath) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_non_canonical_upper_path_entry() { + let (shard_leaves, mut upper_path, anchor) = shard_fixture(4, 16, 7); + upper_path[0] = [0xffu8; 32]; + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 7, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path + }, + &anchor, + ), + Err(IronwoodBuildError::BadWitnessPath) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_wrong_anchor() { + let (shard_leaves, upper_path, _anchor) = shard_fixture(4, 16, 7); + let wrong_anchor = Anchor::empty_tree().to_bytes(); + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 7, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_path + }, + &wrong_anchor, + ), + Err(IronwoodBuildError::WitnessAnchorMismatch) + )); + } + + #[test] + fn build_ironwood_witness_from_shard_rejects_non_canonical_anchor() { + let (shard_leaves, upper_path, _anchor) = shard_fixture(4, 16, 7); + let non_canonical_anchor = [0xffu8; 32]; + assert!(matches!( + build_ironwood_witness_from_shard( + &ShardWitnessInput { + position: 7, + shard_height: 4, + shard_leaves: &shard_leaves, + upper_path: &upper_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 index a5b797a6902..2fcfc436084 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodWitness.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodWitness.ts @@ -17,6 +17,17 @@ const fixture = JSON.parse( wrongAnchor: string; }; +const shardFixture = JSON.parse( + fs.readFileSync(path.join(fixturesZcash, "ironwood_witness_shard.json"), "utf8"), +) as { + position: number; + shardHeight: number; + shardLeaves: string; + upperPath: string; + anchor: string; + wrongAnchor: string; +}; + function splitAuthPath(hex: string): Uint8Array[] { const bytes = Buffer.from(hex, "hex"); const siblings: Uint8Array[] = []; @@ -26,6 +37,15 @@ function splitAuthPath(hex: string): Uint8Array[] { return siblings; } +function splitInto32ByteChunks(hex: string): Uint8Array[] { + const bytes = Buffer.from(hex, "hex"); + const chunks: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i += 32) { + chunks.push(new Uint8Array(bytes.subarray(i, i + 32))); + } + return chunks; +} + describe("ZcashIronwoodWitness.build", function () { it("builds and validates a witness that recomputes to the expected anchor", function () { const witness = ZcashIronwoodWitness.build( @@ -61,3 +81,29 @@ describe("ZcashIronwoodWitness.build", function () { ); }); }); + +describe("ZcashIronwoodWitness.buildFromShard", function () { + it("builds and validates a witness that recomputes to the expected anchor", function () { + const witness = ZcashIronwoodWitness.buildFromShard( + shardFixture.position, + shardFixture.shardHeight, + splitInto32ByteChunks(shardFixture.shardLeaves), + splitInto32ByteChunks(shardFixture.upperPath), + Buffer.from(shardFixture.anchor, "hex"), + ); + + assert.strictEqual(witness.position, shardFixture.position); + }); + + it("throws when the path does not recompute to the given anchor", function () { + assert.throws(() => + ZcashIronwoodWitness.buildFromShard( + shardFixture.position, + shardFixture.shardHeight, + splitInto32ByteChunks(shardFixture.shardLeaves), + splitInto32ByteChunks(shardFixture.upperPath), + Buffer.from(shardFixture.wrongAnchor, "hex"), + ), + ); + }); +}); diff --git a/packages/wasm-utxo/test/fixtures/zcash/ironwood_witness_shard.json b/packages/wasm-utxo/test/fixtures/zcash/ironwood_witness_shard.json new file mode 100644 index 00000000000..d344ea19dad --- /dev/null +++ b/packages/wasm-utxo/test/fixtures/zcash/ironwood_witness_shard.json @@ -0,0 +1,9 @@ +{ + "comment": "Self-consistent (position, shardHeight, shardLeaves, upperPath, anchor) fixture for ZcashIronwoodWitness.buildFromShard: a full 16-leaf shard (shardHeight=4) at local position 7, folded bottom-up via MerkleHashOrchard::combine (brute force, independent of the crate's shard_local_auth_path) then rooted via orchard::tree::MerklePath::root - the same primitive build_ironwood_witness wraps. shardLeaves/upperPath are synthetic-but-canonical Pallas field elements, not from a real note-commitment tree. wrongAnchor is a different, still-canonical anchor (Anchor::empty_tree) used to exercise the mismatch case.", + "position": 7, + "shardHeight": 4, + "shardLeaves": "6400000000000000000000000000000000000000000000000000000000000000650000000000000000000000000000000000000000000000000000000000000066000000000000000000000000000000000000000000000000000000000000006700000000000000000000000000000000000000000000000000000000000000680000000000000000000000000000000000000000000000000000000000000069000000000000000000000000000000000000000000000000000000000000006a000000000000000000000000000000000000000000000000000000000000006b000000000000000000000000000000000000000000000000000000000000006c000000000000000000000000000000000000000000000000000000000000006d000000000000000000000000000000000000000000000000000000000000006e000000000000000000000000000000000000000000000000000000000000006f000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000710000000000000000000000000000000000000000000000000000000000000072000000000000000000000000000000000000000000000000000000000000007300000000000000000000000000000000000000000000000000000000000000", + "upperPath": "e803000000000000000000000000000000000000000000000000000000000000e903000000000000000000000000000000000000000000000000000000000000ea03000000000000000000000000000000000000000000000000000000000000eb03000000000000000000000000000000000000000000000000000000000000ec03000000000000000000000000000000000000000000000000000000000000ed03000000000000000000000000000000000000000000000000000000000000ee03000000000000000000000000000000000000000000000000000000000000ef03000000000000000000000000000000000000000000000000000000000000f003000000000000000000000000000000000000000000000000000000000000f103000000000000000000000000000000000000000000000000000000000000f203000000000000000000000000000000000000000000000000000000000000f303000000000000000000000000000000000000000000000000000000000000f403000000000000000000000000000000000000000000000000000000000000f503000000000000000000000000000000000000000000000000000000000000f603000000000000000000000000000000000000000000000000000000000000f703000000000000000000000000000000000000000000000000000000000000f803000000000000000000000000000000000000000000000000000000000000f903000000000000000000000000000000000000000000000000000000000000fa03000000000000000000000000000000000000000000000000000000000000fb03000000000000000000000000000000000000000000000000000000000000fc03000000000000000000000000000000000000000000000000000000000000fd03000000000000000000000000000000000000000000000000000000000000fe03000000000000000000000000000000000000000000000000000000000000ff030000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000010400000000000000000000000000000000000000000000000000000000000002040000000000000000000000000000000000000000000000000000000000000304000000000000000000000000000000000000000000000000000000000000", + "anchor": "02c4214b2b0f6c76a68267a47d6ff584c3987d93325299fb27e216dffc2db502", + "wrongAnchor": "ae2935f1dfd8a24aed7c70df7de3a668eb7a49b1319880dde2bbd9031ae5d82f" +}