From d38c18aed3d8a58a9cd2f6771ea50dce335a073e Mon Sep 17 00:00:00 2001 From: blocknodes Date: Wed, 5 Aug 2026 12:37:34 +0000 Subject: [PATCH 1/2] feat(wallet): single active-wallet model with a Wallets switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make "one active wallet" a first-class concept so the app's processing stays focused on a single account at a time (trial-decryption is CPU-bound, so concurrent group syncs only thrash). Backend: - Settings.active_group_id persists the chosen wallet. - AppState.sync_gate: an app-wide "one sync at a time" lock held for the whole of every wallet_sync, so no two groups ever sync concurrently. - get_active_wallet / set_active_wallet commands. Switching cancels the previously active group's in-flight sync (safe — sync resumes next time); a no-op when the wallet is already active. Frontend: - New Wallets switcher page (4 · Zcash → "Wallets"): lists each Pallas group with its last-known balance and an active indicator; selecting one makes it active and opens it. Switching away from a wallet with an in-progress send/ceremony prompts for confirmation before abandoning it (sync is cancelled silently). - Replaced the Groups sidebar dropdown with a plain Groups link (management stays under 2 · Groups) and a Wallets nav item that surfaces the active wallet as a one-click sub-link. - Opening a wallet asserts it as active (self-healing invariant for direct navigation / deep links; the switcher is the guarded path). Incoming signing requests stay global (Inbox/attention badges unchanged), so switching focus never drops time-sensitive signing duties for other groups. Backend builds, tsc clean, no new clippy warnings. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/commands/server.rs | 31 ++++++ src-tauri/src/commands/wallet.rs | 7 ++ src-tauri/src/lib.rs | 2 + src-tauri/src/state.rs | 13 +++ src/App.tsx | 66 ++++-------- src/ipc/commands.ts | 7 ++ src/screens/Groups.tsx | 16 +++ src/screens/Wallets.tsx | 175 +++++++++++++++++++++++++++++++ 8 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 src/screens/Wallets.tsx diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index 5c6ce14..e7fdb33 100644 --- a/src-tauri/src/commands/server.rs +++ b/src-tauri/src/commands/server.rs @@ -45,6 +45,37 @@ pub async fn set_session_role(state: State<'_, AppState>, role: String) -> AppRe state.save_settings(&settings) } +/// The currently active wallet (group id), or `None` if the user hasn't chosen +/// one yet. Wallet actions are scoped to this group. +#[tauri::command] +pub async fn get_active_wallet(state: State<'_, AppState>) -> AppResult> { + Ok(state.load_settings().active_group_id) +} + +/// Make `group_id` the active wallet. Cancels the previously active group's +/// in-flight sync so the app's processing follows the switch: only one wallet +/// syncs at a time, and stale work on the old wallet is abandoned promptly (a +/// sync is safe to cancel — it resumes from where it left off next time). +#[tauri::command] +pub async fn set_active_wallet(state: State<'_, AppState>, group_id: String) -> AppResult<()> { + let mut settings = state.load_settings(); + // Already active — nothing to change (the wallet page re-asserts this on every + // open, so skip the redundant disk write and sync-cancel). + if settings.active_group_id.as_deref() == Some(group_id.as_str()) { + return Ok(()); + } + let previous = settings.active_group_id.replace(group_id); + state.save_settings(&settings)?; + + // Changing wallets: stop the old one's sync so processing follows the switch. + if let Some(prev) = previous { + if let Some(token) = state.sync_cancels.lock().await.get(&prev) { + token.cancel(); + } + } + Ok(()) +} + /// Determine trust for a given server URL: pinned certs for the embedded /// sidecar and any TOFU-imported external certs, system roots otherwise. pub async fn trust_for(state: &AppState, url: &str) -> ServerTrust { diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index fa81ae7..9a10339 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -225,6 +225,13 @@ pub async fn wallet_sync(state: State<'_, AppState>, group_id: String) -> AppRes } } + // Hold the app-wide sync gate for the whole run so only one wallet ever syncs + // at a time (trial decryption is CPU-bound; concurrent group syncs only + // thrash). We register+cancel the prior same-group token above first, so a + // restart of this group releases the gate before we wait on it; an + // active-wallet switch cancels the other group's sync, so this rarely blocks. + let _gate = state.sync_gate.lock().await; + let batch_size = state.load_settings().sync_batch_size; let result = wallet::sync_group( &state.data_dir, &group_id, network, &url, db_key.as_ref(), batch_size, &cancel, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b58bc33..c053c44 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -94,6 +94,8 @@ pub fn run() { commands::server::set_server_url, commands::server::set_session_config, commands::server::set_session_role, + commands::server::get_active_wallet, + commands::server::set_active_wallet, commands::server::test_server_connection, commands::server::trust_server_cert, commands::server::cert_fingerprint_of, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d4e4d6e..7f8cc3b 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -82,6 +82,12 @@ pub struct Settings { /// they aren't prompted again unless they revisit it. #[serde(default)] pub session_configured: Option, + /// The single **active wallet**: the group whose wallet the app currently + /// works on. Wallet actions (sync, balance, send) are scoped to this one, and + /// only this group syncs — switching cancels the previous group's sync. `None` + /// until the user first selects one. Persisted so the choice survives restart. + #[serde(default)] + pub active_group_id: Option, } /// Per-group rotating receive-address bookkeeping (#3). Non-secret; the actual @@ -123,6 +129,12 @@ 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>, + /// App-wide "one wallet syncs at a time" gate. Trial decryption is CPU-bound + /// and saturates every core, so two groups syncing at once only thrash. Every + /// `wallet_sync` holds this for its whole run; combined with cancelling the + /// previous group's sync on an active-wallet switch, it guarantees the app's + /// processing stays focused on a single wallet. + pub sync_gate: Mutex<()>, /// Epoch-millis of the last user activity, used to drive the idle auto-lock. pub last_activity: AtomicI64, } @@ -147,6 +159,7 @@ impl AppState { sidecar: Mutex::new(None), tunnel: Mutex::new(None), sync_cancels: Mutex::new(HashMap::new()), + sync_gate: Mutex::new(()), last_activity: AtomicI64::new(now_millis()), } } diff --git a/src/App.tsx b/src/App.tsx index 1a6bcec..efc78b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect } from "react"; import { createBrowserRouter, RouterProvider, @@ -6,7 +6,6 @@ import { NavLink, Navigate, useLocation, - useNavigate, } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { listen } from "@tauri-apps/api/event"; @@ -17,6 +16,7 @@ import { listGroups, recordActivity, getSettings, + getActiveWallet, listPendingSessions, } from "./ipc/commands"; import CeremonyListener from "./CeremonyListener"; @@ -31,56 +31,24 @@ import DkgWizard from "./screens/DkgWizard"; import NewSigningSession from "./screens/NewSigningSession"; import Inbox from "./screens/Inbox"; import Wallet from "./screens/Wallet"; +import Wallets from "./screens/Wallets"; -/** Groups nav entry: a single-select dropdown so exactly one group ("active - * wallet") is shown at a time, with only that group's links below it. Keeps - * the sidebar quiet no matter how many groups exist. Follows the current - * route, and switching the picker navigates into the chosen group. */ -function GroupsNavItem() { +/** Zcash "Wallets" nav entry: links to the wallet switcher, and — when a wallet + * is active — shows its name as a one-click sub-link to that wallet. Exactly one + * wallet is active at a time; the switcher page changes it. */ +function WalletsNavItem() { const groups = useQuery({ queryKey: ["groups"], queryFn: listGroups }); - const location = useLocation(); - const navigate = useNavigate(); - - const activeGroupId = useMemo(() => { - const m = location.pathname.match(/^\/groups\/([^/]+)/); - return m ? m[1] : null; - }, [location.pathname]); - const [selectedId, setSelectedId] = useState(activeGroupId); - useEffect(() => { - if (activeGroupId) setSelectedId(activeGroupId); - }, [activeGroupId]); - - const list = groups.data ?? []; - if (!list.length) return null; - - // Always resolve to one real group so the panel shows a single active wallet. - const current = - list.find((g) => g.id === selectedId) ?? list[0]; + const active = useQuery({ queryKey: ["active-wallet"], queryFn: getActiveWallet }); + const activeGroup = (groups.data ?? []).find((g) => g.id === active.data); return (
- - - Details + + Wallets - {current.ciphersuite.includes("Pallas") && ( - - Wallet + {activeGroup && activeGroup.ciphersuite.includes("Pallas") && ( + + {activeGroup.description || `${activeGroup.id.slice(0, 10)}…`} )}
@@ -114,6 +82,7 @@ const NAV_SECTIONS: { title: string; links: { to: string; label: string }[] }[] { title: "4 · Zcash", links: [ + { to: "/wallets", label: "Wallets" }, { to: "/setup", label: "Session Configuration" }, { to: "/wallet", label: "Wallet Settings" }, ], @@ -206,8 +175,8 @@ function Layout() {
{section.title}
{section.links.map((link) => - link.to === "/groups" ? ( - + link.to === "/wallets" ? ( + ) : ( {link.label} @@ -259,6 +228,7 @@ const router = createBrowserRouter([ { path: "dkg", element: }, { path: "sign", element: }, { path: "inbox", element: }, + { path: "wallets", element: }, { path: "wallet", element: }, { path: "server", element: }, { path: "setup", element: }, diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 2939caf..64f46dd 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -38,6 +38,8 @@ export interface Settings { coordinator_exposure: string | null; /** True once first-run Session Configuration has been saved. */ session_configured: boolean | null; + /** The active wallet (group id) the app is focused on, or null if unset. */ + active_group_id: string | null; } export interface SidecarStatus { @@ -330,6 +332,11 @@ export const setSessionConfig = (role: string, exposure?: string | null) => /** Switch the active session profile (coordinator/participant). */ export const setSessionRole = (role: string) => invoke("set_session_role", { role }); +/** The active wallet (group id) the app is focused on, or null if unset. */ +export const getActiveWallet = () => invoke("get_active_wallet"); +/** Make a group the active wallet; cancels the previously active group's sync. */ +export const setActiveWallet = (groupId: string) => + invoke("set_active_wallet", { groupId }); export const testServerConnection = (url: string) => invoke("test_server_connection", { url }); export const trustServerCert = (url: string, certPem: string) => diff --git a/src/screens/Groups.tsx b/src/screens/Groups.tsx index 9d9df68..9060df5 100644 --- a/src/screens/Groups.tsx +++ b/src/screens/Groups.tsx @@ -15,6 +15,7 @@ import { walletInitAccount, walletSync, walletCancelSync, + setActiveWallet, walletSyncProgress, walletPrepareSend, walletPrepareVote, @@ -2452,6 +2453,21 @@ export function GroupWalletPage() { const group = groups.data?.find((g) => g.id === id); const walletConfig = useQuery({ queryKey: ["wallet-config"], queryFn: getWalletConfig }); const isMainnet = walletConfig.data?.network === "main"; + const queryClient = useQueryClient(); + + // Opening a wallet makes it the active wallet, so the app's processing follows + // the view: the backend cancels the previously active wallet's sync, and only + // this one syncs. A no-op when it's already active. The Wallets switcher is the + // guarded path (it confirms before abandoning unfinished work); this keeps the + // invariant for direct navigation / deep links. + const isPallasGroup = group?.ciphersuite.includes("Pallas") ?? false; + useEffect(() => { + if (id && isPallasGroup) { + setActiveWallet(id) + .then(() => queryClient.invalidateQueries({ queryKey: ["active-wallet"] })) + .catch(() => {}); + } + }, [id, isPallasGroup, queryClient]); if (groups.isLoading) return

Loading…

; if (!group) { diff --git a/src/screens/Wallets.tsx b/src/screens/Wallets.tsx new file mode 100644 index 0000000..83a5511 --- /dev/null +++ b/src/screens/Wallets.tsx @@ -0,0 +1,175 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { + listGroups, + getActiveWallet, + setActiveWallet, + getWalletConfig, + walletGroupStatus, + GroupSummary, + AppError, +} from "../ipc/commands"; +import { useCeremonies } from "../stores/ceremonies"; + +/** Amount display from zatoshis (1 unit = 1e8 zatoshis). */ +function zec(zats: number): string { + return (zats / 1e8).toLocaleString(undefined, { maximumFractionDigits: 8 }); +} +function unit(isMainnet: boolean): string { + return isMainnet ? "ZEC" : "TAZ"; +} + +/** + * The wallet switcher. Exactly one wallet is "active" at a time; the whole app's + * wallet processing (sync, balance, send) is focused on it, and only it syncs. + * Selecting a different wallet makes it active and cancels the previous wallet's + * sync — if that wallet has an unfinished send/ceremony, we confirm first, since + * switching abandons it. + */ +export default function Wallets() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const groups = useQuery({ queryKey: ["groups"], queryFn: listGroups }); + const active = useQuery({ queryKey: ["active-wallet"], queryFn: getActiveWallet }); + const walletConfig = useQuery({ queryKey: ["wallet-config"], queryFn: getWalletConfig }); + const isMainnet = walletConfig.data?.network === "main"; + + const activeSendByGroup = useCeremonies((s) => s.activeSendByGroup); + const activeSigningId = useCeremonies((s) => s.activeSigningId); + + const activate = useMutation({ + mutationFn: (groupId: string) => setActiveWallet(groupId), + onSuccess: (_r, groupId) => { + queryClient.invalidateQueries({ queryKey: ["active-wallet"] }); + queryClient.invalidateQueries({ queryKey: ["settings"] }); + navigate(`/groups/${groupId}/wallet`); + }, + }); + + const wallets = (groups.data ?? []).filter((g) => g.ciphersuite.includes("Pallas")); + const others = (groups.data ?? []).filter((g) => !g.ciphersuite.includes("Pallas")); + const activeId = active.data ?? null; + + const select = (group: GroupSummary) => { + if (group.id === activeId) { + // Already active — just go to its wallet. + navigate(`/groups/${group.id}/wallet`); + return; + } + // Switching away: if the wallet we're leaving has unfinished work, confirm. + const leavingHasSend = activeId ? !!activeSendByGroup[activeId] : false; + const busy = leavingHasSend || !!activeSigningId; + if (busy) { + const ok = window.confirm( + "The current wallet has a signing ceremony or send in progress. " + + "Switching wallets will abandon it. Continue?" + ); + if (!ok) return; + } + activate.mutate(group.id); + }; + + return ( +
+

Wallets

+

+ The app works on one wallet at a time. Select a wallet to + make it active — only the active wallet syncs, and switching cancels the + previous wallet's sync so processing stays focused on one account. +

+ + {groups.isLoading ? ( +

Loading…

+ ) : wallets.length === 0 ? ( +
+

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

+
+ ) : ( +
+ {wallets.map((g) => ( + select(g)} + /> + ))} +
+ )} + + {activate.isError && ( +
+ {(activate.error as unknown as AppError).message} +
+ )} + + {others.length > 0 && ( +

+ {others.length} non-Zcash group{others.length === 1 ? "" : "s"} (ed25519) + aren't wallets and are managed under 2 · Groups. +

+ )} +
+ ); +} + +function WalletRow({ + group, + isMainnet, + isActive, + pending, + onSelect, +}: { + group: GroupSummary; + isMainnet: boolean; + isActive: boolean; + pending: boolean; + onSelect: () => void; +}) { + // Read-only last-known status (does not trigger a sync). Balance is whatever the + // wallet last scanned; it refreshes once this wallet is active and syncs. + const status = useQuery({ + queryKey: ["wallet-status", group.id], + queryFn: () => walletGroupStatus(group.id), + }); + const s = status.data; + const total = s?.total_zatoshis ?? 0; + + return ( +
+
+
+ + {group.description || `${group.id.slice(0, 10)}…`} + + {isActive && active} +
+
+ {group.threshold}-of-{group.num_participants} + {" · "} + {!s || !s.initialized + ? "not set up yet" + : `${zec(total)} ${unit(isMainnet)}`} +
+
+ +
+ ); +} From 601d8bcac58df52528c46c890991ac63c8546f5a Mon Sep 17 00:00:00 2001 From: blocknodes Date: Wed, 5 Aug 2026 23:08:11 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(nav):=20active=20group's=20Details=20+?= =?UTF-8?q?=20Wallet=20sub-links=20under=202=20=C2=B7=20Groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reposition the active group's quick-nav: the "2 · Groups" section now shows Groups plus, for the active group, sub-links to its Details (key info / participants) and its Wallet — where group management naturally lives. The "Wallets" entry under 4 · Zcash is now just the switcher link. The active wallet is still chosen on the switcher; this only surfaces its Details/Wallet in the Groups section. tsc clean. Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index efc78b3..7cc4cf0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,23 +33,31 @@ import Inbox from "./screens/Inbox"; import Wallet from "./screens/Wallet"; import Wallets from "./screens/Wallets"; -/** Zcash "Wallets" nav entry: links to the wallet switcher, and — when a wallet - * is active — shows its name as a one-click sub-link to that wallet. Exactly one - * wallet is active at a time; the switcher page changes it. */ -function WalletsNavItem() { +/** Groups nav entry: the Groups list plus, for the active group, quick sub-links + * to its Details (key info, participants) and its Wallet. The single active + * wallet is chosen on the Wallets switcher (4 · Zcash); this just surfaces its + * Details/Wallet where group management lives. */ +function GroupsNavItem() { const groups = useQuery({ queryKey: ["groups"], queryFn: listGroups }); const active = useQuery({ queryKey: ["active-wallet"], queryFn: getActiveWallet }); const activeGroup = (groups.data ?? []).find((g) => g.id === active.data); return (
- - Wallets + + Groups - {activeGroup && activeGroup.ciphersuite.includes("Pallas") && ( - - {activeGroup.description || `${activeGroup.id.slice(0, 10)}…`} - + {activeGroup && ( + <> + + Details + + {activeGroup.ciphersuite.includes("Pallas") && ( + + Wallet + + )} + )}
); @@ -175,8 +183,8 @@ function Layout() {
{section.title}
{section.links.map((link) => - link.to === "/wallets" ? ( - + link.to === "/groups" ? ( + ) : ( {link.label}