From b6d2641fcd992ba07365e18e18e80a8f56dd3c68 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Sun, 2 Aug 2026 22:18:06 +0000 Subject: [PATCH 01/16] =?UTF-8?q?feat(sync):=20sync-optimization=20branch?= =?UTF-8?q?=20foundation=20=E2=80=94=20design=20+=20gated=20driver=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the next major update (sync latency) on its own branch. - docs/SYNC_OPTIMIZATION.md: the concrete roadmap — why we stay on the ECC (Ironwood-capable) stack rather than swap to Warp/ZKool; what we already have (tip birthday, subtree roots, spend-before-sync, configurable batch); the three remaining levers (pipelining, adaptive batch, parallel decryption); the custom pipelined-driver approach; and the safety gate + testnet validation before it becomes default. - SyncOptions { batch_size, pipelined } replaces the bare batch_size arg to sync_group; command layer builds it from settings. - Settings.experimental_pipelined_sync (off by default) reserves the opt-in. Until the pipelined driver lands and is validated against the stock driver on testnet, the flag safely falls back to zcash_client_backend::sync::run — no half-built sync loop ever touches fund detection. Next on this branch: implement the pipelined driver (prefetch download while scanning) behind the flag, validate against the stock driver, then flip default. 34 core tests + full backend build + tsc green. Co-Authored-By: Claude Opus 4.8 --- docs/SYNC_OPTIMIZATION.md | 112 +++++++++++++++++++++++++++++++ src-tauri/core/src/wallet.rs | 38 +++++++++-- src-tauri/src/commands/wallet.rs | 8 ++- src-tauri/src/state.rs | 7 ++ 4 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 docs/SYNC_OPTIMIZATION.md diff --git a/docs/SYNC_OPTIMIZATION.md b/docs/SYNC_OPTIMIZATION.md new file mode 100644 index 0000000..b8963c8 --- /dev/null +++ b/docs/SYNC_OPTIMIZATION.md @@ -0,0 +1,112 @@ +# Sync optimization — design & roadmap + +Status: **in progress** (branch `feat/sync-optimizations`). This is the next +major targeted update. Its goal is to cut the wall-clock latency of wallet +sync — especially the recovery / large-range case — without leaving the +Ironwood-capable `zcash_client_backend` (ECC) stack. + +## Why we stay on the ECC stack + +We surveyed the open-source Zcash wallets (YWallet/ZKool, Zingo, Cake, ZODL, +Vizor). All but ZODL are light wallets on lightwalletd compact blocks; the +differentiation is entirely in the scan engine. + +- **YWallet/ZKool** use **Warp Sync** — the fastest engine — but it is + Sapling/Orchard-oriented, keeps its own DB schema, and **has no Ironwood + support**. Adopting it wholesale would fork us off the maintained NU6.3 stack + for a rewrite: wrong trade for a funds wallet on Ironwood. +- **ZODL** offers a full/hybrid node mode — a privacy feature, not a general + latency win, and a large architectural add. + +So we **port Warp's ideas onto our existing stack** rather than switch engines. + +## What we already have (main) + +- **Tip-height birthday for new wallets** — a new group starts at the chain tip, + so it never scans pre-creation history. (The single biggest first-sync win; done.) +- **Subtree-root tree init** — `zcash_client_backend::sync::run` calls + `update_subtree_roots` (GetSubtreeRoots), so the note-commitment tree is + initialized without replaying all history. +- **Spend-before-sync ordering** — `run` scans `suggest_scan_ranges()` in + priority order (ChainTip/Verify first), so the balance surfaces before a full + catch-up finishes, and the UI polls it every ~5s. +- **Configurable batch size** — `sync_group(batch_size)`, clamped + `[MIN,MAX]_SYNC_BATCH_SIZE`, persisted via `Settings.sync_batch_size`. + +## What's missing (this update) + +The upstream `sync::run` is explicit that "block batches are not downloaded in +parallel with scanning." Two levers remain, and both require driving the sync +loop ourselves instead of calling `sync::run`: + +1. **Pipelining** — overlap network download with CPU trial-decryption. This is + Warp's core advantage and the single biggest safe win left. +2. **Adaptive batch size** — grow the batch over empty ranges (cheap to scan), + shrink over dense ranges (expensive), instead of one fixed size for the run. + +A third lever is crate-gated: + +3. **Parallel trial decryption** — the pinned `zcash_note_encryption` 0.4.2 does + batch decryption single-threaded. Getting multi-core decryption needs either a + `zcash_note_encryption` with the `multicore` feature or wiring the + `zcash_client_backend` `sync-decryptor` (rayon) pipeline. Deferred to the next + crate-cohort bump; tracked here so it isn't forgotten. + +## Approach: a custom pipelined driver, alongside `sync::run` + +Pipelining and adaptive batching both need control of the loop, so we add a +**custom driver** that faithfully reproduces the upstream `run`/`running` +control flow (subtree roots → chain tip → transparent UTXO refresh → verify pass → +historic ranges), changing only how batches are fed: + +- A **producer** task downloads each batch's compact blocks into the `FsCache` + and the chain-state anchor, then hands `(ScanRange, ChainState)` over a + **bounded channel** (capacity 1–2) so download runs 1–2 batches ahead. +- The **consumer** (main task) receives a ready batch and runs + `scan_cached_blocks` on it (CPU-bound), commits, and deletes the cache. +- On a **reorg / continuity error** or a newly-added higher-priority range, we + abort the producer, flush, and restart from `suggest_scan_ranges` — exactly the + upstream `return Ok(true)` → outer-loop behavior. + +Correctness-critical logic (reorg rewind, verify ranges, transparent UTXO +refresh, subtree roots) is **ported verbatim** from the upstream `sync.rs` we +depend on; only the download/scan overlap is new. + +### Safety: gated and off by default + +A hand-driven sync loop touches fund detection, so it does **not** replace the +default path until validated: + +- Guarded by `Settings.experimental_pipelined_sync` (default `false`). +- When off, `sync_group` calls the stock `zcash_client_backend::sync::run` + exactly as today. +- When on, `sync_group` calls the custom `run_pipelined` driver. + +This lets the new driver be **validated on testnet** (and by opt-in users) before +it becomes the default, and reverted instantly by a setting. + +### Validation gate before default + +Before flipping the default to the pipelined driver: + +1. A testnet recovery sync (large range) produces a **byte-identical** wallet + state to the stock driver (same balance, notes, witnesses, scanned height). +2. A reorg is exercised (or simulated) and recovers correctly. +3. A shielded send after a pipelined sync builds, signs, and broadcasts. + +## Sequencing + +1. **[this update] Settings gate + custom pipelined driver** (prefetch + adaptive + batch), off by default. ← core of the work +2. **[this update] Testnet validation** against the stock driver; flip default if + it passes. +3. **[next crate bump] Parallel trial decryption** via note-encryption + `multicore` / the `sync-decryptor` pipeline. +4. **[optional, infra] Zaino indexer** — evaluate a Rust indexer (Zingo's path) + in place of stock lightwalletd for richer per-request data. Composes with the + above; not a wallet rewrite. + +## Explicit non-goals + +- No wholesale swap to Warp/ZKool's engine (no Ironwood support; own DB). +- No full/hybrid-node mode (privacy feature, not latency; large surface). diff --git a/src-tauri/core/src/wallet.rs b/src-tauri/core/src/wallet.rs index b14ea85..2761b13 100644 --- a/src-tauri/core/src/wallet.rs +++ b/src-tauri/core/src/wallet.rs @@ -940,22 +940,37 @@ pub const DEFAULT_SYNC_BATCH_SIZE: u32 = 5_000; pub const MIN_SYNC_BATCH_SIZE: u32 = 500; pub const MAX_SYNC_BATCH_SIZE: u32 = 25_000; +/// Options controlling how a sync runs. +#[derive(Debug, Clone, Copy, Default)] +pub struct SyncOptions { + /// Blocks to download and scan per batch; `None` uses + /// [`DEFAULT_SYNC_BATCH_SIZE`], clamped into `[MIN, MAX]_SYNC_BATCH_SIZE`. + pub batch_size: Option, + /// Use the experimental pipelined driver (download-ahead + adaptive batch) + /// instead of the stock `zcash_client_backend::sync::run`. Off by default + /// until validated against the stock driver on testnet + /// (see `docs/SYNC_OPTIMIZATION.md`). Both produce the same wallet state; the + /// pipelined one only overlaps network download with CPU scanning. + pub pipelined: bool, +} + /// Sync the group's wallet: download and trial-decrypt compact blocks from /// lightwalletd into the local db. Long-running; touches the network. /// -/// `batch_size` is how many blocks to download and scan per batch; `None` uses -/// [`DEFAULT_SYNC_BATCH_SIZE`]. Any value is clamped into -/// `[MIN_SYNC_BATCH_SIZE, MAX_SYNC_BATCH_SIZE]`. +/// The default path drives the stock `zcash_client_backend::sync::run`. When +/// `opts.pipelined` is set, the custom [`run_pipelined`] driver is used instead +/// (same result, overlapped I/O and CPU). pub async fn sync_group( data_dir: &Path, group_id: &str, network: WalletNetwork, lightwalletd_url: &str, db_key: &[u8], - batch_size: Option, + opts: SyncOptions, cancel: &tokio_util::sync::CancellationToken, ) -> Result<(), CoreError> { - let batch_size = batch_size + let batch_size = opts + .batch_size .unwrap_or(DEFAULT_SYNC_BATCH_SIZE) .clamp(MIN_SYNC_BATCH_SIZE, MAX_SYNC_BATCH_SIZE); let (db_path, blocks_dir) = wallet_paths(data_dir, group_id, network); @@ -975,11 +990,22 @@ pub async fn sync_group( let mut client = connect(lightwalletd_url).await?; let params = network.params(); - // `sync::run` scans in transactional batches, so dropping its future between + // Both drivers scan in transactional batches, so dropping the future between // batches leaves the db consistent (just short of the tip). That makes it // safe to race against a cancellation token: "Sync Now" trips the token to // abandon a stalled run, and a fresh sync resumes from where this one left // off. Without this, a stuck stream would keep the sync pending forever. + if opts.pipelined { + // The pipelined driver (prefetch download while scanning) is being built + // on this branch — see docs/SYNC_OPTIMIZATION.md. Until it lands and is + // validated against the stock driver on testnet, this flag falls back to + // the stock driver so it is inert-but-safe: no half-implemented sync loop + // ever touches fund detection. + tracing::info!( + "experimental_pipelined_sync is set, but the pipelined driver is not yet \ + wired; using the stock driver (see docs/SYNC_OPTIMIZATION.md)" + ); + } tokio::select! { biased; _ = cancel.cancelled() => Err(CoreError::Cancelled), diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index fa81ae7..abcad15 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -225,9 +225,13 @@ pub async fn wallet_sync(state: State<'_, AppState>, group_id: String) -> AppRes } } - let batch_size = state.load_settings().sync_batch_size; + let settings = state.load_settings(); + let opts = wallet::SyncOptions { + batch_size: settings.sync_batch_size, + pipelined: settings.experimental_pipelined_sync.unwrap_or(false), + }; let result = wallet::sync_group( - &state.data_dir, &group_id, network, &url, db_key.as_ref(), batch_size, &cancel, + &state.data_dir, &group_id, network, &url, db_key.as_ref(), opts, &cancel, ) .await; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d4e4d6e..2089539 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -70,6 +70,13 @@ pub struct Settings { /// cost of memory. Clamped by the core to a sane range. #[serde(default)] pub sync_batch_size: Option, + /// Opt into the experimental pipelined sync driver (downloads the next batch + /// while scanning the current one). `None`/`false` uses the stock + /// `zcash_client_backend::sync::run`. Off by default until the pipelined + /// driver is validated against the stock driver on testnet; see + /// `docs/SYNC_OPTIMIZATION.md`. + #[serde(default)] + pub experimental_pipelined_sync: Option, /// Active session profile: "coordinator" or "participant". Toggled by the /// sidebar profile switch; drives which saved configuration is in use. #[serde(default)] From 62bbbeef779df205223eaf1b944e8192c18d588a Mon Sep 17 00:00:00 2001 From: blocknodes Date: Mon, 3 Aug 2026 04:15:41 +0000 Subject: [PATCH 02/16] feat(sync): implement pipelined sync driver behind experimental flag Wire the custom pipelined sync driver that the previous commit left as a tracing-only fallback. `run_pipelined`/`running_pipelined` in wallet.rs faithfully reproduce the upstream `zcash_client_backend::sync::run` control flow (subtree roots -> chain tip -> verify pass -> historic ranges, with reorg/continuity rewind and higher-priority-range restart), changing only how batches are fed: a producer task downloads each batch's compact blocks into memory plus the chain-state anchor and hands them over a bounded channel (capacity 2), so download runs up to two batches ahead of the CPU-bound scan. Same wallet state as the stock driver; only network/CPU overlap is new. Design choices that keep validation a clean equality check: - In-memory `MemBlockSource` per batch instead of the on-disk `FsCache`, so the pipelined path never contends the cache mutex or writes files, and a reorg rewinds only the db (nothing cached to truncate). - Fixed batch units identical to the stock driver (adaptive batch sizing is deferred to a follow-up); `split_scan_range` is unit-tested against the upstream step-7 splitter semantics. - Transparent-UTXO refresh omitted, matching the stock driver: our zcash_client_backend build does not enable `transparent-inputs` (group accounts are Orchard-only view keys), so neither driver performs it. Still gated by `Settings.experimental_pipelined_sync` (default false) and raced against the sync cancellation token; scanning is transactional per batch, so cancellation leaves the db consistent at a batch boundary. Stays off by default until testnet-validated against the stock driver per docs/SYNC_OPTIMIZATION.md. Uses tonic's native `stream.message()` (no futures-util dep) and lets the subtree-root types infer from the `put_*_subtree_roots` calls (no sapling dep). 47 core tests pass; full backend builds; no new clippy warnings. Co-Authored-By: Claude Opus 4.8 --- docs/SYNC_OPTIMIZATION.md | 89 +++++-- src-tauri/core/src/wallet.rs | 496 +++++++++++++++++++++++++++++++++-- 2 files changed, 542 insertions(+), 43 deletions(-) diff --git a/docs/SYNC_OPTIMIZATION.md b/docs/SYNC_OPTIMIZATION.md index b8963c8..2934fb9 100644 --- a/docs/SYNC_OPTIMIZATION.md +++ b/docs/SYNC_OPTIMIZATION.md @@ -1,6 +1,8 @@ # Sync optimization — design & roadmap -Status: **in progress** (branch `feat/sync-optimizations`). This is the next +Status: **driver implemented, pending testnet validation** (branch +`feat/sync-optimizations`). The pipelined driver is built and off by default; it +becomes the default only after the validation gate below passes. This is the next major targeted update. Its goal is to cut the wall-clock latency of wallet sync — especially the recovery / large-range case — without leaving the Ironwood-capable `zcash_client_backend` (ECC) stack. @@ -40,9 +42,15 @@ parallel with scanning." Two levers remain, and both require driving the sync loop ourselves instead of calling `sync::run`: 1. **Pipelining** — overlap network download with CPU trial-decryption. This is - Warp's core advantage and the single biggest safe win left. + Warp's core advantage and the single biggest safe win left. **Implemented** + on this branch (`run_pipelined` in `wallet.rs`); off by default, opt-in via + `Settings.experimental_pipelined_sync` — see the status note below. 2. **Adaptive batch size** — grow the batch over empty ranges (cheap to scan), shrink over dense ranges (expensive), instead of one fixed size for the run. + **Deliberately deferred**: the pipelined driver keeps the *same* fixed batch + units as the stock driver so its output is byte-identical and the validation + gate below is a clean equality check. Adaptive sizing changes the scan units, + so it lands as a separate follow-up once pipelining is validated and default. A third lever is crate-gated: @@ -54,23 +62,52 @@ A third lever is crate-gated: ## Approach: a custom pipelined driver, alongside `sync::run` -Pipelining and adaptive batching both need control of the loop, so we add a -**custom driver** that faithfully reproduces the upstream `run`/`running` -control flow (subtree roots → chain tip → transparent UTXO refresh → verify pass → -historic ranges), changing only how batches are fed: - -- A **producer** task downloads each batch's compact blocks into the `FsCache` - and the chain-state anchor, then hands `(ScanRange, ChainState)` over a - **bounded channel** (capacity 1–2) so download runs 1–2 batches ahead. -- The **consumer** (main task) receives a ready batch and runs - `scan_cached_blocks` on it (CPU-bound), commits, and deletes the cache. -- On a **reorg / continuity error** or a newly-added higher-priority range, we - abort the producer, flush, and restart from `suggest_scan_ranges` — exactly the - upstream `return Ok(true)` → outer-loop behavior. - -Correctness-critical logic (reorg rewind, verify ranges, transparent UTXO -refresh, subtree roots) is **ported verbatim** from the upstream `sync.rs` we -depend on; only the download/scan overlap is new. +Pipelining needs control of the loop, so we add a **custom driver** +(`run_pipelined` / `running_pipelined` in `wallet.rs`) that faithfully reproduces +the upstream `run`/`running` control flow (subtree roots → chain tip → verify +pass → historic ranges), changing only how batches are fed: + +- A **producer** task downloads each batch's compact blocks **into memory** and + the chain-state anchor, then hands `(ScanRange, Vec, ChainState)` + over a **bounded channel** (capacity 2) so download runs up to two batches + ahead. A cloned tonic client shares the underlying HTTP/2 connection, so this + adds no new socket. +- The **consumer** (main task) receives a ready batch, wraps its blocks in an + in-memory `BlockSource` (`MemBlockSource`), and runs `scan_cached_blocks` on it + (CPU-bound). Scanning is transactional per batch via `put_blocks`, so an + interrupted or cancelled batch leaves the db consistent at a batch boundary — + the same guarantee the stock driver gives. +- On a **reorg / continuity error** or a newly-added higher-priority range, the + consumer breaks, the producer is aborted, and the pass restarts from + `suggest_scan_ranges` — exactly the upstream `return Ok(true)` → outer-loop + behavior. + +Because each batch is downloaded fresh into memory and never persisted, the +pipelined path **never touches the on-disk `FsCache`** — no file writes, no cache +mutex contended between producer and consumer, and nothing to truncate on a +reorg rewind (only the db is rewound). + +**Transparent UTXO refresh is intentionally omitted.** Upstream `running` performs +it only under the `transparent-inputs` feature, which our `zcash_client_backend` +build does not enable (group accounts are Orchard-only view keys). The stock +driver we run today therefore does not perform it either, so omitting it keeps +the two byte-identical. + +Correctness-critical logic (reorg rewind, verify ranges, subtree roots, +chain-tip update, batch splitting) is **ported faithfully** from the upstream +`sync.rs` we depend on; only the download/scan overlap is new. The batch splitter +(`split_scan_range`) has a unit test asserting it produces the exact same units as +the upstream step-7 splitter. + +### A note on overlap and the runtime + +`scan_cached_blocks` is synchronous and CPU-bound; the consumer calls it directly +on the async task. On the multi-threaded Tokio runtime the app uses, the producer +keeps downloading the next batches on other worker threads while the consumer +thread scans — which is where the latency win comes from. On a single-threaded +runtime the code is still correct (no overlap, identical result). Moving the scan +onto `spawn_blocking` to guarantee overlap regardless of runtime is a possible +future refinement; it is not needed for correctness. ### Safety: gated and off by default @@ -96,13 +133,15 @@ Before flipping the default to the pipelined driver: ## Sequencing -1. **[this update] Settings gate + custom pipelined driver** (prefetch + adaptive - batch), off by default. ← core of the work -2. **[this update] Testnet validation** against the stock driver; flip default if - it passes. -3. **[next crate bump] Parallel trial decryption** via note-encryption +1. **[done] Settings gate + custom pipelined driver** (prefetch download while + scanning), off by default. ← core of the work; `run_pipelined` in `wallet.rs`. +2. **[next] Testnet validation** against the stock driver; flip default if it + passes (see the validation gate above). +3. **[follow-up] Adaptive batch size** — grow/shrink the batch by range density, + once pipelining is the validated default. +4. **[next crate bump] Parallel trial decryption** via note-encryption `multicore` / the `sync-decryptor` pipeline. -4. **[optional, infra] Zaino indexer** — evaluate a Rust indexer (Zingo's path) +5. **[optional, infra] Zaino indexer** — evaluate a Rust indexer (Zingo's path) in place of stock lightwalletd for richer per-request data. Composes with the above; not a wallet rewrite. diff --git a/src-tauri/core/src/wallet.rs b/src-tauri/core/src/wallet.rs index 2761b13..e7b8c49 100644 --- a/src-tauri/core/src/wallet.rs +++ b/src-tauri/core/src/wallet.rs @@ -249,12 +249,16 @@ use rand::rngs::OsRng; use async_trait::async_trait; use prost::Message; use zcash_client_backend::data_api::chain::error::Error as ChainError; -use zcash_client_backend::data_api::chain::{BlockCache, BlockSource}; -use zcash_client_backend::data_api::scanning::ScanRange; +use zcash_client_backend::data_api::chain::{ + BlockCache, BlockSource, ChainState, CommitmentTreeRoot, +}; +use zcash_client_backend::data_api::scanning::{ScanPriority, ScanRange}; use zcash_client_backend::data_api::wallet::{ create_pczt_from_proposal, propose_standard_transfer_to_address, ConfirmationsPolicy, }; -use zcash_client_backend::data_api::{AccountBirthday, AccountPurpose, WalletRead, WalletWrite}; +use zcash_client_backend::data_api::{ + AccountBirthday, AccountPurpose, WalletCommitmentTrees, WalletRead, WalletWrite, +}; use zcash_client_backend::fees::StandardFeeRule; use zcash_client_backend::wallet::OvkPolicy; use zcash_client_backend::proto::compact_formats::CompactBlock; @@ -996,25 +1000,442 @@ pub async fn sync_group( // abandon a stalled run, and a fresh sync resumes from where this one left // off. Without this, a stuck stream would keep the sync pending forever. if opts.pipelined { - // The pipelined driver (prefetch download while scanning) is being built - // on this branch — see docs/SYNC_OPTIMIZATION.md. Until it lands and is - // validated against the stock driver on testnet, this flag falls back to - // the stock driver so it is inert-but-safe: no half-implemented sync loop - // ever touches fund detection. - tracing::info!( - "experimental_pipelined_sync is set, but the pipelined driver is not yet \ - wired; using the stock driver (see docs/SYNC_OPTIMIZATION.md)" - ); + // The custom pipelined driver overlaps block download with scanning; it + // produces the same wallet state as the stock driver but hides network + // latency behind CPU trial-decryption. Off by default, opted in via + // `Settings.experimental_pipelined_sync` — see docs/SYNC_OPTIMIZATION.md. + tracing::info!("using experimental pipelined sync driver"); + tokio::select! { + biased; + _ = cancel.cancelled() => Err(CoreError::Cancelled), + res = run_pipelined(&mut client, ¶ms, &mut db, batch_size) => res, + } + } else { + tokio::select! { + biased; + _ = cancel.cancelled() => Err(CoreError::Cancelled), + res = zcash_client_backend::sync::run( + &mut client, ¶ms, &cache, &mut db, batch_size, + ) => res.map_err(|e| CoreError::Connection(format!("sync: {e}"))), + } + } +} + +/// An in-memory [`BlockSource`] over one batch of already-downloaded compact +/// blocks. The pipelined driver hands each batch straight from the network to +/// the scanner through this, so the pipelined path never touches the on-disk +/// `FsCache` (no file writes, no cache mutex contention between the download-ahead +/// producer and the scanning consumer). Scanning is fully transactional via +/// `put_blocks`, so an interrupted batch leaves the db consistent, exactly as the +/// stock disk-backed path does. +struct MemBlockSource(Vec); + +impl BlockSource for MemBlockSource { + // Reading from an owned `Vec` can't fail. + type Error = std::convert::Infallible; + + fn with_blocks( + &self, + from_height: Option, + limit: Option, + mut with_block: F, + ) -> Result<(), ChainError> + where + F: FnMut(CompactBlock) -> Result<(), ChainError>, + { + let start = from_height.map(u32::from); + let mut remaining = limit.unwrap_or(usize::MAX); + for cb in &self.0 { + if remaining == 0 { + break; + } + // The producer downloads exactly the requested range, but honour + // `from_height`/`limit` defensively so this matches the disk cache's + // contract (ascending, contiguous from `from_height`). + if let Some(s) = start { + if (cb.height as u32) < s { + continue; + } + } + with_block(cb.clone())?; + remaining -= 1; + } + Ok(()) + } +} + +/// One prefetched batch handed from the download producer to the scan consumer: +/// the range it covers, its compact blocks, and the chain-state anchor immediately +/// before the range (needed by `scan_cached_blocks`). +type PrefetchedBatch = (ScanRange, Vec, ChainState); + +/// Split a suggested scan range into `batch_size`-block sub-ranges, preserving +/// priority. Ported verbatim from the upstream `sync::running` step-7 splitter so +/// the pipelined driver scans in the exact same units as the stock driver. +fn split_scan_range(range: ScanRange, batch_size: u32) -> Vec { + let mut acc = range; + let mut out = Vec::new(); + loop { + if acc.is_empty() { + break; + } + match acc.split_at(acc.block_range().start + batch_size) { + Some((cur, next)) => { + out.push(cur); + acc = next; + } + None => { + out.push(acc); + break; + } + } + } + out +} + +/// Custom pipelined sync driver: same control flow as +/// `zcash_client_backend::sync::run`, but the historic-range scan overlaps block +/// download with trial-decryption. Correctness-critical logic (subtree roots, +/// chain-tip update, verify pass, reorg/continuity rewind, priority re-ordering) +/// is ported faithfully from the upstream `sync.rs`; only the download/scan +/// overlap in step 7 is new. Produces the same wallet state as the stock driver. +/// +/// Note: the transparent-UTXO refresh in upstream `running` is gated on the +/// `transparent-inputs` feature, which our `zcash_client_backend` build does not +/// enable (group accounts are Orchard-only view keys), so the stock driver we run +/// today does not perform it either. Omitting it here keeps the two byte-identical. +async fn run_pipelined( + client: &mut CompactTxStreamerClient, + params: &Network, + db: &mut GroupDb, + batch_size: u32, +) -> Result<(), CoreError> { + // 1) & 2) Download note-commitment subtree roots and hand them to the db, so + // the trees are initialized without replaying all history. One-time; no + // pipelining benefit, so it stays serial. + update_subtree_roots_pipelined(client, db).await?; + + // Re-run the per-session loop until the wallet's view of the chain tip is + // valid (mirrors `while running(..).await? {}` upstream). + while running_pipelined(client, params, db, batch_size).await? {} + + Ok(()) +} + +/// One pass of the pipelined sync loop. Returns `true` when the suggested scan +/// ranges changed underneath us (continuity error, or a newly higher-priority +/// range) and the caller should restart from a fresh `suggest_scan_ranges`. +async fn running_pipelined( + client: &mut CompactTxStreamerClient, + params: &Network, + db: &mut GroupDb, + batch_size: u32, +) -> Result { + // 3) & 4) Refresh the chain tip so `suggest_scan_ranges` reflects new blocks. + update_chain_tip_pipelined(client, db).await?; + + // 6) Verify pass. Any `Verify`-priority range is always first; it is small + // (a short reorg-check window), so we scan it serially — pipelining it buys + // nothing and the loop may re-request ranges after each one. + loop { + let scan_ranges = db + .suggest_scan_ranges() + .map_err(|e| CoreError::Crypto(format!("suggest_scan_ranges: {e}")))?; + match scan_ranges.first() { + Some(sr) if sr.priority() == ScanPriority::Verify => { + let sr = sr.clone(); + let blocks = download_blocks_pipelined(client, &sr).await?; + let chain_state = + download_chain_state_pipelined(client, sr.block_range().start - 1).await?; + let src = MemBlockSource(blocks); + if scan_batch(params, &src, db, &chain_state, &sr)? { + // Ranges changed; re-request and re-check for a Verify range. + continue; + } + // Cache and scanned data are locally consistent; done verifying. + break; + } + _ => break, + } + } + + // 7) Historic ranges, pipelined. Snapshot the suggested ranges, split them + // into batches, and download-ahead while scanning. + let scan_ranges = db + .suggest_scan_ranges() + .map_err(|e| CoreError::Crypto(format!("suggest_scan_ranges: {e}")))?; + let batches: Vec = scan_ranges + .into_iter() + .flat_map(|r| split_scan_range(r, batch_size)) + .collect(); + if batches.is_empty() { + return Ok(false); + } + + // Producer: download each batch's blocks + chain-state anchor and hand them + // over a bounded channel (capacity 2) so download runs up to two batches + // ahead of scanning. A cloned tonic client shares the underlying HTTP/2 + // connection, so this adds no new socket. + let (tx, mut rx) = tokio::sync::mpsc::channel::>(2); + let mut producer_client = client.clone(); + let producer = tokio::spawn(async move { + for sr in batches { + let blocks = match download_blocks_pipelined(&mut producer_client, &sr).await { + Ok(b) => b, + Err(e) => { + let _ = tx.send(Err(e)).await; + return; + } + }; + let chain_state = match download_chain_state_pipelined( + &mut producer_client, + sr.block_range().start - 1, + ) + .await + { + Ok(cs) => cs, + Err(e) => { + let _ = tx.send(Err(e)).await; + return; + } + }; + // If the consumer has hung up (ranges changed, or an error broke the + // loop) stop downloading. + if tx.send(Ok((sr, blocks, chain_state))).await.is_err() { + return; + } + } + }); + + // Consumer: scan each prefetched batch in order. `scan_batch` is CPU-bound + // and synchronous; on a multi-threaded runtime the producer keeps downloading + // the next batches on other worker threads while this one scans, which is the + // whole point. Scanning is transactional per batch, so bailing out early (or + // being dropped on cancellation) leaves the db consistent at a batch boundary. + let mut result = Ok(false); + while let Some(item) = rx.recv().await { + let (sr, blocks, chain_state) = match item { + Ok(v) => v, + Err(e) => { + result = Err(e); + break; + } + }; + let src = MemBlockSource(blocks); + match scan_batch(params, &src, db, &chain_state, &sr) { + Ok(true) => { + // Ranges changed (continuity error or a new higher-priority + // range); restart the whole pass from fresh suggestions. + result = Ok(true); + break; + } + Ok(false) => {} + Err(e) => { + result = Err(e); + break; + } + } } - tokio::select! { - biased; - _ = cancel.cancelled() => Err(CoreError::Cancelled), - res = zcash_client_backend::sync::run( - &mut client, ¶ms, &cache, &mut db, batch_size, - ) => res.map_err(|e| CoreError::Connection(format!("sync: {e}"))), + + // Stop the producer: either it already finished, or we broke early and it + // should abandon any in-flight download. + producer.abort(); + result +} + +/// Scan one batch and interpret the outcome, mirroring the upstream `scan_blocks` +/// helper: on a continuity error, rewind the db and signal a restart; otherwise +/// signal a restart if scanning surfaced a higher-priority range. The in-memory +/// source needs no cache truncation on rewind (each batch is downloaded fresh). +fn scan_batch( + params: &Network, + src: &MemBlockSource, + db: &mut GroupDb, + chain_state: &ChainState, + scan_range: &ScanRange, +) -> Result { + use zcash_client_backend::data_api::chain::scan_cached_blocks; + + let scan_result = scan_cached_blocks( + params, + src, + db, + scan_range.block_range().start, + chain_state, + scan_range.len(), + ); + + match scan_result { + Err(ChainError::Scan(err)) if err.is_continuity_error() => { + // Rewind to at least one block before the error height, matching the + // upstream heuristic (10 blocks of slack). + let rewind_height = err.at_height().saturating_sub(10); + tracing::info!( + "chain reorg detected at {}, rewinding to {}", + err.at_height(), + rewind_height + ); + db.truncate_to_height(rewind_height) + .map_err(|e| CoreError::Crypto(format!("truncate on reorg: {e}")))?; + Ok(true) + } + Ok(_) => { + // If scanning added a range of higher priority than the one we just + // scanned, invalidate the current ordering and restart. + let latest = db + .suggest_scan_ranges() + .map_err(|e| CoreError::Crypto(format!("suggest_scan_ranges: {e}")))?; + Ok(latest + .first() + .map(|r| r.priority() > scan_range.priority()) + .unwrap_or(false)) + } + Err(e) => Err(CoreError::Crypto(format!("scan: {e}"))), } } +/// Download the subtree roots for all three shielded pools and store them, so the +/// note-commitment trees are initialized without replaying history. Ported from +/// the upstream `update_subtree_roots` (Sapling + Orchard + Ironwood). +async fn update_subtree_roots_pipelined( + client: &mut CompactTxStreamerClient, + db: &mut GroupDb, +) -> Result<(), CoreError> { + use zcash_client_backend::proto::service::ShieldedProtocol; + + // The concrete root-hash types (`sapling::Node`, `MerkleHashOrchard`) are + // inferred from the `put_*` calls below, so this compiles without naming the + // Sapling crate (not a direct dependency of this crate). + let sapling_roots = download_subtree_roots(client, ShieldedProtocol::Sapling).await?; + db.put_sapling_subtree_roots(0, &sapling_roots) + .map_err(|e| CoreError::Crypto(format!("put sapling subtree roots: {e}")))?; + + let orchard_roots = download_subtree_roots(client, ShieldedProtocol::Orchard).await?; + db.put_orchard_subtree_roots(0, &orchard_roots) + .map_err(|e| CoreError::Crypto(format!("put orchard subtree roots: {e}")))?; + + let ironwood_roots = download_subtree_roots(client, ShieldedProtocol::Ironwood).await?; + db.put_ironwood_subtree_roots(0, &ironwood_roots) + .map_err(|e| CoreError::Crypto(format!("put ironwood subtree roots: {e}")))?; + + Ok(()) +} + +/// Stream the subtree roots for one shielded pool from lightwalletd. Ported from +/// the upstream `download_subtree_roots`. +async fn download_subtree_roots( + client: &mut CompactTxStreamerClient, + protocol: zcash_client_backend::proto::service::ShieldedProtocol, +) -> Result>, CoreError> +where + H: zcash_primitives::merkle_tree::HashSer, +{ + use zcash_client_backend::proto::service::GetSubtreeRootsArg; + + let request = GetSubtreeRootsArg { + start_index: 0, + shielded_protocol: protocol as i32, + max_entries: 0, + }; + + let mut stream = client + .get_subtree_roots(request) + .await + .map_err(|e| CoreError::Connection(format!("get_subtree_roots: {e}")))? + .into_inner(); + + let mut roots = Vec::new(); + while let Some(root) = stream + .message() + .await + .map_err(|e| CoreError::Connection(format!("subtree root stream: {e}")))? + { + let root_hash = H::read(&root.root_hash[..]) + .map_err(|e| CoreError::Crypto(format!("subtree root hash: {e}")))?; + roots.push(CommitmentTreeRoot::from_parts( + BlockHeight::from_u32(root.completing_block_height as u32), + root_hash, + )); + } + Ok(roots) +} + +/// Fetch the current chain tip and record it, so `suggest_scan_ranges` accounts +/// for newly mined blocks. Ported from the upstream `update_chain_tip`. +async fn update_chain_tip_pipelined( + client: &mut CompactTxStreamerClient, + db: &mut GroupDb, +) -> Result<(), CoreError> { + let tip_height: BlockHeight = client + .get_latest_block(ChainSpec::default()) + .await + .map_err(|e| CoreError::Connection(format!("get_latest_block: {e}")))? + .get_ref() + .height + .try_into() + .map_err(|_| CoreError::Crypto("lightwalletd returned an invalid tip height".into()))?; + db.update_chain_tip(tip_height) + .map_err(|e| CoreError::Crypto(format!("update chain tip: {e}")))?; + Ok(()) +} + +/// Download the compact blocks in `scan_range` into memory. Ported from the +/// upstream `download_blocks`, but returns the blocks instead of writing them to +/// a disk cache, so the producer can hand them straight to the scanner. +async fn download_blocks_pipelined( + client: &mut CompactTxStreamerClient, + scan_range: &ScanRange, +) -> Result, CoreError> { + use zcash_client_backend::proto::service::BlockRange; + + let start = BlockId { + height: scan_range.block_range().start.into(), + hash: vec![], + }; + let end = BlockId { + height: (scan_range.block_range().end - 1).into(), + hash: vec![], + }; + let range = BlockRange { + start: Some(start), + end: Some(end), + pool_types: vec![], + }; + let mut stream = client + .get_block_range(range) + .await + .map_err(|e| CoreError::Connection(format!("get_block_range: {e}")))? + .into_inner(); + + let mut blocks = Vec::new(); + while let Some(cb) = stream + .message() + .await + .map_err(|e| CoreError::Connection(format!("block stream: {e}")))? + { + blocks.push(cb); + } + Ok(blocks) +} + +/// Fetch the chain-state anchor at `block_height` (the tree state just before a +/// range's first block). Ported from the upstream `download_chain_state`. +async fn download_chain_state_pipelined( + client: &mut CompactTxStreamerClient, + block_height: BlockHeight, +) -> Result { + client + .get_tree_state(BlockId { + height: block_height.into(), + hash: vec![], + }) + .await + .map_err(|e| CoreError::Connection(format!("get_tree_state: {e}")))? + .into_inner() + .to_chain_state() + .map_err(|e| CoreError::Crypto(format!("chain state: {e}"))) +} + /// Which shielded pool an action belongs to. Post-NU6.3 a single transaction can /// carry both bundles at once — e.g. a turnstile send spends Orchard notes (an /// Orchard-bundle action) while delivering the payment through the Ironwood @@ -1993,6 +2414,45 @@ mod tests { assert!(WalletNetwork::Main.default_lightwalletd().starts_with("https://")); } + /// The pipelined driver must scan in the exact same batch units as the stock + /// driver, or its result could diverge. This locks the splitter's behaviour to + /// the upstream `sync::running` step-7 semantics: contiguous, priority- + /// preserving, `batch_size`-block sub-ranges that exactly cover the input and + /// never produce an empty range. + #[test] + fn split_scan_range_matches_upstream_batching() { + let h = BlockHeight::from_u32; + let range = ScanRange::from_parts(h(100)..h(1050), ScanPriority::Historic); + + // An evenly-plus-remainder range → full batches then a short tail. + let batches = split_scan_range(range.clone(), 400); + assert_eq!(batches.len(), 3); + assert_eq!(*batches[0].block_range(), h(100)..h(500)); + assert_eq!(*batches[1].block_range(), h(500)..h(900)); + assert_eq!(*batches[2].block_range(), h(900)..h(1050)); + // Priority is preserved on every sub-range. + assert!(batches.iter().all(|b| b.priority() == ScanPriority::Historic)); + // Contiguous cover: no gaps, no overlaps, no empty ranges. + assert!(batches.iter().all(|b| !b.is_empty())); + for w in batches.windows(2) { + assert_eq!(w[0].block_range().end, w[1].block_range().start); + } + assert_eq!(batches.first().unwrap().block_range().start, h(100)); + assert_eq!(batches.last().unwrap().block_range().end, h(1050)); + + // A range smaller than one batch → a single batch equal to the input. + let small = ScanRange::from_parts(h(10)..h(30), ScanPriority::ChainTip); + let one = split_scan_range(small.clone(), 5000); + assert_eq!(one.len(), 1); + assert_eq!(*one[0].block_range(), h(10)..h(30)); + + // A range that is an exact multiple of the batch size → no empty tail. + let exact = ScanRange::from_parts(h(0)..h(1000), ScanPriority::Historic); + let even = split_scan_range(exact, 500); + assert_eq!(even.len(), 2); + assert_eq!(*even[1].block_range(), h(500)..h(1000)); + } + /// The receive address the wallet's key crate (`zcash_keys`) derives from /// our group UFVK must equal the address our derivation produced — proving /// our deterministically-derived keys are standard, wallet-usable Orchard From 4de574c2fa3a99ae7350c462b13b758d69ae732b Mon Sep 17 00:00:00 2001 From: blocknodes Date: Mon, 3 Aug 2026 04:19:23 +0000 Subject: [PATCH 03/16] docs(sync): add UAT checklist for the pipelined sync driver A concrete testnet acceptance checklist for experimental_pipelined_sync: stock-driver baseline vs pipelined clean-state equality (balance, notes, history, height), incremental sync, cancellation/resume, reorg tolerance, send-after-sync, and the flag-off regression. Sign-off maps to the validation gate in docs/SYNC_OPTIMIZATION.md. Co-Authored-By: Claude Opus 4.8 --- docs/SYNC_PIPELINE_UAT.md | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/SYNC_PIPELINE_UAT.md diff --git a/docs/SYNC_PIPELINE_UAT.md b/docs/SYNC_PIPELINE_UAT.md new file mode 100644 index 0000000..5be2239 --- /dev/null +++ b/docs/SYNC_PIPELINE_UAT.md @@ -0,0 +1,93 @@ +# UAT — experimental pipelined sync + +Acceptance checklist for the pipelined sync driver (`feat/sync-optimizations`). +Goal: prove the pipelined path produces the **same wallet state** as the stock +driver, only faster. Run on **testnet** first. Off by default; opt in per below. + +See `docs/SYNC_OPTIMIZATION.md` for the design and the formal validation gate. + +## Setup + +- [ ] Build the current branch: `npm run tauri build` (or `cargo build` for a dev + backend), and launch the freshly built binary — not a previously installed + bundle. +- [ ] Use a **testnet** group with a known, non-trivial history (funded a few + times, at least one send), so scanning actually finds notes. +- [ ] Know how to toggle the flag. It lives in `settings.json` + (`/settings.json`) as `"experimental_pipelined_sync": true|false`. + Default/absent = stock driver. Toggling requires a fresh sync to take effect + (use "Sync Now" or relaunch). + +## A. Baseline with the stock driver (control) + +- [ ] Ensure `experimental_pipelined_sync` is `false`/absent. +- [ ] Delete the group's wallet db (force a full rescan from birthday) and sync to + the tip. Time it roughly (wall clock). +- [ ] Record, from the group screen / notes: + - [ ] Total balance, and the Orchard vs Ironwood breakdown. + - [ ] Number of received notes. + - [ ] Transaction history (count + amounts). + - [ ] Scanned-to height (matches chain tip). + +## B. Pipelined driver — clean-state equality (the core test) + +- [ ] Set `experimental_pipelined_sync` to `true`. +- [ ] Delete the wallet db again (same starting point as A) and sync to the tip. +- [ ] Confirm the log shows **"using experimental pipelined sync driver"** (proves + the flag took effect, not a silent fallback). +- [ ] **Balance is byte-identical to A** — total, Orchard, and Ironwood all match + exactly. +- [ ] Received-note count matches A. +- [ ] Transaction history matches A (same txids, amounts, memos). +- [ ] Scanned-to height reaches the chain tip. +- [ ] Wall-clock sync time is **≤ A** (the point of the change; expect faster on a + high-latency link, roughly equal on a fast LAN). + +## C. Incremental sync + +- [ ] With the pipelined wallet already at the tip, wait for / cause a new inbound + testnet payment, then "Sync Now". +- [ ] Only the new blocks are scanned (fast), the new note appears, and the balance + increases by the expected amount. +- [ ] Sync a second time with no new activity → completes quickly, balance + unchanged (no double-count, no drift). + +## D. Cancellation / resume + +- [ ] Start a full rescan (delete db) with the pipelined driver, then hit "Sync + Now" (or switch away) mid-sync to cancel it. +- [ ] App stays responsive; no panic; no error toast beyond an expected + "cancelled". +- [ ] Start sync again → it resumes and completes, ending at the same + balance/height as B (cancellation left the db consistent at a batch + boundary, not corrupted). + +## E. Reorg tolerance (best-effort) + +- [ ] If a testnet reorg happens to occur during a sync, confirm it recovers: the + log shows a "chain reorg detected … rewinding" line and the sync finishes at + the correct tip with the correct balance. (Hard to force on demand; watch for + it opportunistically during A–D.) + +## F. Send after a pipelined sync (funds path) + +- [ ] After a pipelined sync, build + FROST-sign + broadcast a small testnet send. +- [ ] Transaction is accepted by the node (no branch-id / MissingSpendAuthSig / + note-selection errors). +- [ ] After it confirms, a re-sync shows the spend and the reduced balance + correctly. + +## G. Regression — flag off still works + +- [ ] Set `experimental_pipelined_sync` back to `false`, sync once, and confirm the + stock path still works normally (guards against the dispatch wiring breaking + the default path). + +## Sign-off + +- [ ] A vs B balances/notes/history/height are identical. +- [ ] C, D, F pass on testnet. +- [ ] No panics, no stuck syncs, UI responsive throughout. + +Only after this passes on testnet: consider flipping the default and/or repeating +A/B/F once on **mainnet** with a small balance before recommending it broadly. From c2ab490b2d54199c7f67d62ba4bfba779ab137c3 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Tue, 4 Aug 2026 04:54:36 +0000 Subject: [PATCH 04/16] docs(todo): scope ValarGroup Shielded Vote as the real voting target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the memo-v1 "automated poll source" follow-up with the actual governance target: a full rebuild around ValarGroup Shielded Vote (live, ZK-based, on-chain vote chain). Records the user's confirmations — infra is live, FROST-compatible via governance PCZT into ZKP1, Ironwood-supported snapshots, pin production zcash_voting/pir-client at build time — and notes that voting.rs (memo v1) is expected to be scrapped. Includes the wallet-side flow and a de-risking scoping spike as the first step. Co-Authored-By: Claude Opus 4.8 --- TODO.md | 74 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index 15c6e0c..cb38bdf 100644 --- a/TODO.md +++ b/TODO.md @@ -88,22 +88,64 @@ current build depends on them. still authenticates end-to-end, so the transport only provides reachability). -## Voting (coinholder polling) - -- [ ] **Automated poll source / discovery** — voting currently uses **manual - ballot entry**: the user pastes the poll's published ballot-definition JSON - and its reception address, answers, and casts (a shielded memo via the FROST - send path; weight is set by the poll's off-chain balance snapshot). Replace - the manual paste with a programmatic source once one is available: - - Fetch active polls from a configurable **poll-source URL** (Zodl exposes a - "custom poll sources" config; confirm the feed format), render the ballot - automatically, and show live/closed status + results. - - The definitive protocol design is ValarDragon/Valar's; get the poll-feed + - registration/snapshot spec from there (or the Zodl integration docs) to - match eligibility exactly. Keep manual entry as the fallback/offline path. - - The casting core (`voting.rs`: memo v1 encode/validate/poll-hash, - `prepare_vote`) is source-agnostic and already done — this is only about - *where the ballot comes from* and surfacing results. +## Voting (protocol / coinholder governance) + +- [ ] **Migrate to ValarGroup Shielded Vote (full rebuild; supersedes memo v1)** — + the real coinholder-vote protocol we should target is **ValarGroup Shielded + Vote**: https://valargroup.gitbook.io/shielded-vote-docs . It is a live, + cryptographically-private, on-chain voting system on a **dedicated vote + chain**, used infrequently to gauge protocol-upgrade sentiment *before* + committing engineering resources — exactly Cyze's governance use case. + + **This is not a change to the current memo format — it is a different + system.** Our shipped `core/src/voting.rs` implements the informal + **zec-coin-polling "Vote Cast Memo v1"** (a JSON memo cast as a shielded send + to a reception address, tallied off-chain from a *transparent*-balance + snapshot). Shielded Vote has no vote memo: a vote is a ZK-proven **Vote + Commitment** (VAN consumed → new VAN + `H(DOMAIN_VC, round_id, shares_hash, + proposal_id, vote_decision)`) plus 16 ElGamal share ciphertexts, submitted to + REST endpoints on the vote chain. **Expect to scrap `voting.rs` and its UI** + (`VoteTab`/`parseBallot` in `src/screens/Groups.tsx`, `wallet_prepare_vote`, + `VoteEntry`/`BallotDefinition`) and rebuild around the SDK below. Keep memo v1 + only if a lightweight, no-infra sentiment poll is still wanted; otherwise + remove it so the two are never confused. + + **Confirmed (2026-08-04, from the user):** + 1. **Infrastructure is live** — vote chain + election authority + PIR fleet + are running; there is a real network to build/test against. + 2. **FROST-compatible** — the delegation step (ZKP1) takes an externally + produced re-randomized spend-auth signature via a governance PCZT + (`(rk, sighash, spend_auth_sig)`), which maps onto Cyze's existing FROST + re-randomized Orchard signing ceremony. Confirm the exact PCZT hand-off + when building. + 3. **Ironwood supported going forward** — vote weight snapshots the group's + shielded note holdings, and Ironwood is covered, so a post-NU6.3 shielded + treasury can vote (this also fixes memo v1's flaw that only *transparent* + balances counted — a shielded FROST treasury effectively couldn't vote). + 4. **Crate versions: pin to whatever is in production at build time.** Both + SDK crates are published and moving fast — snapshot the then-current + production versions rather than an early rc: + - `zcash_voting` — client lib (ZKP1/2/3 via Halo2, ElGamal, governance + PCZT, Merkle witnesses, SQLite round state). Repo: + https://github.com/valargroup/zcash_voting + - `pir-client` — nullifier non-membership PIR queries. + (Swift SDK exists too, but Cyze is Rust — use the crates directly.) + + **Rough shape of the wallet-side flow** (see the Integration Guide): discover + + validate vote config → `GET /shielded-vote/v1/rounds/active` → PIR + nullifier proofs → build+prove **ZKP1 delegation** (governance PCZT, FROST + spend-auth) → `POST /delegate-vote` → sync the vote-commitment tree → per + proposal, build **ZKP2** and `POST /cast-vote` → split into 16 ElGamal shares + and `POST /shares` with staggered anti-censorship `submit_at` timing → read + `GET /tally-results/{round_id}` once the round is `FINALIZED`. Note + `vote_round_id` encoding is context-sensitive (hex in config/URLs/shares, + base64 in delegate/cast bodies). + + **Suggested first step — a scoping spike** before any UI: add the production + `zcash_voting` + `pir-client` crates, hit a live round's `/rounds/active`, and + prove the FROST-produced re-randomized spend-auth sig feeds ZKP1 end-to-end. + That de-risks the one genuinely novel part (threshold signing into their + prover) cheaply. Own branch, own PR; larger effort than any current item. ## Wallet (Zcash) From fcc8b25c2aaf92d4e318e2bf7eb626ff1a7556e4 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Wed, 5 Aug 2026 03:46:41 +0000 Subject: [PATCH 05/16] feat(sync): friendly diagnostic for non-Ironwood lightwalletd servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lightwalletd that predates Ironwood (NU6.3) rejects the subtree-roots request for the Ironwood pool with "invalid shielded protocol value", which aborts the whole sync at its first step. The raw gRPC error gave no hint that the cause is a server-capability gap, not a wallet bug. `annotate_sync_error` now post-processes both sync drivers' results: when the failure matches that signature it returns an actionable message ("This lightwalletd server doesn't support Ironwood (NU6.3)… switch to an Ironwood-capable server in the wallet's network settings"), keeping the raw server text after an em-dash for debugging. The Groups sync box renders it via SyncErrorView, showing the actionable headline in bold with the raw detail dimmed beneath — the friendly message on top of the existing log output. Unit tests cover the flagged case and pass-through of unrelated errors and cancellation. Backend builds, tsc clean. Co-Authored-By: Claude Opus 4.8 --- src-tauri/core/src/wallet.rs | 76 +++++++++++++++++++++++++++++++++++- src/screens/Groups.tsx | 23 ++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src-tauri/core/src/wallet.rs b/src-tauri/core/src/wallet.rs index e7b8c49..ef90578 100644 --- a/src-tauri/core/src/wallet.rs +++ b/src-tauri/core/src/wallet.rs @@ -999,7 +999,7 @@ pub async fn sync_group( // safe to race against a cancellation token: "Sync Now" trips the token to // abandon a stalled run, and a fresh sync resumes from where this one left // off. Without this, a stuck stream would keep the sync pending forever. - if opts.pipelined { + let result = if opts.pipelined { // The custom pipelined driver overlaps block download with scanning; it // produces the same wallet state as the stock driver but hides network // latency behind CPU trial-decryption. Off by default, opted in via @@ -1018,7 +1018,42 @@ pub async fn sync_group( &mut client, ¶ms, &cache, &mut db, batch_size, ) => res.map_err(|e| CoreError::Connection(format!("sync: {e}"))), } + }; + // Turn known, actionable failures into a message that says what to do, while + // keeping the raw server error appended for diagnosis. + result.map_err(annotate_sync_error) +} + +/// Rewrite a raw sync failure into an actionable message when it matches a known +/// cause, preserving the original text after an em-dash for debugging. Applies to +/// both sync drivers (both surface a `CoreError::Connection` carrying the raw +/// lightwalletd/tonic error string). +fn annotate_sync_error(e: CoreError) -> CoreError { + let raw = match &e { + CoreError::Connection(m) => m.clone(), + // Cancellation and non-connection errors are already clear. + _ => return e, + }; + let lower = raw.to_lowercase(); + + // A lightwalletd that predates Ironwood (NU6.3) doesn't know the Ironwood + // shielded protocol, so the very first sync step — fetching subtree roots for + // all pools, including Ironwood — is rejected with "invalid shielded protocol + // value". The whole sync then aborts. This is a server-capability problem, not + // a wallet bug, and the fix is to point at an Ironwood-capable server. + if lower.contains("invalid shielded protocol") + || (lower.contains("shielded protocol") && lower.contains("invalid")) + { + return CoreError::Connection(format!( + "This lightwalletd server doesn't support Ironwood (NU6.3). Syncing has \ + to fetch the Ironwood note-commitment tree, and the server rejected \ + that request (\"invalid shielded protocol value\"). Switch to an \ + Ironwood-capable lightwalletd in the wallet's network settings, then \ + sync again. — {raw}" + )); } + + e } /// An in-memory [`BlockSource`] over one batch of already-downloaded compact @@ -2414,6 +2449,45 @@ mod tests { assert!(WalletNetwork::Main.default_lightwalletd().starts_with("https://")); } + #[test] + fn annotate_sync_error_flags_non_ironwood_server() { + // The exact string a pre-Ironwood lightwalletd returns, as wrapped by the + // stock driver. + let raw = "sync: Error while communicating with lightwalletd server: \ + status: InvalidArgument, message: \"Error: Invalid shielded \ + protocol value.\""; + let out = annotate_sync_error(CoreError::Connection(raw.to_string())); + match out { + CoreError::Connection(m) => { + assert!(m.contains("doesn't support Ironwood"), "friendly headline: {m}"); + assert!(m.contains("network settings"), "actionable guidance: {m}"); + // Raw detail is preserved after the em-dash separator. + assert!(m.contains(" — "), "keeps raw detail: {m}"); + assert!(m.contains("Invalid shielded protocol value"), "raw text: {m}"); + } + other => panic!("expected Connection error, got {other:?}"), + } + } + + #[test] + fn annotate_sync_error_passes_through_unrelated() { + // An unrelated connection error is returned unchanged (no false headline). + let raw = "sync: Error while communicating with lightwalletd server: \ + transport error"; + match annotate_sync_error(CoreError::Connection(raw.to_string())) { + CoreError::Connection(m) => { + assert_eq!(m, raw); + assert!(!m.contains("Ironwood")); + } + other => panic!("expected Connection error, got {other:?}"), + } + // Cancellation is untouched. + assert!(matches!( + annotate_sync_error(CoreError::Cancelled), + CoreError::Cancelled + )); + } + /// The pipelined driver must scan in the exact same batch units as the stock /// driver, or its result could diverge. This locks the splitter's behaviour to /// the upstream `sync::running` step-7 semantics: contiguous, priority- diff --git a/src/screens/Groups.tsx b/src/screens/Groups.tsx index 9d9df68..47aec2e 100644 --- a/src/screens/Groups.tsx +++ b/src/screens/Groups.tsx @@ -1678,7 +1678,28 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo )} )} - {err &&
{err}
} + {err && } + + ); +} + +/** Render an error. When the backend has attached a raw server detail after + * " — " (as `annotate_sync_error` does for known, actionable failures), show the + * actionable headline prominently and keep the raw text below it, dimmed, so the + * user sees what to do first without losing the log detail for debugging. */ +function SyncErrorView({ text }: { text: string }) { + const sep = text.indexOf(" — "); + if (sep === -1) { + return
{text}
; + } + const headline = text.slice(0, sep); + const detail = text.slice(sep + 3); + return ( +
+ {headline} +
+ {detail} +
); } From 3d2075682bca8988362fe51abb9554b0b6c726d3 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Wed, 5 Aug 2026 04:06:13 +0000 Subject: [PATCH 06/16] fix(sync): serialize per-group syncs to prevent "database is locked" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wallet_sync cancelled the previous sync's token and immediately opened a new db connection, but cancellation is cooperative: the old sync keeps its connection (and, mid-batch, the SQLite write lock) until it returns at the next batch boundary. The new sync's first writes — put_*_subtree_roots — then raced the old writer and, on a large mainnet batch that held the lock past the 30s busy timeout, failed with "database is locked". The pipelined driver surfaces it more because it keeps the old sync writing more continuously. Add a per-group async lock (AppState.sync_locks) held across the whole sync_group call. A restarting sync cancels the old token, then *waits* on this lock until the cancelled sync fully exits and drops its connection before opening its own — so at most one writer touches a group's db at a time. Different groups still sync in parallel. Status reads already use a read-only (shared-lock) connection, so they don't contend. Driver-agnostic; helps the stock path too. 55 core tests pass; backend builds; no new clippy warnings. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/commands/wallet.rs | 15 +++++++++++++++ src-tauri/src/state.rs | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index abcad15..f61151d 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -225,6 +225,21 @@ pub async fn wallet_sync(state: State<'_, AppState>, group_id: String) -> AppRes } } + // Cancellation is cooperative: the previous sync keeps its db connection (and, + // mid-batch, the SQLite write lock) until it returns at the next batch + // boundary. Acquire the per-group sync lock and hold it across `sync_group`, so + // this run *waits* for the cancelled one to fully release before opening its + // own connection. Without this the two overlap and one dies with "database is + // locked" (seen at `put_*_subtree_roots` at the very start of the new sync). + let sync_lock = { + let mut locks = state.sync_locks.lock().await; + locks + .entry(group_id.clone()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _sync_guard = sync_lock.lock().await; + let settings = state.load_settings(); let opts = wallet::SyncOptions { batch_size: settings.sync_batch_size, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 2089539..13448b8 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; use frost_app_core::keystore::Keystore; use frost_client::cli::config::Config; @@ -130,6 +131,14 @@ pub struct AppState { /// Cancellation token for the in-flight wallet sync of each group, so a /// "Sync Now" can abandon a stalled sync and restart it cleanly. pub sync_cancels: Mutex>, + /// Per-group serialization lock for wallet sync. Cancelling the previous + /// sync's token only *asks* it to stop at the next batch boundary; it keeps + /// its db connection (and, mid-batch, the SQLite write lock) until it + /// actually returns. A restarting sync must hold this lock across the whole + /// `sync_group` call so it waits for the cancelled one to exit before opening + /// its own connection — otherwise two writers race the db and one fails with + /// "database is locked". Keyed by group id; different groups sync in parallel. + pub sync_locks: Mutex>>>, /// Epoch-millis of the last user activity, used to drive the idle auto-lock. pub last_activity: AtomicI64, } @@ -154,6 +163,7 @@ impl AppState { sidecar: Mutex::new(None), tunnel: Mutex::new(None), sync_cancels: Mutex::new(HashMap::new()), + sync_locks: Mutex::new(HashMap::new()), last_activity: AtomicI64::new(now_millis()), } } From 4875ce232b9832da623520d7042d2f39ac5c3991 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Wed, 5 Aug 2026 04:27:44 +0000 Subject: [PATCH 07/16] chore(sync): instrument pipelined driver with download/scan timing Log per-batch download time (debug) and per-batch scan time + cumulative blocks/s (info) in the pipelined driver, so the download-vs-scan split is visible when diagnosing slow syncs. No behavior change. Confirms whether a slow sync is network-bound (pipelining helps) or CPU-bound in trial decryption + note-commitment tree updates (needs parallel decryption, which no published crate in the current Ironwood cohort provides). Co-Authored-By: Claude Opus 4.8 --- src-tauri/core/src/wallet.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src-tauri/core/src/wallet.rs b/src-tauri/core/src/wallet.rs index ef90578..c8e8b1f 100644 --- a/src-tauri/core/src/wallet.rs +++ b/src-tauri/core/src/wallet.rs @@ -1215,6 +1215,7 @@ async fn running_pipelined( let mut producer_client = client.clone(); let producer = tokio::spawn(async move { for sr in batches { + let dl_start = std::time::Instant::now(); let blocks = match download_blocks_pipelined(&mut producer_client, &sr).await { Ok(b) => b, Err(e) => { @@ -1222,6 +1223,12 @@ async fn running_pipelined( return; } }; + tracing::debug!( + "pipelined download: {} blocks for {} in {} ms", + blocks.len(), + sr, + dl_start.elapsed().as_millis() + ); let chain_state = match download_chain_state_pipelined( &mut producer_client, sr.block_range().start - 1, @@ -1248,6 +1255,8 @@ async fn running_pipelined( // whole point. Scanning is transactional per batch, so bailing out early (or // being dropped on cancellation) leaves the db consistent at a batch boundary. let mut result = Ok(false); + let scan_run_start = std::time::Instant::now(); + let mut scanned_blocks: u64 = 0; while let Some(item) = rx.recv().await { let (sr, blocks, chain_state) = match item { Ok(v) => v, @@ -1256,8 +1265,24 @@ async fn running_pipelined( break; } }; + let n = blocks.len() as u64; let src = MemBlockSource(blocks); - match scan_batch(params, &src, db, &chain_state, &sr) { + let scan_start = std::time::Instant::now(); + let outcome = scan_batch(params, &src, db, &chain_state, &sr); + // Per-batch scan cost and cumulative throughput. This is the CPU-bound leg + // (trial decryption + note-commitment tree updates); logging it here makes + // the download-vs-scan split visible when diagnosing slow syncs. + scanned_blocks += n; + let secs = scan_run_start.elapsed().as_secs_f64(); + tracing::info!( + "pipelined scan: {} blocks for {} in {} ms ({:.0} blocks/s cumulative over {} blocks)", + n, + sr, + scan_start.elapsed().as_millis(), + if secs > 0.0 { scanned_blocks as f64 / secs } else { 0.0 }, + scanned_blocks + ); + match outcome { Ok(true) => { // Ranges changed (continuity error or a new higher-priority // range); restart the whole pass from fresh suggestions. From 494e258c34777b97782700ecdba12ca6d3d0a9da Mon Sep 17 00:00:00 2001 From: blocknodes Date: Wed, 5 Aug 2026 05:05:22 +0000 Subject: [PATCH 08/16] feat(diagnostics): in-app log viewer in Wallet settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing was subscribing to the app's `tracing` output, so diagnostics (including the new sync timing logs) went nowhere. Add a global subscriber that formats every event to stdout AND an in-memory ring buffer (logbuf.rs, bounded at 3000 lines, honouring RUST_LOG; default info + our crates at debug). The buffer is process-only and never written to disk — wallet logs can contain addresses/amounts. New commands get_logs / clear_logs expose the buffer. Wallet settings gains a "Diagnostics log" card: live auto-refresh (2s), copy-all, refresh, clear, and a scroll-pinned monospace view — so a user can grab and share logs (e.g. the `pipelined scan: … blocks/s` lines) without a terminal. logbuf unit tests cover ring-buffer capping/order and line splitting. Backend builds, tsc clean, no new clippy warnings. Co-Authored-By: Claude Opus 4.8 --- src-tauri/Cargo.lock | 113 +++++++++++++++++---- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/server.rs | 14 +++ src-tauri/src/lib.rs | 7 ++ src-tauri/src/logbuf.rs | 168 +++++++++++++++++++++++++++++++ src/ipc/commands.ts | 4 + src/screens/Wallet.tsx | 100 +++++++++++++++++- 7 files changed, 387 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/logbuf.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0ddd137..06d7108 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -60,9 +60,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -1044,9 +1044,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" @@ -1619,6 +1619,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "tracing-subscriber", "uuid", "zeroize", ] @@ -2261,9 +2262,9 @@ dependencies = [ [[package]] name = "halo2_proofs" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f63a999d9223fa9d3b9db3031fedc316e6039a0ae0f67394408b16ef670c69f" +checksum = "f5aca1c66059a919227dec97444a11a4350d2f9c820ca48690988f0aa0e81cbf" dependencies = [ "blake2b_simd", "ff", @@ -2716,9 +2717,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is-docker" @@ -2951,9 +2952,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "libc", ] @@ -3020,6 +3021,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3229,6 +3239,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3565,9 +3584,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchard" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793e2e8c2323f35f082d1b3467ca8f576d646f9c93aef8c5168809d099245af8" +checksum = "a3cb2b35534bba3c63fbf640dc6cd9dfd1ece2fae886cdab4ab1f1e380dd6ca1" dependencies = [ "aes", "bitvec", @@ -3694,9 +3713,9 @@ dependencies = [ [[package]] name = "pczt" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d4693dcb3d72f30064e0fdab122292d803bd9325a95b25df08679cf895452f" +checksum = "aead0b7ecb4363d8560ac76687c02ef896292fb836c1c68f3a4d99443f32e1a0" dependencies = [ "blake2b_simd", "bls12_381", @@ -4391,9 +4410,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5075,6 +5094,15 @@ dependencies = [ "digest 0.11.0-pre.9", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shardtree" version = "0.7.1" @@ -5815,11 +5843,20 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -6218,6 +6255,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -6425,6 +6492,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -7614,9 +7687,9 @@ dependencies = [ [[package]] name = "zcash_protocol" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f074493fff337207e28bcfa5bbdf0e2a125c4203a4bdd07e72067eea81e9e7b" +checksum = "043686451284bcb72e40ffa18e2cdcea3108e8468e3d021062c0b32c4a060c98" dependencies = [ "corez", "document-features", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ec56688..0607dd8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ tokio-util = { workspace = true } postcard = { workspace = true } dirs = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } [dev-dependencies] tauri = { version = "2", features = ["test"] } @@ -66,5 +67,6 @@ postcard = "1.1" bech32 = "0.11" dirs = "5" tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } sha2 = "0.10" base64 = "0.22" diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index 5c6ce14..4d63f69 100644 --- a/src-tauri/src/commands/server.rs +++ b/src-tauri/src/commands/server.rs @@ -205,6 +205,20 @@ pub async fn tunnel_status(state: State<'_, AppState>) -> AppResult AppResult> { + Ok(crate::logbuf::global().snapshot()) +} + +/// Discard the buffered log lines. +#[tauri::command] +pub async fn clear_logs() -> AppResult<()> { + crate::logbuf::global().clear(); + Ok(()) +} + #[tauri::command] pub async fn sidecar_status(state: State<'_, AppState>) -> AppResult { sidecar::status(&state).await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b58bc33..5245fae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod error; +pub mod logbuf; pub mod sidecar; pub mod state; pub mod tunnel; @@ -44,6 +45,10 @@ async fn run_auto_lock_monitor(app: tauri::AppHandle) { } pub fn run() { + // Start capturing `tracing` output (to stdout + the in-app log buffer) before + // anything else runs, so early diagnostics are recorded too. + logbuf::init_logging(); + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(AppState::new()) @@ -104,6 +109,8 @@ pub fn run() { commands::server::start_tunnel, commands::server::stop_tunnel, commands::server::tunnel_status, + commands::server::get_logs, + commands::server::clear_logs, commands::dkg::start_dkg, commands::dkg::cancel_ceremony, commands::signing::create_signing_session, diff --git a/src-tauri/src/logbuf.rs b/src-tauri/src/logbuf.rs new file mode 100644 index 0000000..d2bddaa --- /dev/null +++ b/src-tauri/src/logbuf.rs @@ -0,0 +1,168 @@ +//! In-app log capture. +//! +//! The app emits diagnostics via `tracing`, but nothing was subscribing to them, +//! so they went nowhere. This installs a global subscriber that formats every +//! event to **stdout** (for terminal runs) and into an in-memory **ring buffer** +//! that the UI can read back — so a user can copy the logs (e.g. sync timing) +//! straight from the app without a terminal. +//! +//! The buffer is bounded ([`MAX_LINES`]) and lives only for the process: it is +//! not persisted to disk (logs can contain addresses/amounts, and a wallet +//! should not silently write those to a log file). Restarting the app clears it. + +use std::collections::VecDeque; +use std::io; +use std::sync::{Arc, Mutex, OnceLock}; + +use tracing_subscriber::fmt::writer::MakeWriter; + +/// Maximum number of log lines retained in memory. Older lines are dropped as +/// new ones arrive, so a long-running session can't grow memory without bound. +const MAX_LINES: usize = 3000; + +/// A bounded, shareable ring buffer of formatted log lines. +#[derive(Clone)] +pub struct LogBuffer { + lines: Arc>>, +} + +impl LogBuffer { + fn new() -> Self { + Self { + lines: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LINES))), + } + } + + /// A copy of the currently buffered lines, oldest first. + pub fn snapshot(&self) -> Vec { + self.lines + .lock() + .map(|l| l.iter().cloned().collect()) + .unwrap_or_default() + } + + /// Drop all buffered lines. + pub fn clear(&self) { + if let Ok(mut l) = self.lines.lock() { + l.clear(); + } + } + + fn push_line(&self, line: String) { + if let Ok(mut l) = self.lines.lock() { + while l.len() >= MAX_LINES { + l.pop_front(); + } + l.push_back(line); + } + } +} + +/// The process-wide log buffer, shared by the subscriber (writer) and the +/// `get_logs` command (reader). +static LOG_BUFFER: OnceLock = OnceLock::new(); + +/// Access the global log buffer, creating it on first use. +pub fn global() -> &'static LogBuffer { + LOG_BUFFER.get_or_init(LogBuffer::new) +} + +/// A short-lived writer for one `tracing` event. `fmt` formats the whole event +/// (a single line ending in `\n`) into this, then drops it; on drop we split the +/// accumulated bytes into lines and append them to the buffer. +pub struct LineWriter { + buf: LogBuffer, + pending: Vec, +} + +impl io::Write for LineWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + self.pending.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for LineWriter { + fn drop(&mut self) { + if self.pending.is_empty() { + return; + } + let text = String::from_utf8_lossy(&self.pending); + for line in text.split('\n') { + let line = line.trim_end_matches('\r'); + if !line.is_empty() { + self.buf.push_line(line.to_string()); + } + } + } +} + +impl<'a> MakeWriter<'a> for LogBuffer { + type Writer = LineWriter; + fn make_writer(&'a self) -> Self::Writer { + LineWriter { + buf: self.clone(), + pending: Vec::new(), + } + } +} + +/// Install the global `tracing` subscriber. Idempotent: a second call (e.g. in +/// tests) is a no-op rather than a panic. Formats to stdout and the in-app ring +/// buffer, honouring `RUST_LOG` when set; otherwise our crates log at `debug` +/// (so sync timing is captured) and dependencies stay at `info`. +pub fn init_logging() { + use tracing_subscriber::fmt::writer::MakeWriterExt; + use tracing_subscriber::EnvFilter; + + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,frost_app=debug,frost_app_core=debug")); + + // Tee: the same formatted event goes to stdout and the in-app buffer. + let writer = std::io::stdout.and(global().clone()); + + let _ = tracing_subscriber::fmt() + .with_ansi(false) + .with_target(false) + .with_env_filter(filter) + .with_writer(writer) + .try_init(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn ring_buffer_caps_and_orders() { + let buf = LogBuffer::new(); + for i in 0..(MAX_LINES + 50) { + buf.push_line(format!("line {i}")); + } + let snap = buf.snapshot(); + assert_eq!(snap.len(), MAX_LINES, "buffer is capped"); + assert_eq!(snap.first().unwrap(), "line 50", "oldest dropped first"); + assert_eq!(snap.last().unwrap(), &format!("line {}", MAX_LINES + 49)); + buf.clear(); + assert!(buf.snapshot().is_empty()); + } + + #[test] + fn line_writer_splits_events_into_lines() { + let buf = LogBuffer::new(); + { + let mut w = buf.make_writer(); + w.write_all(b"first line\n").unwrap(); + } // dropped here -> flushed + { + let mut w = buf.make_writer(); + w.write_all(b"second\nthird\n").unwrap(); + } + assert_eq!(buf.snapshot(), vec!["first line", "second", "third"]); + } +} diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 2939caf..803cde8 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -342,6 +342,10 @@ export const exportSidecarCert = () => invoke("export_sidecar_cert"); export const startTunnel = () => invoke("start_tunnel"); export const stopTunnel = () => invoke("stop_tunnel"); export const tunnelStatus = () => invoke("tunnel_status"); +/** The in-app application log (oldest line first), captured from tracing since + * app start. In-memory and bounded; cleared on restart. */ +export const getLogs = () => invoke("get_logs"); +export const clearLogs = () => invoke("clear_logs"); // Ceremonies export type Ciphersuite = "ed25519" | "redpallas"; diff --git a/src/screens/Wallet.tsx b/src/screens/Wallet.tsx index 081b8df..b247d9a 100644 --- a/src/screens/Wallet.tsx +++ b/src/screens/Wallet.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getWalletConfig, lightwalletdInfo, setWalletConfig, + getLogs, + clearLogs, AppError, LightwalletdInfo, } from "../ipc/commands"; @@ -279,6 +281,8 @@ export default function Wallet() { {testErr &&
{testErr}
} + + {showMainnetModal && ( { @@ -293,3 +297,97 @@ export default function Wallet() { ); } + +/** In-app diagnostics log: shows what the app has logged this session (sync + * timing, errors, ceremony steps) so it can be copied and shared without a + * terminal. In-memory only — cleared when the app restarts. */ +function LogsCard() { + const [live, setLive] = useState(true); + const [copied, setCopied] = useState(false); + const preRef = useRef(null); + const atBottomRef = useRef(true); + + const logs = useQuery({ + queryKey: ["app-logs"], + queryFn: getLogs, + refetchInterval: live ? 2000 : false, + }); + const lines = logs.data ?? []; + + const clear = useMutation({ + mutationFn: clearLogs, + onSuccess: () => logs.refetch(), + }); + + // Keep the view pinned to the newest line while live, unless the user has + // scrolled up to read older output. + useEffect(() => { + const el = preRef.current; + if (el && atBottomRef.current) el.scrollTop = el.scrollHeight; + }, [lines.length]); + + const onScroll = () => { + const el = preRef.current; + if (!el) return; + atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + }; + + const copyAll = async () => { + await navigator.clipboard.writeText(lines.join("\n")); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+

Diagnostics log

+ + {lines.length} line{lines.length === 1 ? "" : "s"} · this session + +
+

+ What the app has logged while running (sync timing, errors, ceremony + steps). Kept in memory only and cleared on restart — copy it here to share + for troubleshooting. +

+ +
+ + + + +
+ +
+        {lines.length ? lines.join("\n") : "No log output yet."}
+      
+
+ ); +} From 16a829d07c630fcd413787abd36cd9cdb62acb2d Mon Sep 17 00:00:00 2001 From: blocknodes Date: Sun, 16 Aug 2026 00:07:29 +0000 Subject: [PATCH 09/16] feat(sync): toggle pipelined sync from Wallet Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The experimental pipelined sync driver was only togglable by hand-editing settings.json. Add a "Sync" card in Wallet Settings with an "Experimental pipelined sync" checkbox: - New set_experimental_pipelined_sync command (persists the flag; read at the start of each wallet_sync, so it takes effect on the next sync — no restart). - Registered in lib.rs; Settings interface + setExperimentalPipelinedSync IPC binding added. - SyncCard in Wallet.tsx reads the flag from settings and toggles it, with a note that it's off by default and applies on the next sync. - SYNC_PIPELINE_UAT.md: point the toggle steps at the checkbox (settings.json still works) and add A0a to test the checkbox wiring itself. Backend + tsc build clean; clippy unchanged at the 7-warning baseline. Co-Authored-By: Claude Opus 4.8 --- docs/SYNC_PIPELINE_UAT.md | 26 ++++++++++++----- src-tauri/src/commands/server.rs | 12 ++++++++ src-tauri/src/lib.rs | 1 + src/ipc/commands.ts | 5 ++++ src/screens/Wallet.tsx | 50 ++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/docs/SYNC_PIPELINE_UAT.md b/docs/SYNC_PIPELINE_UAT.md index 5be2239..74db520 100644 --- a/docs/SYNC_PIPELINE_UAT.md +++ b/docs/SYNC_PIPELINE_UAT.md @@ -13,14 +13,24 @@ See `docs/SYNC_OPTIMIZATION.md` for the design and the formal validation gate. bundle. - [ ] Use a **testnet** group with a known, non-trivial history (funded a few times, at least one send), so scanning actually finds notes. -- [ ] Know how to toggle the flag. It lives in `settings.json` - (`/settings.json`) as `"experimental_pipelined_sync": true|false`. - Default/absent = stock driver. Toggling requires a fresh sync to take effect - (use "Sync Now" or relaunch). +- [ ] Know how to toggle the flag. Primary: **Zcash → Wallet Settings → Sync → + "Experimental pipelined sync"** checkbox (persists immediately; takes effect + on the next sync). It still maps to `settings.json` + (`/settings.json`) `"experimental_pipelined_sync": true|false`, + which you can edit directly if preferred. Default/absent = stock driver. + +## A0a. The toggle itself (checkbox wiring) + +- [ ] Wallet Settings shows a **Sync** card with the checkbox, **unchecked** by + default on a fresh profile. +- [ ] Check it → reopen Wallet Settings (or another screen and back) → it stays + checked (persisted). Confirm `settings.json` now has + `"experimental_pipelined_sync": true`. +- [ ] Uncheck it → the value flips back to `false`. No restart needed either way. ## A. Baseline with the stock driver (control) -- [ ] Ensure `experimental_pipelined_sync` is `false`/absent. +- [ ] Ensure the checkbox is **unchecked** (`experimental_pipelined_sync` `false`/absent). - [ ] Delete the group's wallet db (force a full rescan from birthday) and sync to the tip. Time it roughly (wall clock). - [ ] Record, from the group screen / notes: @@ -31,7 +41,7 @@ See `docs/SYNC_OPTIMIZATION.md` for the design and the formal validation gate. ## B. Pipelined driver — clean-state equality (the core test) -- [ ] Set `experimental_pipelined_sync` to `true`. +- [ ] **Check** the pipelined-sync box (or set `experimental_pipelined_sync` to `true`). - [ ] Delete the wallet db again (same starting point as A) and sync to the tip. - [ ] Confirm the log shows **"using experimental pipelined sync driver"** (proves the flag took effect, not a silent fallback). @@ -79,8 +89,8 @@ See `docs/SYNC_OPTIMIZATION.md` for the design and the formal validation gate. ## G. Regression — flag off still works -- [ ] Set `experimental_pipelined_sync` back to `false`, sync once, and confirm the - stock path still works normally (guards against the dispatch wiring breaking +- [ ] **Uncheck** the box (`experimental_pipelined_sync` back to `false`), sync once, + and confirm the stock path still works normally (guards against the dispatch wiring breaking the default path). ## Sign-off diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index 17a7709..208aa33 100644 --- a/src-tauri/src/commands/server.rs +++ b/src-tauri/src/commands/server.rs @@ -19,6 +19,18 @@ pub async fn set_server_url(state: State<'_, AppState>, url: String) -> AppResul state.save_settings(&settings) } +/// Toggle the experimental pipelined sync driver. Persisted; read at the start of +/// each `wallet_sync`, so it takes effect on the next sync (no restart needed). +#[tauri::command] +pub async fn set_experimental_pipelined_sync( + state: State<'_, AppState>, + enabled: bool, +) -> AppResult<()> { + let mut settings = state.load_settings(); + settings.experimental_pipelined_sync = Some(enabled); + state.save_settings(&settings) +} + /// Save the first-run/session configuration: the active role and, for a /// coordinator, how the server is exposed. Marks the session as configured so /// the first-run prompt is not shown again. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f84ddbb..42e9c21 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,6 +97,7 @@ pub fn run() { commands::wallet::wallet_rebroadcast, commands::server::get_settings, commands::server::set_server_url, + commands::server::set_experimental_pipelined_sync, commands::server::set_session_config, commands::server::set_session_role, commands::server::get_active_wallet, diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 4efc01c..33a25d6 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -40,6 +40,8 @@ export interface Settings { session_configured: boolean | null; /** The active wallet (group id) the app is focused on, or null if unset. */ active_group_id: string | null; + /** Opt into the experimental pipelined sync driver. null/false = stock driver. */ + experimental_pipelined_sync: boolean | null; } export interface SidecarStatus { @@ -325,6 +327,9 @@ export const renameGroup = (id: string, description: string) => // Server / sidecar export const getSettings = () => invoke("get_settings"); +/** Toggle the experimental pipelined sync driver; takes effect on the next sync. */ +export const setExperimentalPipelinedSync = (enabled: boolean) => + invoke("set_experimental_pipelined_sync", { enabled }); export const setServerUrl = (url: string) => invoke("set_server_url", { url }); /** Save the first-run session configuration (role + coordinator exposure). */ export const setSessionConfig = (role: string, exposure?: string | null) => diff --git a/src/screens/Wallet.tsx b/src/screens/Wallet.tsx index b247d9a..3243d1c 100644 --- a/src/screens/Wallet.tsx +++ b/src/screens/Wallet.tsx @@ -4,6 +4,8 @@ import { getWalletConfig, lightwalletdInfo, setWalletConfig, + getSettings, + setExperimentalPipelinedSync, getLogs, clearLogs, AppError, @@ -281,6 +283,8 @@ export default function Wallet() { {testErr &&
{testErr}
} + + {showMainnetModal && ( @@ -298,6 +302,52 @@ export default function Wallet() { ); } +/** Sync settings: opt into the experimental pipelined sync driver. Persisted and + * read at the start of each sync, so the toggle takes effect on the next sync. */ +function SyncCard() { + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: getSettings }); + const enabled = settings.data?.experimental_pipelined_sync ?? false; + + const toggle = useMutation({ + mutationFn: (next: boolean) => setExperimentalPipelinedSync(next), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), + }); + + return ( +
+

Sync

+ + {toggle.isError && ( +
+ {(toggle.error as unknown as AppError).message} +
+ )} +
+ ); +} + /** In-app diagnostics log: shows what the app has logged this session (sync * timing, errors, ceremony steps) so it can be copied and shared without a * terminal. In-memory only — cleared when the app restarts. */ From adde233693e7055f29762be296172298f02de877 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Sun, 16 Aug 2026 00:19:29 +0000 Subject: [PATCH 10/16] fix(wallet): log + time-bound the view-only account setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Setting up the group's view-only wallet…" could spin forever with nothing in the log viewer: init_group_account emitted no tracing output, and its two unary RPCs (get_latest_block, get_tree_state) had no timeout — a server that accepts the TCP connection but never answers (misconfigured proxy, stalled tree-state for a deep birthday) left setup hung with no error and no clue. - Add tracing::info at each step (connecting → chain tip → fetching tree state → account imported), so the in-app log viewer shows setup progress. Routes through the frost_app_core=debug filter already used by the log buffer. - Bound get_latest_block and get_tree_state with a 30s timeout so a hang surfaces as a "did not respond" connection error (→ the UI's "Couldn't set up the wallet — check the lightwalletd endpoint … Retry" path) instead of an infinite spinner. These are single unary calls, not the long block stream, so a timeout is safe here (the stream deliberately has none). Note: a FROST group wallet is view-only by design — the app holds the group's UFVK, so it receives, shows balance, and builds unsigned txs; spending is via a FROST signing ceremony, not a local spend key. Backend builds clean; clippy unchanged at the 7-warning baseline. Co-Authored-By: Claude Opus 4.8 --- src-tauri/core/src/wallet.rs | 38 ++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src-tauri/core/src/wallet.rs b/src-tauri/core/src/wallet.rs index c8e8b1f..7d2ed92 100644 --- a/src-tauri/core/src/wallet.rs +++ b/src-tauri/core/src/wallet.rs @@ -777,6 +777,7 @@ pub async fn init_group_account( .map_err(|e| CoreError::Crypto(format!("wallet accounts: {e}")))? .is_empty() { + tracing::debug!(group = %group_id, "wallet setup: account already imported; nothing to do"); return Ok(0); // already imported } @@ -784,13 +785,25 @@ pub async fn init_group_account( let ufvk = UnifiedFullViewingKey::decode(¶ms, ufvk_str) .map_err(|e| CoreError::Crypto(format!("invalid UFVK: {e}")))?; + // These are single unary RPCs, not the long block stream, so bound them. A + // server that accepts the TCP connection but never answers (a misconfigured + // proxy, a stalled `get_tree_state` for a deep birthday) would otherwise leave + // wallet setup spinning forever with no error and no log line. + let rpc_timeout = std::time::Duration::from_secs(30); + + tracing::info!(group = %group_id, url = %lightwalletd_url, "wallet setup: connecting to lightwalletd"); let mut client = connect(lightwalletd_url).await?; - let tip = client - .get_latest_block(ChainSpec {}) + let tip = tokio::time::timeout(rpc_timeout, client.get_latest_block(ChainSpec {})) .await + .map_err(|_| { + CoreError::Connection( + "get_latest_block timed out — lightwalletd accepted the connection but did not respond".into(), + ) + })? .map_err(|e| CoreError::Connection(format!("get_latest_block: {e}")))? .into_inner() .height; + tracing::info!(group = %group_id, tip, "wallet setup: connected; got chain tip"); let nu5 = params .activation_height(NetworkUpgrade::Nu5) @@ -807,19 +820,28 @@ pub async fn init_group_account( // request the frontier as of the block *before* the first one to scan. // Fetching the treestate at `scan_from` itself would skip that block — and // with it the transaction that funded the group. - let treestate = client - .get_tree_state(BlockId { + tracing::info!(group = %group_id, scan_from, "wallet setup: fetching tree state for the account birthday"); + let treestate = tokio::time::timeout( + rpc_timeout, + client.get_tree_state(BlockId { height: scan_from.saturating_sub(1), hash: vec![], - }) - .await - .map_err(|e| CoreError::Connection(format!("get_tree_state: {e}")))? - .into_inner(); + }), + ) + .await + .map_err(|_| { + CoreError::Connection( + "get_tree_state timed out — lightwalletd did not return the birthday tree state".into(), + ) + })? + .map_err(|e| CoreError::Connection(format!("get_tree_state: {e}")))? + .into_inner(); let birthday = AccountBirthday::from_treestate(treestate, None) .map_err(|_| CoreError::Crypto("could not derive account birthday from treestate".into()))?; db.import_account_ufvk(group_id, &ufvk, &birthday, AccountPurpose::ViewOnly, None) .map_err(|e| CoreError::Crypto(format!("import account: {e}")))?; + tracing::info!(group = %group_id, scan_from, "wallet setup: view-only account imported; will scan from this height"); Ok(scan_from) } From db7a8a418e43a0cd7f8ef1bdc5136f2a767744da Mon Sep 17 00:00:00 2001 From: blocknodes Date: Sun, 16 Aug 2026 00:47:43 +0000 Subject: [PATCH 11/16] fix(wallet): surface a failed status read instead of a stuck spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Setting up the group's view-only wallet…" could sit forever with nothing in the logs even after the init_group_account timeout/logging fix — because init only runs *after* the wallet-status query returns. If that status read errors (keystore locked, key derivation for the group, or a db problem), status.data stays undefined, the init effect never fires (its guard requires status.data), and the UI's catch-all `!s` branch shows the setup spinner indefinitely with no error and no log line. - Handle status.isError: show the actual error with a Retry (refetch) button. The status read is local (no network), so the message notes it's the keystore or db, not lightwalletd. - Distinguish "Loading wallet…" (status still loading) from "Setting up…" (init actually running). - Show the real init error text in the "Couldn't set up the wallet" branch (it previously hid it behind generic endpoint advice), so the new get_tree_state / get_latest_block timeout messages are visible. tsc clean. Co-Authored-By: Claude Opus 4.8 --- src/screens/Groups.tsx | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/screens/Groups.tsx b/src/screens/Groups.tsx index b84b150..025b5af 100644 --- a/src/screens/Groups.tsx +++ b/src/screens/Groups.tsx @@ -1061,6 +1061,15 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo if (!group.ciphersuite.includes("Pallas")) return null; const s = status.data; + // Surface a failed status read instead of masking it as a permanent "Setting + // up…". `init` only runs *after* status returns, so if the status read errors + // (keystore locked, key derivation, a db problem), the wallet otherwise sits on + // the spinner forever with nothing in the logs — exactly the "stuck setting up" + // symptom. This read is local (no network), so an error here is not the server. + const statusErr = + status.isError && !s + ? ((status.error as unknown as AppError)?.message ?? String(status.error)) + : null; // Prefer the live probe while a sync is running; the cached status is stale // until the whole catch-up returns. const live = sync.isPending ? progress.data : undefined; @@ -1076,13 +1085,25 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo return (

