feat: remove a wallet that holds nothing
ci / gate (push) Canceled after 6m53s

An open wallet can be removed from the accounts page (#86): its file is
deleted, it leaves the session, and its account numbers and names leave
the settings. Whether it holds nothing is decided in Rust, never by the
webview, and a refusal says why:

- every account must hold zero, free plus reserved, on every mainnet
  chain, read live; a chain that cannot be read refuses rather than
  assumes;
- no reversible transfer may be pending to or from any of its accounts;
- a phrase wallet's wormhole address must hold no unspent leaf, with the
  zk-tree scan current so none is unseen;
- the wallet whose key controls the intents account is refused while
  that account holds any token; if it holds none, the record goes with
  the wallet and the next creation makes a new account.

The control asks once, inline, and names what happens; no browser
dialog. Keystore::remove reads the file first, so a symlink or a
stranger's file is refused before anything is deleted; Session::forget
drops the one wallet and keeps the password.

Checked in a dev run with three wallets: Remove on a raw-key wallet
holding nothing asked, deleted its file, took it out of the sidebar and
cleaned its settings, with the other two left open.

Closes #86

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
This commit is contained in:
2026-10-12 12:39:21 +03:00
co-authored by Claude Fable 5.1
parent 20f171be96
commit caff8a6be6
8 changed files with 302 additions and 0 deletions
+175
View File
@@ -610,6 +610,181 @@ pub fn wallet_add_account(
session_status(state)
}
/// Remove a wallet that holds nothing (#86): its file is deleted, it leaves
/// the session, and its account numbers and names leave the settings.
/// "Nothing" is established here, not in the webview: zero on every
/// mainnet chain's accounts, no reversible transfer pending to or from
/// them, no unspent wormhole leaf with the scan current, and not the
/// wallet whose key controls an intents account that holds anything. A
/// chain that cannot be read refuses the removal rather than assuming.
#[tauri::command]
pub async fn wallet_remove(
state: State<'_, AppState>,
name: String,
) -> Result<SessionStatusInfo, WalletError> {
use wallet_core::near::INTENTS;
use wallet_data::keys::wormhole::WormholeKeys;
use wallet_data::substrate::wormhole as zk;
use wallet_entities::ChainKind;
let status = state
.session
.status(Instant::now())
.map_err(session_error)?;
let wallet = status
.wallets
.iter()
.find(|w| w.summary.name == name)
.ok_or_else(|| {
WalletError::Refused(format!(
"{name} is not open; only an open wallet can be checked and removed"
))
})?;
let account_ids: Vec<[u8; 32]> = wallet.accounts.iter().map(|a| a.account_id).collect();
let refuse = |why: String| Err(WalletError::Refused(format!("{name} is not empty: {why}")));
for profile in state
.profiles
.iter()
.filter(|p| p.kind == ChainKind::Mainnet)
.cloned()
.collect::<Vec<_>>()
{
let addresses: std::collections::HashSet<String> = account_ids
.iter()
.map(|id| ss58::encode(profile.ss58_prefix, id))
.collect();
let balances = read_balances(&state, &profile.id).await.map_err(|e| {
WalletError::Refused(format!(
"{} could not be read, so {name} cannot be shown empty: {e}",
profile.display_name
))
})?;
for b in balances.iter().filter(|b| addresses.contains(&b.address)) {
if b.total.raw > 0 {
return refuse(format!(
"{} holds {} {}",
b.address,
b.total.to_decimal_string(),
b.symbol
));
}
}
let adapter = adapter_for(&state, &profile.id)?;
for id in &account_ids {
let pending = zk::under_rate_limit(|| adapter.pending_reversible(*id))
.await
.map_err(chain_error)?;
if !pending.is_empty() {
return refuse(format!(
"{} has {} reversible transfer(s) still pending",
ss58::encode(profile.ss58_prefix, id),
pending.len()
));
}
}
if wallet.has_seed {
let keys = state
.session
.with_seed(&name, |seed| WormholeKeys::derive(seed, 0))
.map_err(session_error)?
.ok_or_else(|| WalletError::Internal("phrase missing".into()))?
.map_err(|e| WalletError::Internal(e.to_string()))?;
let address = keys.address(profile.ss58_prefix);
let conn = adapter_for(&state, &profile.id)?
.manager()
.connection()
.map_err(chain_error)?;
let leaf_count = zk::under_rate_limit(|| zk::leaf_count(&conn))
.await
.map_err(chain_error)?;
let scanned_to = state
.shielded_cache
.scanned_to(&profile.id, &address)
.map_err(|e| WalletError::Internal(e.to_string()))?;
if scanned_to < leaf_count {
return refuse(format!(
"its wormhole scan on {} is behind ({scanned_to} of {leaf_count} leaves); open the accounts page and let it finish",
profile.display_name
));
}
let shielded: u128 = state
.shielded_cache
.leaves(&profile.id, &address)
.map_err(|e| WalletError::Internal(e.to_string()))?
.iter()
.filter(|l| !l.spent)
.map(|l| l.amount)
.sum();
if shielded > 0 {
return refuse(format!(
"its wormhole address holds {} {} shielded",
Amount::new(shielded, profile.token_decimals).to_decimal_string(),
profile.token_symbol
));
}
}
}
// The intents account's owner: refused while the account holds
// anything; otherwise the record goes with the wallet.
let mut settings = state
.settings
.load()
.map_err(|e| WalletError::Internal(e.to_string()))?;
if settings
.intents_account
.as_ref()
.is_some_and(|r| r.wallet == name)
{
let rec = settings.intents_account.clone().expect("checked");
let tokens: Vec<String> = state
.assets
.iter()
.filter_map(|e| e.intents.clone())
.filter(|t| t.starts_with("nep141:"))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let raw = state
.near
.view(
INTENTS,
"mt_batch_balance_of",
&serde_json::json!({ "account_id": rec.account_id, "token_ids": tokens }),
)
.await
.map_err(|e| {
WalletError::Refused(format!("its intents account could not be read: {e}"))
})?;
let raw: Vec<String> = serde_json::from_value(raw).unwrap_or_default();
if raw.iter().any(|r| r.parse::<u128>().unwrap_or(0) > 0) {
return refuse(format!(
"its key controls the intents account {}, which holds tokens; move them out first",
rec.account_id
));
}
settings.intents_account = None;
}
state.keystore.remove(&name).map_err(keystore_error)?;
state.session.forget(&name).map_err(session_error)?;
settings.wallet_accounts.remove(&name);
for profile in state.profiles.iter() {
for id in &account_ids {
settings
.account_names
.remove(&ss58::encode(profile.ss58_prefix, id));
}
}
state
.settings
.save(&settings)
.map_err(|e| WalletError::Internal(e.to_string()))?;
tracing::info!(wallet = %name, "wallet removed: it held nothing");
session_status(state)
}
/// Lock: every wallet, together; the password is wiped with them.
#[tauri::command]
pub fn lock(state: State<'_, AppState>) -> Result<(), WalletError> {
+1
View File
@@ -59,6 +59,7 @@ pub fn run() {
commands::checkphrase,
commands::unlock,
commands::wallet_rekey,
commands::wallet_remove,
commands::password_change,
commands::wallet_add_account,
commands::wallet_discover_accounts,
+4
View File
@@ -105,6 +105,10 @@ pub trait Keystore: Send + Sync {
/// Write the wallet's file, unchanged, to `dest`. It stays encrypted.
fn export_file(&self, name: &str, dest: &std::path::Path) -> Result<(), KeystoreError>;
/// Delete a wallet's file. The caller has established that nothing is
/// in it; this only refuses a name that is not a wallet here.
fn remove(&self, name: &str) -> Result<(), KeystoreError>;
}
/// One account an opened wallet can sign with, and where it came from.
+25
View File
@@ -175,6 +175,18 @@ impl Session {
Ok(())
}
/// Drop one wallet from the session, when its file is removed. The
/// others stay open.
pub fn forget(&self, name: &str) -> Result<(), SessionError> {
let mut inner = self.inner()?;
let before = inner.wallets.len();
inner.wallets.retain(|w| w.wallet.name != name);
if inner.wallets.len() == before {
return Err(SessionError::NoSuchWallet(name.to_owned()));
}
Ok(())
}
/// Remember the password the open wallets were decrypted with.
pub fn set_password(&self, password: Zeroizing<String>) -> Result<(), SessionError> {
self.inner()?.password = Some(password);
@@ -575,6 +587,19 @@ mod tests {
Err(SessionError::NoSuchWallet(_))
));
// Forgetting one leaves the other signing, and the password held.
s.forget("spending").unwrap();
assert!(matches!(
s.sign(&a1, b"m", b"", t0),
Err(SessionError::NoSuchAccount)
));
assert!(s.sign(&a3, b"m", b"", t0).is_ok());
assert!(matches!(
s.forget("spending"),
Err(SessionError::NoSuchWallet(_))
));
assert!(s.password().unwrap().is_some());
// Accounts added to an open wallet sign; duplicates are skipped.
assert_eq!(
s.add_accounts("savings", named("x", &[4, 3]).accounts)
+29
View File
@@ -586,6 +586,14 @@ impl Keystore for FileKeystore {
Ok(fresh.len())
}
fn remove(&self, name: &str) -> Result<(), KeystoreError> {
let path = self.path_for(name)?;
// Read it first: a symlink or a non-wallet file is refused, and a
// missing one is NotFound, before anything is deleted.
let _ = self.load(name)?;
fs::remove_file(&path).map_err(|e| KeystoreError::Io(format!("{}: {e}", path.display())))
}
fn export_file(&self, name: &str, dest: &Path) -> Result<(), KeystoreError> {
let encrypted = self.load(name)?;
let text = serde_json::to_vec_pretty(&encrypted)
@@ -1010,4 +1018,25 @@ mod tests {
ks.rekey("crystal_alice", "odd", "two").unwrap();
assert_eq!(ks.rekey_all("two", "three").unwrap(), 3);
}
#[test]
fn remove_deletes_a_wallet_and_nothing_else() {
let dir = tempfile::tempdir().unwrap();
let ks = FileKeystore::in_dir(dir.path());
ks.import_file(&fixtures().join("v-ml-dsa-65-89001.json"), "", "")
.unwrap();
ks.import_file(&fixtures().join("crystal_alice.json"), "", "")
.unwrap();
ks.remove("crystal_alice").unwrap();
assert_eq!(ks.list().unwrap().len(), 1);
assert!(matches!(
ks.remove("crystal_alice"),
Err(KeystoreError::NotFound(_))
));
assert!(matches!(
ks.remove("../etc/passwd"),
Err(KeystoreError::InvalidName(_))
));
assert!(ks.open("v-ml-dsa-65-89001", "").is_ok());
}
}
+14
View File
@@ -21,6 +21,7 @@ import {
unlock,
walletAddAccount,
walletDiscoverAccounts,
walletRemove,
walletsList,
} from './wallet'
@@ -100,6 +101,19 @@ export function useDiscoverAccounts() {
}
/** Lock one wallet (pass its name) or every open wallet (pass nothing). */
/** Remove an empty wallet; the session and the wallet list both change. */
export function useRemoveWallet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => walletRemove(name),
onSuccess: (s) => {
qc.setQueryData(queryKeys.session, s)
qc.invalidateQueries({ queryKey: queryKeys.wallets })
qc.invalidateQueries({ queryKey: ['dashboard'] })
},
})
}
export function useLock() {
const qc = useQueryClient()
return useMutation({
+7
View File
@@ -142,6 +142,13 @@ export function walletRekey(name: string, oldPassword: string): Promise<SessionS
return command<SessionStatusInfo>('wallet_rekey', { name, oldPassword })
}
// Remove a wallet that holds nothing. Rust establishes that it is empty
// (accounts, pending transfers, shielded leaves, the intents account) and
// refuses with the reason otherwise; its file is deleted.
export function walletRemove(name: string): Promise<SessionStatusInfo> {
return command<SessionStatusInfo>('wallet_remove', { name })
}
// Every wallet file re-encrypted, all or none.
export function passwordChange(current: string, next: string): Promise<void> {
return command<void>('password_change', { current, new: next })
+47
View File
@@ -1,6 +1,8 @@
import { useState } from 'react'
import {
useAddAccount,
useDiscoverAccounts,
useRemoveWallet,
useBalances,
useChains,
useSession,
@@ -111,6 +113,7 @@ export function Accounts() {
: 'Find accounts'}
</button>
)}
<RemoveWallet name={w.wallet.name} />
{w.has_seed && (
<button
className="button"
@@ -176,3 +179,47 @@ export function Accounts() {
</>
)
}
/**
* Remove an open wallet that holds nothing. The click asks once, inline;
* Rust then checks every account, pending transfer, shielded leaf and the
* intents account, and refuses with the reason if anything is there.
*/
function RemoveWallet({ name }: { name: string }) {
const [asking, setAsking] = useState(false)
const remove = useRemoveWallet()
if (!asking) {
return (
<button
className="button"
type="button"
title="Delete this wallet's file, if it holds nothing"
onClick={() => {
remove.reset()
setAsking(true)
}}
>
Remove
</button>
)
}
return (
<span className="row" style={{ gap: 8, alignItems: 'center' }}>
<span className="muted" style={{ fontSize: 12, textAlign: 'right' }}>
Delete {name}'s file? Only if it holds nothing; its phrase is its backup.
</span>
<button
className="button danger"
type="button"
disabled={remove.isPending}
onClick={() => remove.mutate(name, { onError: () => setAsking(false) })}
>
{remove.isPending ? 'Checking…' : 'Remove'}
</button>
<button className="button" type="button" onClick={() => setAsking(false)}>
Keep
</button>
{remove.isError && <span className="error">{remove.error.message}</span>}
</span>
)
}