From 260e15f98bbd83e2e3405d16f0b46abd8993920e Mon Sep 17 00:00:00 2001 From: zeapoz Date: Tue, 21 Apr 2026 16:14:09 +0900 Subject: [PATCH 1/2] feat(zair-sdk): support all gaptree modes in the integration api --- crates/zair-sdk/src/api/claims.rs | 108 +++++++- crates/zair-sdk/src/api/mod.rs | 10 + crates/zair-sdk/src/api/scan.rs | 65 +++-- crates/zair-sdk/src/commands/airdrop_claim.rs | 261 ++++-------------- 4 files changed, 219 insertions(+), 225 deletions(-) diff --git a/crates/zair-sdk/src/api/claims.rs b/crates/zair-sdk/src/api/claims.rs index e78a963..2c78899 100644 --- a/crates/zair-sdk/src/api/claims.rs +++ b/crates/zair-sdk/src/api/claims.rs @@ -7,7 +7,8 @@ use tracing::{debug, info}; use zair_core::base::{Nullifier, Pool, SanitiseNullifiers}; use zair_core::schema::proof_inputs::{ClaimInput, PublicInputs}; use zair_nonmembership::{ - MerklePathError, NonMembershipTree, OrchardNonMembershipTree, TreePosition, + MerklePathError, NonMembershipTree, OrchardGapTree, OrchardNonMembershipTree, SaplingGapTree, + TreePosition, map_orchard_user_positions, map_sapling_user_positions, }; use zair_scan::ViewingKeys; @@ -50,6 +51,10 @@ pub struct LoadedPoolData { /// Pool-specific non-membership tree variants. pub enum PoolMerkleTree { + /// Sapling pool gap tree (dense representation from precomputed file). + Sapling(SaplingGapTree), + /// Orchard pool gap tree (dense representation from precomputed file). + Orchard(OrchardGapTree), /// Sapling pool non-membership tree using sparse representation. SaplingSparse(NonMembershipTree), /// Orchard pool non-membership tree using sparse representation. @@ -61,6 +66,8 @@ impl PoolMerkleTree { #[must_use] pub fn root_bytes(&self) -> [u8; 32] { match self { + Self::Sapling(tree) => tree.root_bytes(), + Self::Orchard(tree) => tree.root_bytes(), Self::SaplingSparse(tree) => tree.root().to_bytes(), Self::OrchardSparse(tree) => tree.root_bytes(), } @@ -73,6 +80,8 @@ impl PoolMerkleTree { /// Returns an error if the witness cannot be generated for the given position. pub fn witness_bytes(&self, position: u64) -> Result, MerklePathError> { match self { + Self::Sapling(tree) => tree.witness_bytes(position), + Self::Orchard(tree) => tree.witness_bytes(position), Self::SaplingSparse(tree) => tree .witness(position.into()) .map(|path| path.into_iter().map(|node| node.to_bytes()).collect()), @@ -142,6 +151,103 @@ pub async fn build_pool_merkle_tree_from_memory( }) } +/// Build the non-membership merkle tree for a pool from precomputed gap-tree bytes. +/// +/// Corresponds to `GapTreeMode::None` - requires precomputed gap-tree file bytes. +/// +/// # Errors +/// +/// Returns an error if gap-tree bytes are invalid or user nullifier mapping fails. +pub async fn build_pool_merkle_tree_from_file( + chain_nullifiers: SanitiseNullifiers, + user_nullifiers: SanitiseNullifiers, + gap_tree_bytes: &[u8], + pool: Pool, +) -> Result { + let use_orchard_tree = pool == Pool::Orchard; + + info!( + count = chain_nullifiers.len(), + %pool, + "Loaded chain nullifiers" + ); + + info!(%pool, "Loading gap tree from precomputed bytes..."); + let user_positions = if use_orchard_tree { + map_orchard_user_positions(&chain_nullifiers, &user_nullifiers) + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))? + } else { + map_sapling_user_positions(&chain_nullifiers, &user_nullifiers) + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))? + }; + + let tree = if use_orchard_tree { + PoolMerkleTree::Orchard( + OrchardGapTree::from_bytes(gap_tree_bytes) + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))?, + ) + } else { + PoolMerkleTree::Sapling( + SaplingGapTree::from_bytes(gap_tree_bytes) + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))?, + ) + }; + + info!(%pool, "Gap tree loaded"); + Ok(LoadedPoolData { + tree, + user_nullifiers: user_positions, + }) +} + +/// Build gap tree from nullifier bytes and return serialized bytes (for persistence). +/// +/// Corresponds to `GapTreeMode::Rebuild` - builds gap tree and returns serialized bytes +/// that can be persisted to a file. +/// +/// # Errors +/// +/// Returns an error if tree construction fails. +pub async fn build_gap_tree_and_serialize( + snapshot_nullifiers_bytes: &[u8], + pool: Pool, +) -> Result, ClaimsError> { + let use_orchard_tree = pool == Pool::Orchard; + let chain_nullifiers_vec = zair_scan::read_nullifiers(snapshot_nullifiers_bytes) + .await + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))?; + let chain_nullifiers = SanitiseNullifiers::new(chain_nullifiers_vec); + + info!( + count = chain_nullifiers.len(), + %pool, + "Building gap tree from snapshot nullifiers..." + ); + + let serialized = tokio::task::spawn_blocking(move || { + if use_orchard_tree { + let tree = OrchardGapTree::from_nullifiers_with_progress( + &chain_nullifiers, + |_current, _total| {}, + ) + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))?; + Ok::<_, ClaimsError>(tree.to_bytes()) + } else { + let tree = SaplingGapTree::from_nullifiers_with_progress( + &chain_nullifiers, + |_current, _total| {}, + ) + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))?; + Ok(tree.to_bytes()) + } + }) + .await + .map_err(|e| ClaimsError::MerkleTreeBuild(pool.to_string(), e.to_string()))??; + + info!(%pool, "Gap tree built and serialized"); + Ok(serialized) +} + /// Generate airdrop claims for the user's notes. /// /// This generic function works with any metadata type implementing `NoteMetadata`, diff --git a/crates/zair-sdk/src/api/mod.rs b/crates/zair-sdk/src/api/mod.rs index c4d0554..a531542 100644 --- a/crates/zair-sdk/src/api/mod.rs +++ b/crates/zair-sdk/src/api/mod.rs @@ -25,6 +25,7 @@ use crate::api::key::KeyError; use crate::api::prove::ProveError; use crate::api::scan::ScanError; use crate::api::sign::SignError; +pub use crate::commands::GapTreeMode; /// Errors that can occur in the API pipeline. #[derive(Debug, Error)] @@ -67,6 +68,9 @@ pub enum ApiError { /// * `orchard_params_bytes` - Halo2 params bytes (None if no Orchard claims) /// * `birthday_height` - earliest block for note scanning /// * `config` - airdrop configuration +/// * `gap_tree_mode` - gap tree handling mode (None, Rebuild, or Sparse) +/// * `sapling_gap_tree_bytes` - precomputed Sapling gap tree bytes (for None/Rebuild mode) +/// * `orchard_gap_tree_bytes` - precomputed Orchard gap tree bytes (for None/Rebuild mode) /// * `message_hashes` - pre-computed message hashes to sign; per‑proof hashes (keyed by airdrop /// nullifier) take precedence over the shared fallback /// @@ -85,6 +89,9 @@ pub async fn run( orchard_params_bytes: Option<&[u8]>, birthday_height: u64, config: &AirdropConfiguration, + gap_tree_mode: GapTreeMode, + sapling_gap_tree_bytes: Option<&[u8]>, + orchard_gap_tree_bytes: Option<&[u8]>, message_hashes: Option, ) -> Result { let ufvk = key::derive_ufvk_from_seed(to_zcash_network(config.network), account_id, seed) @@ -97,6 +104,9 @@ pub async fn run( &ufvk, birthday_height, config, + gap_tree_mode, + sapling_gap_tree_bytes, + orchard_gap_tree_bytes, ) .await .map_err(ApiError::Scan)?; diff --git a/crates/zair-sdk/src/api/scan.rs b/crates/zair-sdk/src/api/scan.rs index 4f91051..46bbe30 100644 --- a/crates/zair-sdk/src/api/scan.rs +++ b/crates/zair-sdk/src/api/scan.rs @@ -5,7 +5,6 @@ use std::str::FromStr as _; use http::Uri; use thiserror::Error; use tracing::{info, instrument, warn}; -use zair_core::base::SanitiseNullifiers; use zair_core::schema::config::AirdropConfiguration; use zair_core::schema::proof_inputs::AirdropClaimInputs; use zair_scan::ViewingKeys; @@ -16,7 +15,7 @@ use zcash_keys::keys::UnifiedFullViewingKey; use crate::api::claims::{ GapTreeMode, OrchardPool, PoolClaimResult, PoolProcessor, SaplingPool, - build_pool_merkle_tree_from_memory, generate_claims, + build_pool_merkle_tree_from_file, build_pool_merkle_tree_from_memory, generate_claims, }; use crate::common::{resolve_lightwalletd_url, to_zcash_network}; @@ -55,13 +54,14 @@ pub enum ScanError { } #[instrument(level = "debug", skip_all, fields(pool = %P::POOL))] -pub(crate) async fn process_pool_claims_from_memory( +pub(crate) async fn process_pool_claims( pool_enabled_in_config: bool, visitor: &AccountNotesVisitor, viewing_keys: &ViewingKeys, airdrop_config: &AirdropConfiguration, - snapshot_nullifiers: Option, + snapshot_nullifiers: Option, gap_tree_mode: GapTreeMode, + gap_tree_bytes: Option<&[u8]>, ) -> Result, ScanError> { if !pool_enabled_in_config { return Ok(PoolClaimResult::empty()); @@ -85,15 +85,34 @@ pub(crate) async fn process_pool_claims_from_memory( } }; - let user_nullifiers = SanitiseNullifiers::new(notes.keys().copied().collect()); - let pool_data = build_pool_merkle_tree_from_memory( - snapshot_nullifiers, - user_nullifiers, - P::POOL, - gap_tree_mode, - ) - .await - .map_err(|e| ScanError::ClaimGeneration(P::POOL.to_string(), e.to_string()))?; + let user_nullifiers = zair_core::base::SanitiseNullifiers::new(notes.keys().copied().collect()); + + let pool_data = match gap_tree_mode { + GapTreeMode::Sparse => build_pool_merkle_tree_from_memory( + snapshot_nullifiers, + user_nullifiers, + P::POOL, + gap_tree_mode, + ) + .await + .map_err(|e| ScanError::ClaimGeneration(P::POOL.to_string(), e.to_string()))?, + GapTreeMode::None | GapTreeMode::Rebuild => { + let Some(gap_tree_bytes) = gap_tree_bytes else { + return Err(ScanError::ClaimGeneration( + P::POOL.to_string(), + format!("GapTreeMode::{gap_tree_mode:?} requires gap_tree_bytes"), + )); + }; + build_pool_merkle_tree_from_file( + snapshot_nullifiers, + user_nullifiers, + gap_tree_bytes, + P::POOL, + ) + .await + .map_err(|e| ScanError::ClaimGeneration(P::POOL.to_string(), e.to_string()))? + } + }; let anchor = pool_data.tree.root_bytes(); let Some(expected_root) = P::expected_root(airdrop_config) else { @@ -128,6 +147,9 @@ pub(crate) async fn process_pool_claims_from_memory( /// * `ufvk_encoded` - encoded Unified Full Viewing Key string /// * `birthday_height` - earliest block for note scanning /// * `config` - airdrop configuration +/// * `gap_tree_mode` - gap tree handling mode (None, Rebuild, or Sparse) +/// * `sapling_gap_tree_bytes` - precomputed Sapling gap tree bytes (for None/Rebuild mode) +/// * `orchard_gap_tree_bytes` - precomputed Orchard gap tree bytes (for None/Rebuild mode) /// /// # Returns /// @@ -144,6 +166,9 @@ pub async fn airdrop_claim_from_config( ufvk_encoded: &str, birthday_height: u64, config: &AirdropConfiguration, + gap_tree_mode: GapTreeMode, + sapling_gap_tree_bytes: Option<&[u8]>, + orchard_gap_tree_bytes: Option<&[u8]>, ) -> Result { let network = to_zcash_network(config.network); let lightwalletd_url = resolve_lightwalletd_url(network, lightwalletd_url.as_deref()); @@ -199,23 +224,25 @@ pub async fn airdrop_claim_from_config( None }; - let sapling_result = process_pool_claims_from_memory::( + let sapling_result = process_pool_claims::( config.sapling.is_some(), &visitor, &viewing_keys, config, - sapling_nullifiers.map(SanitiseNullifiers::new), - GapTreeMode::Sparse, + sapling_nullifiers.map(zair_core::base::SanitiseNullifiers::new), + gap_tree_mode, + sapling_gap_tree_bytes, ) .await?; - let orchard_result = process_pool_claims_from_memory::( + let orchard_result = process_pool_claims::( config.orchard.is_some(), &visitor, &viewing_keys, config, - orchard_nullifiers.map(SanitiseNullifiers::new), - GapTreeMode::Sparse, + orchard_nullifiers.map(zair_core::base::SanitiseNullifiers::new), + gap_tree_mode, + orchard_gap_tree_bytes, ) .await?; diff --git a/crates/zair-sdk/src/commands/airdrop_claim.rs b/crates/zair-sdk/src/commands/airdrop_claim.rs index d0ffd87..95b0270 100644 --- a/crates/zair-sdk/src/commands/airdrop_claim.rs +++ b/crates/zair-sdk/src/commands/airdrop_claim.rs @@ -16,10 +16,7 @@ use tracing::{debug, info, instrument, warn}; use zair_core::base::{Nullifier, Pool, SanitiseNullifiers}; use zair_core::schema::config::AirdropConfiguration; use zair_core::schema::proof_inputs::{AirdropClaimInputs, ClaimInput, PublicInputs}; -use zair_nonmembership::{ - MerklePathError, NonMembershipTree, OrchardGapTree, OrchardNonMembershipTree, SaplingGapTree, - TreePosition, map_orchard_user_positions, map_sapling_user_positions, -}; +use zair_nonmembership::TreePosition; use zair_scan::ViewingKeys; use zair_scan::light_walletd::LightWalletd; use zair_scan::scanner::{AccountNotesVisitor, BlockScanner}; @@ -29,6 +26,10 @@ use zcash_protocol::consensus::Network; use super::note_metadata::NoteMetadata; use super::pool_processor::{OrchardPool, PoolClaimResult, PoolProcessor, SaplingPool}; use super::sensitive_output::write_sensitive_output; +use crate::api::claims::{ + LoadedPoolData, PoolMerkleTree, build_gap_tree_and_serialize, build_pool_merkle_tree_from_file, + build_pool_merkle_tree_from_memory, +}; use crate::common::{resolve_lightwalletd_url, to_zcash_network}; /// 1 MiB buffer for file I/O. const FILE_BUF_SIZE: usize = 1024 * 1024; @@ -307,49 +308,9 @@ async fn find_user_notes( Ok(visitor) } -/// Loaded pool data including the non-membership merkle-tree and user's nullifier positions. -pub struct LoadedPoolData { - /// The non-membership merkle tree for the pool. - pub tree: PoolMerkleTree, - /// The user's nullifiers with tree positions needed to generate proofs. - pub user_nullifiers: Vec, -} - -/// Pool-specific non-membership tree variants. -pub enum PoolMerkleTree { - Sapling(SaplingGapTree), - Orchard(OrchardGapTree), - SaplingSparse(NonMembershipTree), - OrchardSparse(OrchardNonMembershipTree), -} - -impl PoolMerkleTree { - fn root_bytes(&self) -> [u8; 32] { - match self { - Self::Sapling(tree) => tree.root_bytes(), - Self::Orchard(tree) => tree.root_bytes(), - Self::SaplingSparse(tree) => tree.root().to_bytes(), - Self::OrchardSparse(tree) => tree.root_bytes(), - } - } - - fn witness_bytes(&self, position: u64) -> Result, MerklePathError> { - match self { - Self::Sapling(tree) => tree.witness_bytes(position), - Self::Orchard(tree) => tree.witness_bytes(position), - Self::SaplingSparse(tree) => tree - .witness(position.into()) - .map(|path| path.into_iter().map(|node| node.to_bytes()).collect()), - Self::OrchardSparse(tree) => tree.witness_bytes(position.into()), - } - } -} - /// Build the non-membership merkle tree for a pool. -#[allow( - clippy::too_many_lines, - reason = "Mode-specific sparse/dense cache handling is intentionally kept in one dispatch function" -)] +/// +/// This is a file-I/O wrapper around the API functions from `api::claims`. async fn build_pool_merkle_tree( snapshot_nullifiers_path: &Path, gap_tree_path: Option<&Path>, @@ -357,7 +318,6 @@ async fn build_pool_merkle_tree( pool: Pool, gap_tree_mode: GapTreeMode, ) -> eyre::Result { - let use_orchard_tree = pool == Pool::Orchard; let chain_nullifiers = load_nullifiers_from_file(snapshot_nullifiers_path).await?; info!( @@ -372,169 +332,57 @@ async fn build_pool_merkle_tree( %pool, "Building sparse non-membership tree from snapshot nullifiers..." ); - info!( - %pool, - progress = "0%", - "Building non-membership tree" - ); - let chain_for_build = chain_nullifiers; - let user_for_build = user_nullifiers; - let (tree, user_positions) = tokio::task::spawn_blocking(move || { - let mut last_progress_pct = 0_usize; - if use_orchard_tree { - OrchardNonMembershipTree::from_chain_and_user_nullifiers_with_progress( - &chain_for_build, - &user_for_build, - |current, total| { - if total == 0 { - return; - } - #[allow( - clippy::arithmetic_side_effects, - reason = "Progress percentage uses saturating operations and is guarded against total=0" - )] - let pct = current.saturating_mul(100).saturating_div(total); - if pct >= last_progress_pct.saturating_add(10) { - last_progress_pct = pct; - info!(%pool, progress = %format!("{pct}%"), "Building non-membership tree"); - } - }, - ) - .map(|(tree, positions)| (PoolMerkleTree::OrchardSparse(tree), positions)) - } else { - NonMembershipTree::from_chain_and_user_nullifiers_with_progress( - &chain_for_build, - &user_for_build, - |current, total| { - if total == 0 { - return; - } - #[allow( - clippy::arithmetic_side_effects, - reason = "Progress percentage uses saturating operations and is guarded against total=0" - )] - let pct = current.saturating_mul(100).saturating_div(total); - if pct >= last_progress_pct.saturating_add(10) { - last_progress_pct = pct; - info!(%pool, progress = %format!("{pct}%"), "Building non-membership tree"); - } - }, - ) - .map(|(tree, positions)| (PoolMerkleTree::SaplingSparse(tree), positions)) - } - }) - .await??; - - info!(%pool, "Non-membership tree ready"); - Ok(LoadedPoolData { - tree, - user_nullifiers: user_positions, - }) + // Take ownership for this call + let chain_for_sparse = chain_nullifiers; + build_pool_merkle_tree_from_memory( + chain_for_sparse, + user_nullifiers, + pool, + gap_tree_mode, + ) + .await + .map_err(|e| eyre::eyre!("Failed to build sparse tree: {e}")) } - GapTreeMode::Rebuild | GapTreeMode::None => { - let user_positions = if use_orchard_tree { - map_orchard_user_positions(&chain_nullifiers, &user_nullifiers) - .map_err(|e| eyre::eyre!("Failed to map Orchard user nullifiers: {e}"))? - } else { - map_sapling_user_positions(&chain_nullifiers, &user_nullifiers) - .map_err(|e| eyre::eyre!("Failed to map Sapling user nullifiers: {e}"))? - }; + GapTreeMode::Rebuild => { let gap_tree_path = gap_tree_path.ok_or_else(|| { - eyre::eyre!( - "Missing gap-tree path for pool {pool} in mode {:?}", - gap_tree_mode - ) + eyre::eyre!("Missing gap-tree path for pool {pool} in Rebuild mode") })?; - let tree = if gap_tree_mode == GapTreeMode::Rebuild { - info!( - %pool, - "Rebuilding gap-tree from snapshot nullifiers..." - ); - let chain_nullifiers_for_build = chain_nullifiers; - let built_tree = tokio::task::spawn_blocking(move || { - if use_orchard_tree { - OrchardGapTree::from_nullifiers_with_progress( - &chain_nullifiers_for_build, - |current, total| { - if total == 0 { - return; - } - #[allow( - clippy::arithmetic_side_effects, - reason = "Progress percentage uses saturating operations and is guarded against total=0" - )] - let pct = current.saturating_mul(100).saturating_div(total); - info!(%pool, progress = %format!("{pct}%"), "Building non-membership tree"); - }, - ) - .map(PoolMerkleTree::Orchard) - } else { - SaplingGapTree::from_nullifiers_with_progress( - &chain_nullifiers_for_build, - |current, total| { - if total == 0 { - return; - } - #[allow( - clippy::arithmetic_side_effects, - reason = "Progress percentage uses saturating operations and is guarded against total=0" - )] - let pct = current.saturating_mul(100).saturating_div(total); - info!(%pool, progress = %format!("{pct}%"), "Building non-membership tree"); - }, - ) - .map(PoolMerkleTree::Sapling) - } - }) - .await??; - let serialized = match &built_tree { - PoolMerkleTree::Sapling(tree) => tree.to_bytes(), - PoolMerkleTree::Orchard(tree) => tree.to_bytes(), - PoolMerkleTree::SaplingSparse(_) | PoolMerkleTree::OrchardSparse(_) => { - unreachable!("sparse variants are not persisted in rebuild mode") - } - }; - tokio::fs::write(gap_tree_path, serialized) - .await - .with_context(|| { - format!("Failed to write gap-tree to {}", gap_tree_path.display()) - })?; - built_tree - } else { - let bytes = tokio::fs::read(gap_tree_path).await.with_context(|| { - format!( - "Failed to read gap-tree from {}. Retry with --gap-tree-mode rebuild", - gap_tree_path.display() - ) + info!(%pool, "Rebuilding gap-tree from snapshot nullifiers..."); + // Read snapshot bytes for rebuild + let snapshot_bytes = tokio::fs::read(snapshot_nullifiers_path).await?; + let serialized = build_gap_tree_and_serialize(&snapshot_bytes, pool) + .await + .map_err(|e| eyre::eyre!("Failed to build gap tree: {e}"))?; + + tokio::fs::write(gap_tree_path, &serialized) + .await + .with_context(|| { + format!("Failed to write gap-tree to {}", gap_tree_path.display()) })?; - if use_orchard_tree { - PoolMerkleTree::Orchard(OrchardGapTree::from_bytes(&bytes).with_context( - || { - format!( - "Failed to parse Orchard gap-tree {}. Retry with --gap-tree-mode rebuild", - gap_tree_path.display() - ) - }, - )?) - } else { - PoolMerkleTree::Sapling(SaplingGapTree::from_bytes(&bytes).with_context( - || { - format!( - "Failed to parse Sapling gap-tree {}. Retry with --gap-tree-mode rebuild", - gap_tree_path.display() - ) - }, - )?) - } - }; - info!(%pool, "Non-membership tree ready"); + // Load the rebuilt gap tree using the chain nullifiers we already loaded + build_pool_merkle_tree_from_file(chain_nullifiers, user_nullifiers, &serialized, pool) + .await + .map_err(|e| eyre::eyre!("Failed to load rebuilt gap tree: {e}")) + } + GapTreeMode::None => { + let gap_tree_path = gap_tree_path + .ok_or_else(|| eyre::eyre!("Missing gap-tree path for pool {pool} in None mode"))?; + + info!(%pool, "Loading gap tree from precomputed file..."); + let bytes = tokio::fs::read(gap_tree_path).await.with_context(|| { + format!( + "Failed to read gap-tree from {}. Retry with --gap-tree-mode rebuild", + gap_tree_path.display() + ) + })?; - Ok(LoadedPoolData { - tree, - user_nullifiers: user_positions, - }) + // Take ownership + let chain_for_load = chain_nullifiers; + build_pool_merkle_tree_from_file(chain_for_load, user_nullifiers, &bytes, pool) + .await + .map_err(|e| eyre::eyre!("Failed to load gap tree: {e}")) } } } @@ -689,6 +537,7 @@ mod tests { AirdropConfiguration, AirdropNetwork, OrchardSnapshot, SaplingSnapshot, ValueCommitmentScheme, }; + use zair_nonmembership::{OrchardGapTree, SaplingGapTree}; use zair_scan::write_nullifiers; use super::*; @@ -778,9 +627,11 @@ mod tests { let err = result .err() .expect("corrupt gap-tree should fail without rebuild"); + let err_str = err.to_string(); assert!( - err.to_string() - .contains(&format!("Failed to parse {pool} gap-tree")) + err_str.contains("gap-tree file is too short") || + err_str.contains("Failed to load gap tree"), + "error: {err_str}" ); } } From 0c233f98ab21f4a400b501da81d5cb747e07a590 Mon Sep 17 00:00:00 2001 From: zeapoz Date: Tue, 21 Apr 2026 22:43:10 +0900 Subject: [PATCH 2/2] chore: update `NonMembershipTree` api --- .../zair-nonmembership/src/sparse/sapling.rs | 23 +++++++++++++++++-- crates/zair-sdk/src/api/claims.rs | 6 ++--- crates/zair-sdk/src/api/scan.rs | 9 ++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/zair-nonmembership/src/sparse/sapling.rs b/crates/zair-nonmembership/src/sparse/sapling.rs index b9b1c01..8ef5ef9 100644 --- a/crates/zair-nonmembership/src/sparse/sapling.rs +++ b/crates/zair-nonmembership/src/sparse/sapling.rs @@ -263,13 +263,17 @@ impl NonMembershipTree { } /// Returns the root of the tree. - /// - /// Returns `None` if the tree is empty. #[must_use] pub const fn root(&self) -> NonMembershipNode { self.cached_root } + /// Returns the root of the tree as bytes. + #[must_use] + pub const fn root_bytes(&self) -> [u8; 32] { + self.root().to_bytes() + } + /// Returns the number of leaves in the tree. #[must_use] pub const fn leaf_count(&self) -> usize { @@ -298,6 +302,21 @@ impl NonMembershipTree { .map_err(|e| MerklePathError::WitnessError(format!("{e:?}"))) } + /// Generate a witness (authentication path) for a marked leaf. + /// + /// # Arguments + /// * `position` - The position of the leaf (must have been marked during construction). + /// + /// # Returns + /// A vector of sibling hashes represented as bytes from leaf to root. + /// + /// # Errors + /// Returns an error if the position is not marked or witness generation fails. + pub fn witness_bytes(&self, position: Position) -> Result, MerklePathError> { + self.witness(position) + .map(|path| path.into_iter().map(|node| node.to_bytes()).collect()) + } + /// Verify that a leaf at the given position produces the expected root. /// /// # Arguments diff --git a/crates/zair-sdk/src/api/claims.rs b/crates/zair-sdk/src/api/claims.rs index 2c78899..2a744c1 100644 --- a/crates/zair-sdk/src/api/claims.rs +++ b/crates/zair-sdk/src/api/claims.rs @@ -68,7 +68,7 @@ impl PoolMerkleTree { match self { Self::Sapling(tree) => tree.root_bytes(), Self::Orchard(tree) => tree.root_bytes(), - Self::SaplingSparse(tree) => tree.root().to_bytes(), + Self::SaplingSparse(tree) => tree.root_bytes(), Self::OrchardSparse(tree) => tree.root_bytes(), } } @@ -82,9 +82,7 @@ impl PoolMerkleTree { match self { Self::Sapling(tree) => tree.witness_bytes(position), Self::Orchard(tree) => tree.witness_bytes(position), - Self::SaplingSparse(tree) => tree - .witness(position.into()) - .map(|path| path.into_iter().map(|node| node.to_bytes()).collect()), + Self::SaplingSparse(tree) => tree.witness_bytes(position.into()), Self::OrchardSparse(tree) => tree.witness_bytes(position.into()), } } diff --git a/crates/zair-sdk/src/api/scan.rs b/crates/zair-sdk/src/api/scan.rs index 46bbe30..959d0d0 100644 --- a/crates/zair-sdk/src/api/scan.rs +++ b/crates/zair-sdk/src/api/scan.rs @@ -5,6 +5,7 @@ use std::str::FromStr as _; use http::Uri; use thiserror::Error; use tracing::{info, instrument, warn}; +use zair_core::base::SanitiseNullifiers; use zair_core::schema::config::AirdropConfiguration; use zair_core::schema::proof_inputs::AirdropClaimInputs; use zair_scan::ViewingKeys; @@ -59,7 +60,7 @@ pub(crate) async fn process_pool_claims( visitor: &AccountNotesVisitor, viewing_keys: &ViewingKeys, airdrop_config: &AirdropConfiguration, - snapshot_nullifiers: Option, + snapshot_nullifiers: Option, gap_tree_mode: GapTreeMode, gap_tree_bytes: Option<&[u8]>, ) -> Result, ScanError> { @@ -85,7 +86,7 @@ pub(crate) async fn process_pool_claims( } }; - let user_nullifiers = zair_core::base::SanitiseNullifiers::new(notes.keys().copied().collect()); + let user_nullifiers = SanitiseNullifiers::new(notes.keys().copied().collect()); let pool_data = match gap_tree_mode { GapTreeMode::Sparse => build_pool_merkle_tree_from_memory( @@ -229,7 +230,7 @@ pub async fn airdrop_claim_from_config( &visitor, &viewing_keys, config, - sapling_nullifiers.map(zair_core::base::SanitiseNullifiers::new), + sapling_nullifiers.map(SanitiseNullifiers::new), gap_tree_mode, sapling_gap_tree_bytes, ) @@ -240,7 +241,7 @@ pub async fn airdrop_claim_from_config( &visitor, &viewing_keys, config, - orchard_nullifiers.map(zair_core::base::SanitiseNullifiers::new), + orchard_nullifiers.map(SanitiseNullifiers::new), gap_tree_mode, orchard_gap_tree_bytes, )