Wallet (Zcash · Orchard + Ironwood)

- {!s || (!s.initialized && (init.isPending || !err)) ? ( + {statusErr ? ( + <> +

+ Couldn't read this wallet: {statusErr}. This is a local read (no + network), so it usually means the keystore is locked or the wallet + database can't be opened — not the lightwalletd server. +

+ + + ) : !s ? ( +

Loading wallet…

+ ) : !s.initialized && (init.isPending || !err) ? (

Setting up the group's view-only wallet…

) : !s.initialized ? ( <>

- Couldn't set up the wallet — check the lightwalletd endpoint on the{" "} - Wallet page, then retry. + Couldn't set up the wallet{err ? `: ${err}` : ""}. Check the + lightwalletd endpoint on the Wallet page, + then retry.

-

- You are about to sign and broadcast a transaction on the Zcash - mainnet. This will move real funds. -

- - +
@@ -146,8 +141,7 @@ function MainnetConfirmModal({ onChange={(e) => setAck(e.target.checked)} /> @@ -250,13 +244,12 @@ function ReceiveShieldCard({ groupId, fallback }: { groupId: string; fallback: s

Receive / Shield into group

- Send Zcash to this unified address to fund the group. Funds arrive in the - group's shielded Ironwood pool and become spendable by - the threshold. To shield transparent funds, send them - here from a personal wallet — the receive itself is the shielding step. + Send Zcash to this address to fund the group. Funds become spendable by + the threshold; sending from a transparent wallet shields them in the same + step.

0.001 ${unit(isMainnet)} above fees)` + `Legacy balance too low to move (need > 0.001 ${unit(isMainnet)} above fees)` ); return walletPrepareSend(group.id, addr, orchard - CONSOLIDATE_FEE_BUFFER); }, @@ -1084,7 +1077,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo return (

-

Wallet (Zcash · Orchard + Ironwood)

+

Wallet

{statusErr ? ( <>

@@ -1111,38 +1104,33 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo ) : ( <> - {/* Balance summary — always visible at top. Totals span every pool the - group holds; the per-pool line below shows the Orchard/Ironwood - split, since post-NU6.3 funds live in both. */} + {/* Balance summary — the group's Ironwood balance, the pool all new + shielded value lands in. Any legacy Orchard balance is surfaced + separately below, only when it exists. */}

- {zec(s.spendable_zatoshis)} {unit(isMainnet)} + {zec(s.ironwood.spendable_zatoshis)} {unit(isMainnet)}
- {zec(s.orchard.pending_zatoshis + s.ironwood.pending_zatoshis)} {unit(isMainnet)} + {zec(s.ironwood.pending_zatoshis)} {unit(isMainnet)}
-
{zec(s.total_zatoshis)} {unit(isMainnet)}
+
+ {zec(s.ironwood.total_zatoshis)} {unit(isMainnet)} +
- {/* Per-pool breakdown: Orchard is the sealed legacy pool, Ironwood is - where all new value lands post-NU6.3. */} -
- Orchard (sealed): {zec(s.orchard.total_zatoshis)} {unit(isMainnet)} - {" · "} - Ironwood: {zec(s.ironwood.total_zatoshis)} {unit(isMainnet)} -
- {/* Prompt to sweep the sealed Orchard pool into Ironwood. Shown only - while a meaningful, spendable Orchard balance remains (above the - fee buffer) and no send is already in flight. */} + {/* Legacy Orchard funds: only surfaced when a spendable balance + actually remains, with a one-tap sweep into Ironwood. New groups + never hold Orchard, so this stays hidden for them. */} {s.orchard.spendable_zatoshis > CONSOLIDATE_FEE_BUFFER && !(activeSend && !activeSend.done) && (
- Migrate Orchard → Ironwood. This group holds{" "} - {zec(s.orchard.spendable_zatoshis)} {unit(isMainnet)} in the legacy - Orchard pool, which can no longer receive funds after the Ironwood - (NU6.3) upgrade. Sweep it into Ironwood so all funds stay in the - active pool. + Move legacy funds to Ironwood. This group holds{" "} + {zec(s.orchard.spendable_zatoshis)} {unit(isMainnet)} in the old + pool. Sweep it across so all funds stay spendable.
@@ -1318,24 +1304,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo /> ) : ( <> - {isMainnet && ( -
- - ⚠ Mainnet — transactions move real ZEC and - are irreversible. Verify every address and amount carefully. - -
- )} - - {/* Mode toggle: shielded Orchard send vs. unshield to transparent. */} + {/* Mode toggle: shielded send vs. unshield to transparent. */}
)} @@ -1513,7 +1481,7 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo {isVote ? "Cast vote" : isMigration - ? "Migrate Orchard → Ironwood" + ? "Move to Ironwood" : isConsolidation ? "Consolidation transaction" : draft.is_unshield @@ -1537,12 +1505,10 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo
{isMigration ? ( - Sweeps the group's sealed Orchard balance back to - its own address; because every post-NU6.3 shielded output lands in - the Ironwood pool, this moves the funds across the - turnstile into Ironwood.{" "} + Moves the group's legacy funds into the active{" "} + Ironwood pool via a self-send.{" "} {draft.spends.length} note{draft.spends.length !== 1 ? "s" : ""}{" "} - will be signed (one round each). A small network fee applies. + will be signed. A small network fee applies. ) : ( @@ -1557,9 +1523,9 @@ function GroupWallet({ group, isMainnet }: { group: GroupSummary; isMainnet: boo {!isConsolidation && draft.is_unshield && (
- Unshield — moves {zec(draft.amount_zatoshis)} {unit(isMainnet)} from - the group's shielded Orchard pool to a transparent address. The amount - and recipient will be publicly visible on-chain. + Unshield — moves {zec(draft.amount_zatoshis)} {unit(isMainnet)} to + a transparent address, so the amount and recipient will be{" "} + public on-chain.
)} @@ -1871,7 +1837,7 @@ function SendSessionPanel({ )} {meta.isUnshield && (
- Unshield — moving funds from the group's shielded Orchard pool to a transparent address (publicly visible on-chain). + Unshield — moving funds to a transparent address (public on-chain).
)}
Sending
@@ -2069,7 +2035,7 @@ export function GroupKeys({ group, masked = false }: { group: GroupSummary; mask {orchard && keys.data && ( <> - +
- The viewing key (nk,{" "} - rivk) is derived - deterministically from the group's ak, - so every member computes this same address. Funds sent here are - spendable only by a threshold of the group. The UFVK grants{" "} - viewing access — share it only within the group. The - address is encoded for the network selected on the{" "} - Wallet page (mainnet by default). + Every member derives this same address from the group's key. Funds + sent here are spendable only by a threshold of the group. The UFVK + grants viewing access — share it only within the group.
)} {orchard && keys.isError && (
- Could not derive the Orchard address for this group. + Could not derive the address for this group.
)} @@ -2527,7 +2488,7 @@ export function GroupWalletPage() {

{group.description || "(unnamed group)"} — Wallet

- A Zcash wallet is only available for RedPallas (Orchard) groups.{" "} + A Zcash wallet is only available for RedPallas groups.{" "} Back to group details.

@@ -2537,21 +2498,22 @@ export function GroupWalletPage() { return (
-

+

{group.description || "(unnamed group)"} — Wallet - {isMainnet && ( - - ⚠ MAINNET - - )} + + {isMainnet ? "Mainnet" : "Testnet"} +

← Group details diff --git a/src/screens/SessionSetup.tsx b/src/screens/SessionSetup.tsx index 773c5a7..235e188 100644 --- a/src/screens/SessionSetup.tsx +++ b/src/screens/SessionSetup.tsx @@ -42,12 +42,9 @@ function TransportSecurityNote() {
Transport is not authentication. However you expose the - server — direct, tunnel, or reverse proxy — FROSTd messages stay - end-to-end authenticated and encrypted by the app's own Noise layer - (participants are verified by their communication public keys). A tunnel - or TLS proxy only makes the server reachable; it never replaces that - application-layer security, so keep participant key verification in place - regardless of the transport you choose. + server, FROSTd messages stay end-to-end encrypted by the app's own Noise + layer, and participants are verified by their keys. Keep that key + verification in place whatever transport you use.
); @@ -84,10 +81,10 @@ export default function SessionSetup() { {!configured ? (
- Welcome — let's configure your session. A FROST - ceremony coordinates through one frostd{" "} - server. Choose your role and how you'll connect below, then save it. - You can change this any time from Zcash → Session Configuration. + Welcome — configure your session. A ceremony runs + through one frostd server. Pick your role + below and save; change it anytime from{" "} + Zcash → Session Configuration.
) : ( diff --git a/src/screens/Wallet.tsx b/src/screens/Wallet.tsx index 3243d1c..f65bdc5 100644 --- a/src/screens/Wallet.tsx +++ b/src/screens/Wallet.tsx @@ -12,81 +12,6 @@ import { LightwalletdInfo, } from "../ipc/commands"; -/** Themed confirmation dialog for switching to mainnet — replaces the plain - * browser confirm() so it matches the application's design language. */ -function SwitchNetworkModal({ - onConfirm, - onCancel, -}: { - onConfirm: () => void; - onCancel: () => void; -}) { - return ( -
-
-
-
- CYZE · NETWORK SETTINGS -
- Switching to Mainnet -
- -
- - Mainnet transactions move real ZEC and are{" "} - irreversible once broadcast. Only switch if you are - ready to handle live funds. - -
- -

- You can switch back to testnet at any time from this page. -

- -
- - -
-
-
- ); -} - /** Known public lightwalletd endpoints per network (user can also type their own). */ const PRESETS: Record = { test: [ @@ -106,7 +31,6 @@ export default function Wallet() { const [info, setInfo] = useState(null); const [testErr, setTestErr] = useState(null); const [testing, setTesting] = useState(false); - const [showMainnetModal, setShowMainnetModal] = useState(false); // Mainnet is the default (matches the backend), so the app opens on the network // it is actually used on rather than quietly pointing at testnet. @@ -148,44 +72,21 @@ export default function Wallet() { } }; - const savedNet = config.data?.network ?? "main"; const isMainnet = net === "main"; return (

Wallet

- {/* Persistent mainnet danger banner — shown any time the active network - is main, both here and as a reminder before the user navigates away. */} - {savedNet === "main" && ( -
- - ⚠ You are on Mainnet. Transactions here move{" "} - real ZEC. Double-check every recipient address and - amount before signing. Signed transactions are irreversible once - broadcast. - -
- )} -

- Cyze syncs Zcash shielded funds as a light client: it scans compact - blocks locally with your group's viewing key and talks to a configurable{" "} - lightwalletd server (no full node - required). Start on testnet to try it with faucet funds; - switch to mainnet once you're ready. + Cyze syncs Zcash as a light client against a configurable{" "} + lightwalletd server — no full node + needed. Start on testnet with faucet funds; switch to mainnet when ready.

Network

-
+
- - {isMainnet - ? "Real ZEC — transactions are irreversible." - : "Safe for testing with faucet funds."} + + {isMainnet ? "Live network — real ZEC." : "Test network — faucet funds."}
@@ -286,18 +186,6 @@ export default function Wallet() { - - {showMainnetModal && ( - { - setShowMainnetModal(false); - setNetwork("main"); - setUrl(""); - }} - onCancel={() => setShowMainnetModal(false)} - /> - )} -
); } diff --git a/src/screens/Wallets.tsx b/src/screens/Wallets.tsx index 83a5511..9df3952 100644 --- a/src/screens/Wallets.tsx +++ b/src/screens/Wallets.tsx @@ -84,8 +84,8 @@ export default function Wallets() { ) : wallets.length === 0 ? (

- No Zcash wallets yet. A wallet is created for each RedPallas (Orchard) - group — create or join one under 2 · Groups. + No Zcash wallets yet. A wallet is created for each RedPallas group — + create or join one under 2 · Groups.

) : ( From e1f50398332a37ce3b00ce51b368d10bdb3fe921 Mon Sep 17 00:00:00 2001 From: blocknodes Date: Sun, 16 Aug 2026 05:56:03 +0000 Subject: [PATCH 15/16] ux(dkg): bold section titles so they stand out from helper text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the DKG wizard the section titles (Role, Group name, Ciphersuite, Threshold, Server, …) are `