diff --git a/crates/deckard-app/src/palette_commands.rs b/crates/deckard-app/src/palette_commands.rs index 4590b50..fe2a903 100644 --- a/crates/deckard-app/src/palette_commands.rs +++ b/crates/deckard-app/src/palette_commands.rs @@ -38,6 +38,13 @@ pub const COMMANDS: &[Command] = &[ shortcut: None, icon: None, }, + Command { + id: "refresh", + title: "Refresh balances", + aliases: &["refresh", "reload", "sync", "refetch", "update balances"], + shortcut: None, + icon: Some(IconName::Replace), + }, Command { id: "send", title: "Send", @@ -310,7 +317,7 @@ mod tests { let results = rank("", COMMANDS, &usage, 0, &mut m); assert_eq!(results.len(), COMMANDS.len()); - assert_eq!(COMMANDS.len(), 11); + assert_eq!(COMMANDS.len(), 12); // The swap command joined the registry (#25); membership is asserted below. assert!( COMMANDS.iter().any(|c| c.id == "swap"), diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index dd0a9cb..3c95de1 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -39,6 +39,9 @@ use crate::{ ToggleTheme, APP_NAME, }; +/// Auto-refresh the public wallet balance every this many seconds while the home view is open. +const BALANCE_POLL_SECS: u64 = 20; + /// How long the user must hold the shield confirm before it signs — the deliberate-gesture /// duration (DESIGN: confirm is a hold, never a tap). The amber fill-sweep (`shield_view`) /// runs for the same span so the bar fills exactly as the action fires. @@ -283,12 +286,16 @@ pub struct Shell { pub portfolio: Option, /// True only during the first sync (the one allowed loading state). pub portfolio_loading: bool, + /// Address currently being refreshed. Separate from `portfolio_loading`, which is visual state. + portfolio_refresh_in_flight: Option
, pub portfolio_error: Option, /// Trust label for the last portfolio/block read: Helios-`Verified` vs visibly /// `Unsynced`/`Degraded`. Never silently "trusted" — surfaced in the status line. pub read_status: Option, /// Latest block height — a liveness/sync indicator for the status line. pub synced_block: Option, + /// Handle to the running balance auto-refresh loop; dropped on lock to cancel it. + poll_task: Option>, /// Bumped on every `retarget`; a slow ENS resolution checks it before applying so a /// stale reply for a since-changed target can't clobber the current view. view_epoch: u64, @@ -606,9 +613,11 @@ impl Shell { viewing_watch: false, portfolio: None, portfolio_loading: false, + portfolio_refresh_in_flight: None, portfolio_error: None, read_status: None, synced_block: None, + poll_task: None, view_epoch: 0, current_rpc, chain_id, @@ -659,6 +668,9 @@ impl Shell { .detach(); self.wallet_address = None; self.portfolio = None; + self.portfolio_refresh_in_flight = None; + // Dropping the task cancels the balance auto-refresh loop. + self.poll_task = None; // Dropping the handle closes its channel → the sync worker thread exits. self.shielded = None; self.railgun_address = None; @@ -972,6 +984,7 @@ impl Shell { self.retarget(cx); self.kick_railgun_grant(cx); self.kick_agent_policy(cx); + self.start_balance_poll(cx); } /// Fetch the daemon's live policy for the agent home (off the UI thread). Key-less: @@ -1142,10 +1155,16 @@ impl Shell { cx.spawn(async move |this, cx| { let res = rx.recv_async().await; this.update(cx, |this, cx| { + if this.portfolio_refresh_in_flight == Some(addr) { + this.portfolio_refresh_in_flight = None; + } + // Ignore stale replies for an address we are no longer viewing. + if addr != this.display_address { + return; + } this.portfolio_loading = false; match res { Ok(Ok(read)) => { - // Ignore a stale reply for an address we're no longer viewing. if read.value.address == this.display_address { this.portfolio = Some(read.value); this.portfolio_error = None; @@ -1163,6 +1182,21 @@ impl Shell { .detach(); } + fn kick_public_balance_refresh(&mut self, cx: &mut Context) -> bool { + let addr = self.display_address; + if self.portfolio_refresh_in_flight == Some(addr) { + return false; + } + self.portfolio_refresh_in_flight = Some(addr); + if self.portfolio.is_none() { + self.portfolio_loading = true; + } + self.portfolio_error = None; + Self::kick_portfolio(&self.eth, addr, cx); + Self::kick_block_number(&self.eth, cx); + true + } + /// Refresh the latest block height for the status line. fn kick_block_number(eth: &EthProvider, cx: &mut Context) { let rx = eth.block_number(); @@ -1181,12 +1215,7 @@ impl Shell { /// Re-fetch the portfolio for the current `display_address` (manual or post-change). pub fn refresh_portfolio(&mut self, cx: &mut Context) { - if self.portfolio.is_none() { - self.portfolio_loading = true; - } - self.portfolio_error = None; - Self::kick_portfolio(&self.eth, self.display_address, cx); - Self::kick_block_number(&self.eth, cx); + self.kick_public_balance_refresh(cx); // An MCP/CLI agent shields through the daemon WITHOUT this app in the loop, so a // manual refresh must re-scan the shielded balance too — otherwise an agent-path // deposit stays invisible until the next unlock. @@ -1197,6 +1226,40 @@ impl Shell { cx.notify(); } + /// Auto-refresh the PUBLIC balance while the wallet home is open, so funds that arrive + /// out-of-band (a faucet top-up, an incoming transfer) appear without a manual refresh. + /// Deliberately lightweight: re-reads ONLY the public balance + block height — the heavier + /// shielded resync stays on the explicit refresh (header button / ⌘K command). Stored in + /// `poll_task`; dropping it (on lock / re-unlock) cancels the loop. Epoch-fenced as + /// belt-and-suspenders against a stale tick after a fast re-unlock. + fn start_balance_poll(&mut self, cx: &mut Context) { + let epoch = self.auth_epoch; + self.poll_task = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_secs(BALANCE_POLL_SECS)) + .await; + let keep = this.update(cx, |this, cx| { + // End the loop once this unlocked session is over (lock / re-unlock bumps the epoch). + if this.auth != AuthStep::Ready || this.auth_epoch != epoch { + return false; + } + // Only while the wallet home is showing, and never stacked on the first load. + if matches!(this.surface, Surface::Home) + && this.selection == Selection::Wallet + && this.portfolio_refresh_in_flight.is_none() + { + this.kick_public_balance_refresh(cx); + } + true + }); + if !matches!(keep, Ok(true)) { + break; + } + } + })); + } + /// Point the portfolio at the wallet, a raw address, or an ENS name (per settings). pub fn retarget(&mut self, cx: &mut Context) { // Each retarget supersedes the last; a slow ENS resolve checks this before applying. @@ -1266,6 +1329,7 @@ impl Shell { return; } self.current_rpc = url.clone(); + self.portfolio_refresh_in_flight = None; self.eth = EthProvider::spawn(url, self.settings.effective_chain_id()); self.retarget(cx); // Re-point the shielded sync at the new RPC too (drops the old worker, clears stale @@ -2250,6 +2314,7 @@ impl Shell { self.select(Selection::Wallet, cx); self.open(Surface::Home, cx); } + "refresh" => self.refresh_portfolio(cx), "send" => self.open_send(cx), "receive" => self.open(Surface::Receive, cx), "shield" => self.open_shield(cx), diff --git a/crates/deckard-core/examples/dev-vault.rs b/crates/deckard-core/examples/dev-vault.rs new file mode 100644 index 0000000..ae12dc8 --- /dev/null +++ b/crates/deckard-core/examples/dev-vault.rs @@ -0,0 +1,79 @@ +//! Dev helper — seal a THROWAWAY vault with a FRESH random wallet. +//! +//! Same idea as `qa-vault`, but instead of anvil's well-known dev mnemonic (whose +//! account 0 is swept by bots the instant it gets ETH on a public testnet), this +//! generates a brand-new HD wallet via the production create path (`Vault::create`, +//! OsRng entropy). Use it for a real-Sepolia clicky run where you faucet your own +//! testnet ETH to a unique address that's actually yours. +//! +//! DECKARD_CONFIG_DIR=/tmp/deckard-montreal-sepolia \ +//! cargo run -q -p deckard-core --example dev-vault +//! +//! Prints ONLY the derived address + the (known, throwaway) passphrase — the backup +//! phrase is generated, sealed, and immediately dropped (zeroized); it is NEVER logged. +//! Fast Argon2 params, baked into the vault header, so unlock is near-instant. +//! +//! WARNING: throwaway wallet. Lives only under `examples/`, so it is never linked into +//! the shipped `deckard` binary. + +use std::path::PathBuf; + +use deckard_core::{config::VAULT_FILE, KdfParams, Vault, WordCount}; + +/// Fixed dev passphrase (>= 8 chars). Override with `DECKARD_DEV_PASS`. +const DEFAULT_PASS: &str = "deckard-qa"; + +fn main() { + let pass = match std::env::var("DECKARD_DEV_PASS") { + Ok(p) if !p.is_empty() => p, + _ => DEFAULT_PASS.to_string(), + }; + + // Resolve the target config dir. NEVER fall back to the real platform keystore. + let dir = match std::env::var_os("DECKARD_CONFIG_DIR") { + Some(v) if !v.is_empty() => PathBuf::from(v), + _ => PathBuf::from("/tmp/deckard-dev"), + }; + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("dev-vault: cannot create {}: {e}", dir.display()); + std::process::exit(1); + } + + // Fast Argon2 (8 MiB / t=1 / p=1) — the floor `validate()` allows. Baked into the + // vault header, so unlock is fast in both the app and the daemon. + let kdf = KdfParams { + m_kib: 8 * 1024, + t: 1, + p: 1, + }; + + // Generate a fresh wallet. The returned phrase is dropped immediately (Zeroizing). + let vault = match Vault::create(&pass, WordCount::Twelve, kdf) { + Ok((v, _phrase)) => v, + Err(e) => { + eprintln!("dev-vault: create failed: {e}"); + std::process::exit(1); + } + }; + + // Derive the address for the log (we print ONLY the address — never seed/key material). + let addr = match vault.unlock(&pass).and_then(|u| u.primary_address()) { + Ok(a) => a, + Err(e) => { + eprintln!("dev-vault: derive address failed: {e}"); + std::process::exit(1); + } + }; + + let path = dir.join(VAULT_FILE); + if let Err(e) = vault.write_atomic(&path) { + eprintln!("dev-vault: write {} failed: {e}", path.display()); + std::process::exit(1); + } + + println!("dev-vault: sealed a throwaway vault (fresh random wallet, fast KDF)"); + println!(" config dir : {}", dir.display()); + println!(" vault file : {}", path.display()); + println!(" address : {addr}"); + println!(" passphrase : {pass}"); +}