Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/deckard-app/src/palette_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down
79 changes: 72 additions & 7 deletions crates/deckard-app/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -283,12 +286,16 @@ pub struct Shell {
pub portfolio: Option<Portfolio>,
/// 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<Address>,
pub portfolio_error: Option<String>,
/// 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<ReadStatus>,
/// Latest block height — a liveness/sync indicator for the status line.
pub synced_block: Option<u64>,
/// Handle to the running balance auto-refresh loop; dropped on lock to cancel it.
poll_task: Option<gpui::Task<()>>,
/// 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand All @@ -1163,6 +1182,21 @@ impl Shell {
.detach();
}

fn kick_public_balance_refresh(&mut self, cx: &mut Context<Self>) -> 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<Self>) {
let rx = eth.block_number();
Expand All @@ -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<Self>) {
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.
Expand All @@ -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<Self>) {
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<Self>) {
// Each retarget supersedes the last; a slow ENS resolve checks this before applying.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
79 changes: 79 additions & 0 deletions crates/deckard-core/examples/dev-vault.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
Loading