Skip to content

Orchard commitment-tree root frozen while Sapling tracks the chain, blocking spends with TreeRootsDiverged #7

Description

@gustavovalverde

Summary

A testnet wallet holding legacy Orchard notes cannot spend them. Sync aborts with WalletError::TreeRootsDiverged: the wallet's Orchard note-commitment-tree root never tracks the chain, while the same wallet's Sapling tree tracks correctly. The light-wallet server serves a valid, non-empty Orchard frontier and the relevant Orchard actions; the gap is between those inputs and zally's populated Orchard ShardTree.

Environment

Component Pin Notes
zally ec5b213 zally-wallet, zally-storage, zally-chain
zinder 74abb4b zinder-client, zinder-proto (Ironwood mains)
librustzcash 235d581 [patch.crates-io], PR #2539 (Ironwood scan/storage) + PR #2412 (target_expiry_height)

Chain: Zcash testnet. NU6.3/Ironwood activates at height 4,133,000. The affected wallet's notes are pre-activation legacy Orchard at heights 4,056,276 and 4,057,384. Account birthday is height 4,050,200. The unified address carries receiver_flags = 13 (transparent + Sapling + Orchard).

Symptom

  • Sync returns WalletError::TreeRootsDiverged.
  • The Orchard commitment-tree root does not advance to match the chain, while Sapling matches at every scanned height.
  • The behaviour persists across a full wallet.db wipe and a fresh rescan from the birthday, so it is not local database corruption.
  • The frozen Orchard root is a real non-empty value. It matches neither the canonical empty-tree root nor zinder's Orchard frontier at the birthday height, so the Orchard tree receives some state and then stops advancing.

The chain feed is correct

zinder serves a valid, non-empty Orchard frontier at the wallet birthday. TreeStateAtHeight at height 4,050,200 returns Orchard finalRoot = 873948679049069b6a463703c5f776d1c6f71eee96542f41066e51c63c00ff1c alongside a populated finalState, and orchardCommitmentTreeSize advances normally (236,668 at the tip used for this reproduction). zinder also serves the legacy Orchard actions (lightwalletd CompactBlock field 6) that carry this wallet's notes, consensus-verified canonical against zebra.

librustzcash 235d581 is not the cause

At the pinned revision the scan path builds the Orchard tree from scanned legacy actions, and the reference storage path appends those commitments to the Orchard ShardTree. Both stages are Orchard-complete with no post-activation skip:

  • Scan emits Orchard leaves unconditionally. scan_block_with_runners reads each CompactTx's actions (legacy Orchard, proto field 6), trial-decrypts under OrchardDomain, and pushes one MerkleHashOrchard::from_cmx(&output.cmx()) per action into orchard_note_commitments. This is gated only by the orchard cargo feature, not by any branch-id or NU6.3 conditional (zcash_client_backend/src/scanning/compact.rs:412-447).
  • Ironwood is a separate parallel pool, not a replacement. Ironwood commitments come from ironwood_actions (proto field 9) into a distinct tree and distinct ScannedBundles (compact.rs:450-485, compact.rs:528-543). Post-activation blocks do not divert legacy Orchard actions away from the Orchard tree.
  • The reference storage path appends the Orchard leaves. put_blocks collects block.into_commitments().orchard, builds subtrees via build_subtrees::<_, ORCHARD_SHARD_HEIGHT> seeded at the caller-supplied ChainState Orchard frontier, and calls with_orchard_tree_mut(|orchard_tree| update_tree("Orchard", from_state.final_orchard_tree(), ..., orchard_tree, ...)) (zcash_client_backend/src/data_api/ll/wallet.rs:467-508, wallet.rs:584-604).
  • Inconsistent inputs fail loudly, not silently. A mismatch between the supplied frontier and a block's reported tree size returns PutBlocksError::NonSequentialBlocks (wallet.rs:237-246), and a missing orchardCommitmentTreeSize against present actions underflows to TreeSizeInvalid (compact.rs:690-706).
  • The only NU6.3 default-skip is Ironwood, not Orchard. On the WalletCommitmentTrees trait, with_orchard_tree_mut and put_orchard_subtree_roots are required methods, while with_ironwood_tree_mut has a default returning Ok(None) (zcash_client_backend/src/data_api.rs:3740-3778). Only Ironwood can be silently dropped by a non-overriding backend, and this wallet's notes are pre-Ironwood.

librustzcash HEAD (9eb1f86) sits ahead of the pin but does not change this conclusion for the Orchard scan path.

Root cause locus

zally owns the Orchard ShardTree population. It reads the Orchard root straight out of librustzcash-owned shardtree storage and never computes the tree itself: commitment_tree_roots returns orchard = with_orchard_tree_mut(|tree| tree.root_at_checkpoint_depth(Some(0)))...map(|node| node.to_bytes()) (crates/zally-storage/src/sqlite.rs:701-726). The tree is written only through librustzcash's WalletWrite inside storage.scan_blocks (crates/zally-wallet/src/sync.rs:1558) and through put_orchard_subtree_roots (sqlite.rs:653).

zally does wire Orchard into the pipeline: the chain source maps Orchard to a supported pool and rejects only Ironwood (crates/zally-chain/src/zinder_source.rs:308-314); backfill_subtree_roots iterates both Sapling and Orchard (sync.rs:1461-1497); and the scan-start frontier is carried whole into the scan via from_state (sync.rs:1361-1364). So the Orchard tree is fed a frontier and leaves, yet its root freezes at a non-empty value that never reaches the chain root.

