2 Commits
v0.2.1 ... main

Author SHA1 Message Date
bfb016d76f chore(release): 0.3.0
All checks were successful
ci / gate (push) Successful in 12m58s
release / linux (push) Successful in 14m33s
release / package (44) (push) Successful in 36s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 1m25s
Restoring a phrase finds the accounts it already uses (#69), and any open
wallet can be rescanned from the accounts page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-18 09:47:21 +03:00
fb42138651 feat: find the accounts a restored phrase already uses
Some checks failed
ci / gate (push) Has been cancelled
Restoring a phrase now scans for the account numbers it already uses,
instead of opening account 1 and leaving the rest to be guessed at. Each
number is derived under both schemes, the chain is asked in one request
which of those addresses exist (a System::Account entry is there for an
account that has ever been funded), and the scan carries on while a batch
held any, stopping after a gap of 20 empty numbers, as the mobile
wallet's import does. Numbers found are opened and remembered like a
hand-added one, so they come back on the next unlock. The same scan is a
"Find accounts" button on every open wallet with a phrase, and it says
what it found. A wallet with no phrase, or a chain that is not connected,
offers no scan; a failure during restore is swallowed, since the wallet
is open either way and the button retries.

On the dev node: 7 DEV sent from the CLI to account 3 of the dev phrase,
then that phrase restored here. Without pressing anything the wallet
opened accounts 1, 2 and 3 under both schemes, account 3 showing the
7 DEV, after looking at 40 numbers; settings recorded 1 and 2. On a
freshly created phrase, Find accounts looked at 20 numbers, added
nothing, and said so.

Closes #69

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-18 09:47:01 +03:00
13 changed files with 278 additions and 15 deletions

8
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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],

View File

@@ -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,
}

View File

@@ -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": {

View 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, };

View File

@@ -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()

View File

@@ -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 })

View File

@@ -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"

View File

@@ -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('')