Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
bfb016d76f
|
|||
|
fb42138651
|
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -6956,7 +6956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-app"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6983,7 +6983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-core"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake2",
|
||||
@@ -6999,7 +6999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-data"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -7032,7 +7032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-entities"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -11,9 +11,9 @@ authors = ["Rob Thijssen <rob@lair.cafe>"]
|
||||
repository = "https://git.lair.cafe/blackbeard/wallet"
|
||||
|
||||
[workspace.dependencies]
|
||||
wallet-entities = { path = "crates/wallet-entities", version = "=0.2.1" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.2.1" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.2.1" }
|
||||
wallet-entities = { path = "crates/wallet-entities", version = "=0.3.0" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.3.0" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.3.0" }
|
||||
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -22,8 +22,8 @@ use wallet_data::keys::seed_from_mnemonic;
|
||||
use wallet_data::mnemonic;
|
||||
use wallet_data::substrate::{ChainManager, account_info};
|
||||
use wallet_entities::{
|
||||
AccountBalance, AccountRef, Amount, AssetId, AssetKind, ChainId, ChainProfile, ChainStatus,
|
||||
HighSecurityInfo, HistoryCursor, HistoryPage, OpenWalletInfo, PendingReversible,
|
||||
AccountBalance, AccountRef, AccountScan, Amount, AssetId, AssetKind, ChainId, ChainProfile,
|
||||
ChainStatus, HighSecurityInfo, HistoryCursor, HistoryPage, OpenWalletInfo, PendingReversible,
|
||||
PreparedTransferInfo, SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo,
|
||||
WormholeSummary,
|
||||
};
|
||||
@@ -267,6 +267,142 @@ pub fn unlock(
|
||||
session_status(state)
|
||||
}
|
||||
|
||||
/// How many consecutive empty account numbers end a scan, as the mobile
|
||||
/// wallet's import uses (BIP-44's gap limit).
|
||||
const ACCOUNT_GAP_LIMIT: u32 = 20;
|
||||
|
||||
/// Find the account numbers a wallet's phrase already uses on `chain`:
|
||||
/// derive each number under every scheme, ask the chain which of those
|
||||
/// addresses exist, and keep going while a batch held any. Numbers found
|
||||
/// are opened and remembered like a hand-added one. Returns the status and
|
||||
/// how many numbers were added.
|
||||
#[tauri::command]
|
||||
pub async fn wallet_discover_accounts(
|
||||
state: State<'_, AppState>,
|
||||
chain: ChainId,
|
||||
wallet: String,
|
||||
) -> Result<AccountScan, WalletError> {
|
||||
let profile = state
|
||||
.profiles
|
||||
.get(&chain)
|
||||
.cloned()
|
||||
.ok_or_else(|| WalletError::NotFound(format!("chain {}", chain.0)))?;
|
||||
let manager = state
|
||||
.chains
|
||||
.lock()
|
||||
.map_err(|_| WalletError::Internal("state poisoned".into()))?
|
||||
.get(&chain)
|
||||
.cloned()
|
||||
.ok_or_else(|| WalletError::Internal(format!("{} not connected", chain.0)))?;
|
||||
let conn = manager.connection().map_err(chain_error)?;
|
||||
|
||||
let mut settings = state
|
||||
.settings
|
||||
.load()
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
let known: std::collections::BTreeSet<u32> = state
|
||||
.session
|
||||
.status(Instant::now())
|
||||
.map_err(session_error)?
|
||||
.wallets
|
||||
.iter()
|
||||
.find(|w| w.summary.name == wallet)
|
||||
.ok_or_else(|| WalletError::NotFound(format!("open wallet {wallet}")))?
|
||||
.accounts
|
||||
.iter()
|
||||
.filter_map(|a| wallet_core::paths::account_of(&a.derivation_path))
|
||||
.chain(
|
||||
settings
|
||||
.wallet_accounts
|
||||
.get(&wallet)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied(),
|
||||
)
|
||||
.collect();
|
||||
|
||||
let mut found: Vec<u32> = Vec::new();
|
||||
let mut next = 0u32;
|
||||
let mut scanned = 0u32;
|
||||
loop {
|
||||
let numbers: Vec<u32> = (next..next + ACCOUNT_GAP_LIMIT).collect();
|
||||
// Derive the batch's accounts inside the session's seed closure, so
|
||||
// the keys are dropped with it.
|
||||
let derived = state
|
||||
.session
|
||||
.with_seed(&wallet, |seed| {
|
||||
numbers
|
||||
.iter()
|
||||
.map(|&n| wallet_data::keystore::accounts_at(seed, n))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.map_err(session_error)?
|
||||
.ok_or_else(|| {
|
||||
WalletError::Refused(format!(
|
||||
"{wallet} is a single key with no recovery phrase; it has no further accounts"
|
||||
))
|
||||
})?
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
let ids: Vec<[u8; 32]> = derived
|
||||
.iter()
|
||||
.flat_map(|accounts| accounts.iter().map(|a| a.signer.account_id()))
|
||||
.collect();
|
||||
let exists = wallet_data::substrate::accounts::which_exist(&conn, &ids)
|
||||
.await
|
||||
.map_err(chain_error)?;
|
||||
let per_number = wallet_core::paths::SCHEMES.len();
|
||||
let mut any = false;
|
||||
for (i, &number) in numbers.iter().enumerate() {
|
||||
let used = exists[i * per_number..(i + 1) * per_number]
|
||||
.iter()
|
||||
.any(|&e| e);
|
||||
if used {
|
||||
any = true;
|
||||
if !known.contains(&number) && !found.contains(&number) {
|
||||
found.push(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
scanned += ACCOUNT_GAP_LIMIT;
|
||||
next += ACCOUNT_GAP_LIMIT;
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for &number in &found {
|
||||
let accounts = state
|
||||
.session
|
||||
.with_seed(&wallet, |seed| {
|
||||
wallet_data::keystore::accounts_at(seed, number)
|
||||
})
|
||||
.map_err(session_error)?
|
||||
.ok_or(WalletError::Locked)?
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
state
|
||||
.session
|
||||
.add_accounts(&wallet, accounts)
|
||||
.map_err(session_error)?;
|
||||
let numbers = settings.wallet_accounts.entry(wallet.clone()).or_default();
|
||||
if !numbers.contains(&number) {
|
||||
numbers.push(number);
|
||||
numbers.sort_unstable();
|
||||
}
|
||||
}
|
||||
if !found.is_empty() {
|
||||
state
|
||||
.settings
|
||||
.save(&settings)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
}
|
||||
tracing::info!(%wallet, chain = %profile.id.0, scanned, added = found.len(), "account scan");
|
||||
Ok(AccountScan {
|
||||
added: found.len() as u32,
|
||||
scanned,
|
||||
session: session_status(state)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add the next account number to an open wallet that carries a phrase:
|
||||
/// derived under every scheme, opened at once, and remembered in settings so
|
||||
/// the next unlock brings it back. A wallet with no phrase has no further
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn run() {
|
||||
commands::checkphrase,
|
||||
commands::unlock,
|
||||
commands::wallet_add_account,
|
||||
commands::wallet_discover_accounts,
|
||||
commands::lock,
|
||||
commands::session_status,
|
||||
commands::chains_list,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "blackbeard-wallet",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"identifier": "cafe.lair.blackbeard.wallet",
|
||||
"build": {
|
||||
"frontendDist": "../../ui/dist",
|
||||
|
||||
@@ -100,6 +100,42 @@ pub async fn storage_at_best(
|
||||
Ok((conn.client.storage().at(best_hash), at_block))
|
||||
}
|
||||
|
||||
/// Which of `accounts` exist on chain, in one request: a `System::Account`
|
||||
/// entry is present for an account that has ever been funded, and absent
|
||||
/// for one that has not (on Substrate an empty account is not an account
|
||||
/// with zero, it is no account at all). Used to find the account numbers a
|
||||
/// restored phrase already uses.
|
||||
pub async fn which_exist(
|
||||
conn: &ChainConnection,
|
||||
accounts: &[[u8; 32]],
|
||||
) -> Result<Vec<bool>, ChainError> {
|
||||
if accounts.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let keys: Vec<Vec<u8>> = accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let addr = subxt::dynamic::storage("System", "Account", vec![Value::from_bytes(*a)]);
|
||||
conn.client
|
||||
.storage()
|
||||
.address_bytes(&addr)
|
||||
.map_err(|e| ChainError::Other(e.to_string()))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let sets = conn
|
||||
.rpc
|
||||
.state_query_storage_at(keys.iter().map(Vec::as_slice), None)
|
||||
.await
|
||||
.map_err(rpc_err)?;
|
||||
let present: std::collections::HashSet<Vec<u8>> = sets
|
||||
.iter()
|
||||
.flat_map(|s| s.changes.iter())
|
||||
.filter(|(_, v)| v.is_some())
|
||||
.map(|(k, _)| k.0.clone())
|
||||
.collect();
|
||||
Ok(keys.iter().map(|k| present.contains(k)).collect())
|
||||
}
|
||||
|
||||
pub async fn account_info(
|
||||
conn: &ChainConnection,
|
||||
account: [u8; 32],
|
||||
|
||||
@@ -797,3 +797,14 @@ pub struct SwapAsk {
|
||||
pub refund_to: String,
|
||||
pub slippage_bps: u16,
|
||||
}
|
||||
|
||||
/// What a scan for a phrase's used accounts found.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct AccountScan {
|
||||
/// Account numbers opened that were not already known.
|
||||
pub added: u32,
|
||||
/// Account numbers looked at.
|
||||
pub scanned: u32,
|
||||
pub session: SessionStatusInfo,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wallet-ui",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
|
||||
15
ui/src/api/generated/AccountScan.ts
Normal file
15
ui/src/api/generated/AccountScan.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SessionStatusInfo } from "./SessionStatusInfo";
|
||||
|
||||
/**
|
||||
* What a scan for a phrase's used accounts found.
|
||||
*/
|
||||
export type AccountScan = {
|
||||
/**
|
||||
* Account numbers opened that were not already known.
|
||||
*/
|
||||
added: number,
|
||||
/**
|
||||
* Account numbers looked at.
|
||||
*/
|
||||
scanned: number, session: SessionStatusInfo, };
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
settingsSet,
|
||||
unlock,
|
||||
walletAddAccount,
|
||||
walletDiscoverAccounts,
|
||||
walletsList,
|
||||
} from './wallet'
|
||||
|
||||
@@ -82,6 +83,19 @@ export function useAddAccount() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Scan a wallet's phrase for account numbers already used on `chain`. */
|
||||
export function useDiscoverAccounts() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ chain, wallet }: { chain: ChainId; wallet: string }) =>
|
||||
walletDiscoverAccounts(chain, wallet),
|
||||
onSuccess: (scan) => {
|
||||
qc.setQueryData(queryKeys.session, scan.session)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.settings })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Lock one wallet (pass its name) or every open wallet (pass nothing). */
|
||||
export function useLock() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
|
||||
import type { CreationStart } from './generated/CreationStart'
|
||||
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
|
||||
import type { Settings } from './generated/Settings'
|
||||
import type { AccountScan } from './generated/AccountScan'
|
||||
import type { AssetInfo } from './generated/AssetInfo'
|
||||
import type { HistoryCursor } from './generated/HistoryCursor'
|
||||
import type { Order } from './generated/Order'
|
||||
@@ -116,6 +117,15 @@ export function walletAddAccount(wallet: string): Promise<SessionStatusInfo> {
|
||||
return command<SessionStatusInfo>('wallet_add_account', { wallet })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the account numbers a wallet's phrase already uses on `chain`: the
|
||||
* chain is asked which derived addresses exist, and the ones found are
|
||||
* opened and remembered.
|
||||
*/
|
||||
export function walletDiscoverAccounts(chain: ChainId, wallet: string): Promise<AccountScan> {
|
||||
return command<AccountScan>('wallet_discover_accounts', { chain, wallet })
|
||||
}
|
||||
|
||||
/** Lock one open wallet by name, or all of them when none is named. */
|
||||
export function lock(wallet?: string): Promise<void> {
|
||||
return command<void>('lock', { wallet: wallet ?? null })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useAddAccount,
|
||||
useDiscoverAccounts,
|
||||
useBalances,
|
||||
useChains,
|
||||
useLock,
|
||||
@@ -28,6 +29,7 @@ export function Accounts() {
|
||||
const session = useSession()
|
||||
const lock = useLock()
|
||||
const addAccount = useAddAccount()
|
||||
const discover = useDiscoverAccounts()
|
||||
const unlocked = !!session.data && !session.data.locked
|
||||
const balances = useBalances(settings.data?.network, unlocked)
|
||||
const chains = useChains()
|
||||
@@ -63,6 +65,14 @@ export function Accounts() {
|
||||
{balances.isPending && <p className="panel-body muted">Reading balances from the chain…</p>}
|
||||
{balances.isError && <p className="error">{balances.error.message}</p>}
|
||||
{addAccount.isError && <p className="error">{addAccount.error.message}</p>}
|
||||
{discover.isError && <p className="error">{discover.error.message}</p>}
|
||||
{discover.isSuccess && (
|
||||
<p className="panel-body muted" style={{ paddingTop: 0 }}>
|
||||
{discover.data.added > 0
|
||||
? `Found ${discover.data.added} more ${discover.data.added === 1 ? 'account' : 'accounts'} in ${discover.data.scanned} looked at.`
|
||||
: `Nothing beyond the accounts already open, in ${discover.data.scanned} account numbers looked at.`}
|
||||
</p>
|
||||
)}
|
||||
{session.data && (
|
||||
<table className="table" data-testid="accounts">
|
||||
<thead>
|
||||
@@ -88,6 +98,19 @@ export function Accounts() {
|
||||
</th>
|
||||
<th className="num">
|
||||
<span className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
|
||||
{w.has_seed && chain && (
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={discover.isPending}
|
||||
title="Ask the chain which of this phrase's accounts have been used"
|
||||
onClick={() => discover.mutate({ chain, wallet: w.wallet.name })}
|
||||
>
|
||||
{discover.isPending && discover.variables?.wallet === w.wallet.name
|
||||
? 'Looking…'
|
||||
: 'Find accounts'}
|
||||
</button>
|
||||
)}
|
||||
{w.has_seed && (
|
||||
<button
|
||||
className="button"
|
||||
|
||||
@@ -3,14 +3,16 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { CreationStart } from '../api/generated/CreationStart'
|
||||
import { pickWalletFile } from '../api/dialog'
|
||||
import { queryKeys } from '../api/hooks'
|
||||
import { queryKeys, useSettings } from '../api/hooks'
|
||||
import {
|
||||
chainConnect,
|
||||
checkphrase,
|
||||
unlock,
|
||||
walletCreateBegin,
|
||||
walletCreateCancel,
|
||||
walletCreateConfirm,
|
||||
walletImportFile,
|
||||
walletDiscoverAccounts,
|
||||
walletImportPhrase,
|
||||
} from '../api/wallet'
|
||||
|
||||
@@ -43,12 +45,27 @@ export function OnboardingIndex() {
|
||||
)
|
||||
}
|
||||
|
||||
function useFinish() {
|
||||
function useFinish(discover = false) {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
const discoverOn = discover ? settings.data?.network : undefined
|
||||
return async (name: string, password: string) => {
|
||||
await qc.invalidateQueries({ queryKey: queryKeys.wallets })
|
||||
const status = await unlock(name, password)
|
||||
// A restored phrase may already use accounts beyond the first. Best
|
||||
// effort: the chain has to be reachable, and a failure here must not
|
||||
// stand between the person and their wallet.
|
||||
if (discoverOn) {
|
||||
try {
|
||||
await chainConnect(discoverOn)
|
||||
const scan = await walletDiscoverAccounts(discoverOn, name)
|
||||
qc.setQueryData(queryKeys.session, scan.session)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.settings })
|
||||
} catch {
|
||||
// the wallet is open; Find accounts on the accounts page retries
|
||||
}
|
||||
}
|
||||
// Leave onboarding before the session flips, so the shell mounts on the
|
||||
// accounts page rather than on a route that no longer applies.
|
||||
navigate('/', { replace: true })
|
||||
@@ -254,7 +271,7 @@ export function Create() {
|
||||
}
|
||||
|
||||
export function Restore() {
|
||||
const finish = useFinish()
|
||||
const finish = useFinish(true)
|
||||
const [phrase, setPhrase] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
Reference in New Issue
Block a user