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:
- 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.
- 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.
- 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.
- 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).
- 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.
- Cross-check against consensus with zebra
z_gettreestate at the same heights (zebra returns roots in reversed byte order).
- 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.
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 OrchardShardTree.Environment
ec5b213zally-wallet,zally-storage,zally-chain74abb4bzinder-client,zinder-proto(Ironwood mains)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
WalletError::TreeRootsDiverged.wallet.dbwipe and a fresh rescan from the birthday, so it is not local database corruption.The chain feed is correct
zinder serves a valid, non-empty Orchard frontier at the wallet birthday.
TreeStateAtHeightat height 4,050,200 returns OrchardfinalRoot = 873948679049069b6a463703c5f776d1c6f71eee96542f41066e51c63c00ff1calongside a populatedfinalState, andorchardCommitmentTreeSizeadvances normally (236,668 at the tip used for this reproduction). zinder also serves the legacy Orchardactions(lightwalletdCompactBlockfield 6) that carry this wallet's notes, consensus-verified canonical against zebra.librustzcash
235d581is not the causeAt 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_block_with_runnersreads eachCompactTx'sactions(legacy Orchard, proto field 6), trial-decrypts underOrchardDomain, and pushes oneMerkleHashOrchard::from_cmx(&output.cmx())per action intoorchard_note_commitments. This is gated only by theorchardcargo feature, not by any branch-id or NU6.3 conditional (zcash_client_backend/src/scanning/compact.rs:412-447).ironwood_actions(proto field 9) into a distinct tree and distinctScannedBundles(compact.rs:450-485,compact.rs:528-543). Post-activation blocks do not divert legacy Orchard actions away from the Orchard tree.put_blockscollectsblock.into_commitments().orchard, builds subtrees viabuild_subtrees::<_, ORCHARD_SHARD_HEIGHT>seeded at the caller-suppliedChainStateOrchard frontier, and callswith_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).PutBlocksError::NonSequentialBlocks(wallet.rs:237-246), and a missingorchardCommitmentTreeSizeagainst present actions underflows toTreeSizeInvalid(compact.rs:690-706).WalletCommitmentTreestrait,with_orchard_tree_mutandput_orchard_subtree_rootsare required methods, whilewith_ironwood_tree_muthas a default returningOk(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
ShardTreepopulation. It reads the Orchard root straight out of librustzcash-owned shardtree storage and never computes the tree itself:commitment_tree_rootsreturnsorchard = 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'sWalletWriteinsidestorage.scan_blocks(crates/zally-wallet/src/sync.rs:1558) and throughput_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_rootsiterates both Sapling and Orchard (sync.rs:1461-1497); and the scan-start frontier is carried whole into the scan viafrom_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
finalRootthat zinder'sTreeStateAtHeightreturns at scan start (873948679049069b6a463703c5f776d1c6f71eee96542f41066e51c63c00ff1cat 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:ScannedBlock.orchard()commitments are not appended throughwith_orchard_tree_mutandupdate_tree("Orchard", ...)the way Sapling is, so per-block Orchard appends never land.ChainStatefrontier 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.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/WalletCommitmentTreesimplementation drives Orchard exactly as the librustzcash referenceput_blocksdoes, rather than treating Orchard like Ironwood'sOk(None)default. Ensure thefrom_stateOrchard frontier matches the tree size at scan start and thatput_orchard_subtree_rootscovers the pre-birthday region.Evidence
Live tree-state (zinder
74abb4b, testnet,TreeStateAtHeight).finalRoot = 873948679049069b6a463703c5f776d1c6f71eee96542f41066e51c63c00ff1c, SaplingfinalRoot = 2809bfce6bb818928f263252f9c72ca149d8a6c32913709cd9f2e11fd6358300, with populatedfinalStatefor both.orchardCommitmentTreeSize = 236668,saplingCommitmentTreeSize = 370762at the tip used for this reproduction (height 4,147,234).finalRootin 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.
grpcurl -plaintext 127.0.0.1:19101 zinder.v1.wallet.WalletQuery/CompactBlock -d '{"height":4056276}', base64-decodepayloadBytes, thenprotoc --decode=cash.z.wallet.sdk.rpc.CompactBlockand countactions(Orchard, field 6) versusoutputs(Sapling, field 5).grpcurl -plaintext 127.0.0.1:19101 zinder.v1.wallet.WalletQuery/TreeStateAtHeight -d '{"height":4050200}', base64-decodepayloadBytes, readorchard.commitments.finalRoot.z_gettreestateat the same heights (zebra returns roots in reversed byte order).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_rootscontradicts its documented empty-tree skip. A wallet whose Orchard tree is legitimately empty at a checkpoint (Sapling correct, no Orchard leaves yet) faults sync withTreeRootsDivergedand no escape hatch, contradicting the documented contract that empty trees are skipped.commitment_tree_rootsreturnsSome(empty_root)rather thanNonefor a scanned-but-empty Orchard tree, because scanning creates a checkpoint every block, soroot_at_checkpoint_depth(Some(0))sees that checkpoint (crates/zally-storage/src/sqlite.rs:715-722).verify_tree_rootsskips only when both pools areNone; its success arm passes whensapling_match != Some(false) && orchard_match != Some(false), and its fallthrough diverges whenever either pool isSome(false)(crates/zally-wallet/src/sync.rs:1672-1712). A Sapling-correct, Orchard-empty state yieldsorchard_match == Some(false)and hits the divergence arm, contradicting the doc comment atsync.rs:1631-1633. Suggested direction: at the storage boundary returnNonefor 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_statesilently 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_statefillsorchard_treefrom the/orchard/commitments/finalStateJSON pointer but falls back tounwrap_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:TreeStateAtHeightat height 1,000,000 returns no OrchardfinalRoot), so this default path is reachable. Suggested direction: replaceunwrap_or_default()with a hardMalformedCompactBlockerror at post-Orchard-activation heights, so an un-fed frontier faults loudly rather than seeding a silent empty tree. Apply the same toironwood_treeonce Ironwood is wired.