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:
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user