From 00ef236e4447de6a8c69e5433e37858d62f054f2 Mon Sep 17 00:00:00 2001 From: Alfredo Garcia Date: Thu, 4 Jun 2026 18:14:16 -0300 Subject: [PATCH] sync: Treat an absent shielded tree state as an empty note commitment tree `fetch_chain_state` errored with "Missing Sapling/Orchard tree state" whenever a shielded pool was active at the requested height but the indexer returned no tree state for it. This crashed the wallet sync task on any chain where a pool is active while its note commitment tree is still empty -- most notably regtest, where NU5 is active from height 1 but the Orchard tree has no commitments yet. An absent tree state is not an error: an empty incremental Merkle tree has no frontier to serialize, so the node (`zebra_rpc::z_get_treestate` maps the tree through `tree.map(|t| t.to_rpc_bytes())`) and the indexer both report `None` for an empty tree. Genuine fetch failures are surfaced separately via the `?` on the `z_get_treestate` call. We therefore treat "pool active but tree state absent" the same as "pool not yet active": an empty frontier. Both the Sapling and Orchard paths had this bug; both are fixed symmetrically. This path requires a live indexer connection and has no unit coverage; it was validated end-to-end against the integration-tests suite (the `wallet.py` and `wallet_orchard_init.py` regtest scenarios sync to completion with this change). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +++ zallet/src/components/sync/steps.rs | 45 +++++++++++------------------ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5500d95..9bae4efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ be considered breaking changes. standalone imported transparent keys (e.g. from a `zcashd` migration). - No longer crashes in regtest mode when a Sapling or NU5 activation height is not defined. +- The wallet sync task no longer exits with a "Missing Sapling/Orchard tree + state" error when a shielded pool is active but its note commitment tree is + still empty (e.g. on regtest, where NU5 is active from height 1). An absent + tree state is now correctly treated as an empty tree. - Zallet now refuses to open wallet databases from incompatible earlier alpha releases instead of attempting to migrate them. - `z_sendmany` no longer drop standalone transparent signing keys when the same diff --git a/zallet/src/components/sync/steps.rs b/zallet/src/components/sync/steps.rs index ff7cc5ee..82c01314 100644 --- a/zallet/src/components/sync/steps.rs +++ b/zallet/src/components/sync/steps.rs @@ -309,36 +309,25 @@ pub(super) async fn fetch_chain_state( .await? .into_parts(); - let final_sapling_tree = if params.is_nu_active(NetworkUpgrade::Sapling, height.0.into()) { - read_frontier_v0( - sapling - .ok_or_else(|| IndexerError::InvalidData { - message: "Missing Sapling tree state".into(), - })? - .as_slice(), - ) - .map_err(|e| IndexerError::InvalidData { - message: format!("{e}"), - })? - } else { - // Sapling is not yet active; the Sapling tree is empty. - Frontier::empty() + // An absent tree state (`None`) means the pool's note commitment tree is empty, not an + // error (fetch errors surface via `?` above). Treat "active but empty" like "not yet + // active": an empty frontier. + let final_sapling_tree = match sapling { + Some(sapling) if params.is_nu_active(NetworkUpgrade::Sapling, height.0.into()) => { + read_frontier_v0(sapling.as_slice()).map_err(|e| IndexerError::InvalidData { + message: format!("{e}"), + })? + } + _ => Frontier::empty(), }; - let final_orchard_tree = if params.is_nu_active(NetworkUpgrade::Nu5, height.0.into()) { - read_frontier_v0( - orchard - .ok_or_else(|| IndexerError::InvalidData { - message: "Missing Orchard tree state".into(), - })? - .as_slice(), - ) - .map_err(|e| IndexerError::InvalidData { - message: format!("{e}"), - })? - } else { - // NU5 is not yet active; the Orchard tree is empty. - Frontier::empty() + let final_orchard_tree = match orchard { + Some(orchard) if params.is_nu_active(NetworkUpgrade::Nu5, height.0.into()) => { + read_frontier_v0(orchard.as_slice()).map_err(|e| IndexerError::InvalidData { + message: format!("{e}"), + })? + } + _ => Frontier::empty(), }; Ok(ChainState::new(