That narrows the fault to zally's Orchard append and checkpoint seam: the scanned Orchard commitments and the backfilled subtree roots are not reconstructing the chain Orchard root the way the equivalent Sapling path does.

Decisive measurement to pin the mechanism

Compare the frozen wallet Orchard root against the Orchard finalRoot that zinder's TreeStateAtHeight returns at scan start (873948679049069b6a463703c5f776d1c6f71eee96542f41066e51c63c00ff1c at height 4,050,200). If they are equal, the frontier was seeded but per-block appends never ran. If they differ, the frontier itself is wrong. This measurement selects the fix location. Candidate mechanisms:

  1. The scanned ScannedBlock.orchard() commitments are not appended through with_orchard_tree_mut and update_tree("Orchard", ...) the way Sapling is, so per-block Orchard appends never land.
  2. The Orchard ChainState frontier passed to the scan does not match the real Orchard tree size at scan start, so appends build from the wrong position and the reconstructed root diverges.
  3. The Orchard subtree-root backfill (sync.rs:1461-1497) does not complete the region below the birthday, leaving the tree unable to reconstruct the full chain root.

Suggested direction

Confirm zally's WalletWrite/WalletCommitmentTrees implementation drives Orchard exactly as the librustzcash reference put_blocks does, rather than treating Orchard like Ironwood's Ok(None) default. Ensure the from_state Orchard frontier matches the tree size at scan start and that put_orchard_subtree_roots covers the pre-birthday region.

Evidence

Live tree-state (zinder 74abb4b, testnet, TreeStateAtHeight).

  • Height 4,050,200 (birthday): Orchard finalRoot = 873948679049069b6a463703c5f776d1c6f71eee96542f41066e51c63c00ff1c, Sapling finalRoot = 2809bfce6bb818928f263252f9c72ca149d8a6c32913709cd9f2e11fd6358300, with populated finalState for both.
  • Chain metadata: orchardCommitmentTreeSize = 236668, saplingCommitmentTreeSize = 370762 at the tip used for this reproduction (height 4,147,234).
  • Height 1,000,000 (pre-NU5): no Orchard finalRoot in the tree-state.

Reported wallet root. 78bdaf718b1d566946420dbc7d755c35e31587d600767d6cda46154429b77403, constant across scanned heights. It does not equal the birthday Orchard frontier above in either byte order, and it is not the canonical empty Orchard tree root.

Reproduction.

  1. Fetch a compact block carrying the Orchard actions and confirm the pool contents:
    grpcurl -plaintext 127.0.0.1:19101 zinder.v1.wallet.WalletQuery/CompactBlock -d '{"height":4056276}', base64-decode payloadBytes, then protoc --decode=cash.z.wallet.sdk.rpc.CompactBlock and count actions (Orchard, field 6) versus outputs (Sapling, field 5).
  2. Fetch the Orchard frontier at scan start:
    grpcurl -plaintext 127.0.0.1:19101 zinder.v1.wallet.WalletQuery/TreeStateAtHeight -d '{"height":4050200}', base64-decode payloadBytes, read orchard.commitments.finalRoot.
  3. Cross-check against consensus with zebra z_gettreestate at the same heights (zebra returns roots in reversed byte order).
  4. Sync the wallet from the birthday and read the Orchard root via commitment_tree_roots; observe it frozen while Sapling advances.

Related, lower-priority findings from the same investigation

Two latent issues share the storage-boundary code and are worth folding into this same fix pass rather than filing separately:

verify_tree_roots contradicts its documented empty-tree skip. A wallet whose Orchard tree is legitimately empty at a checkpoint (Sapling correct, no Orchard leaves yet) faults sync with TreeRootsDiverged and no escape hatch, contradicting the documented contract that empty trees are skipped. commitment_tree_roots returns Some(empty_root) rather than None for a scanned-but-empty Orchard tree, because scanning creates a checkpoint every block, so root_at_checkpoint_depth(Some(0)) sees that checkpoint (crates/zally-storage/src/sqlite.rs:715-722). verify_tree_roots skips only when both pools are None; its success arm passes when sapling_match != Some(false) && orchard_match != Some(false), and its fallthrough diverges whenever either pool is Some(false) (crates/zally-wallet/src/sync.rs:1672-1712). A Sapling-correct, Orchard-empty state yields orchard_match == Some(false) and hits the divergence arm, contradicting the doc comment at sync.rs:1631-1633. Suggested direction: at the storage boundary return None for a provably empty (zero-leaf) tree so the existing (None, ...) guard skips it, applied symmetrically to Sapling. Do not relax the non-empty mismatch arm; a genuinely divergent non-empty root must still fault.

decode_tree_state silently defaults a missing Orchard/Ironwood frontier. When a tree-state response omits the Orchard (or Ironwood) finalState, zally seeds an empty frontier into the scan instead of faulting. For a wallet whose birthday precedes an Orchard tree in the served tree-state, this quietly produces a wrong tree that the root check later blames, hiding the true cause. decode_tree_state fills orchard_tree from the /orchard/commitments/finalState JSON pointer but falls back to unwrap_or_default() (crates/zally-chain/src/zinder_source.rs:379-383). zinder omits the Orchard tree entirely from the tree-state at pre-activation heights (confirmed: TreeStateAtHeight at height 1,000,000 returns no Orchard finalRoot), so this default path is reachable. Suggested direction: replace unwrap_or_default() with a hard MalformedCompactBlock error at post-Orchard-activation heights, so an un-fed frontier faults loudly rather than seeding a silent empty tree. Apply the same to ironwood_tree once Ironwood is wired.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions