diff --git a/src/servers/sidecar/wallet/backends/onchain.ts b/src/servers/sidecar/wallet/backends/onchain.ts index 55a954bb..8364eb7a 100644 --- a/src/servers/sidecar/wallet/backends/onchain.ts +++ b/src/servers/sidecar/wallet/backends/onchain.ts @@ -61,6 +61,7 @@ import { type NewAddressRequest, type NodeInfo, type OnchainTx, + type RescanState, type SendCoinsRequest, type SendCoinsResult, type SignMessageResult, @@ -312,9 +313,31 @@ export class OnchainBackend extends BaseBackend { syncedAt: this.current?.at ?? null, stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS, lastError: this.lastError, + rescan: this.chain.rescanState?.() ?? null, }; } + /** + * Ask the chain source to go looking for this wallet's coins from scratch. + * + * Returns as soon as the search is queued — it takes minutes, and the caller is an HTTP route. The + * refresh chained onto its completion is the part that matters: a rescan that found coins has changed + * nothing visible until the snapshot in front of it is rebuilt, and without this the owner would be + * staring at the same zero until the TTL happened to expire. + */ + async startRescan(): Promise { + const start = this.chain.startRescan?.bind(this.chain); + if (!start) this.notSupported('rescanning the chain'); + const { state, done } = start(this.scanContext().accounts); + void done + .then(() => this.refresh()) + // The rescan's own failure is already on its state and reported through getSyncState; a failed + // refresh behind it lands on lastError the same way. Neither should surface as an unhandled + // rejection in the sidecar's log. + .catch(() => {}); + return state; + } + /** * The wallet's view of the chain, stale-while-revalidate. * diff --git a/src/servers/sidecar/wallet/chain-source-nbxplorer.ts b/src/servers/sidecar/wallet/chain-source-nbxplorer.ts index 2584d93d..4956ca20 100644 --- a/src/servers/sidecar/wallet/chain-source-nbxplorer.ts +++ b/src/servers/sidecar/wallet/chain-source-nbxplorer.ts @@ -6,16 +6,21 @@ // many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide, // so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths. // -// PRUNED-NODE CAVEAT, and it is a real one: this node is pruned to 25 GB, so a newly tracked xpub only -// picks up activity from the moment it is registered. History older than the prune horizon will not -// backfill and this source will report it as absent, not as an error. That is fine for a wallet created -// here and wrong for one being recovered from an old seed — which is exactly why Esplora stays available -// rather than being replaced. +// REGISTRATION IS NOT RECOVERY, and this is the trap: a newly tracked xpub only picks up activity from +// the moment it is registered. An imported seed therefore reads as a real, quiet, empty wallet — every +// call succeeds, nothing errors, and the coins are simply not in the index. `startRescan` below is the +// answer for balance and spending: `scantxoutset` walks the node's current UTXO set directly, so it finds +// coins regardless of when the account was registered or how far back the node is pruned. +// +// What a rescan does NOT recover is spent-transaction history predating registration; that is a block +// rescan, a much heavier thing, and this source will keep reporting it as absent rather than as an error. +// Esplora stays available for the owner who wants the full history back. import * as bitcoin from 'bitcoinjs-lib'; import { type AddressEntry, type ChainIndex, + type RescanHandle, type ScanAccount, type ScanContext, type ScannedAddress, @@ -25,11 +30,29 @@ import { import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer'; import type { SpendableUtxo } from './psbt'; import { networkFor } from './psbt'; -import { BackendError, type AddressType, type BitcoinNetwork, type FeeEstimates, type OnchainTx } from './types'; +import { + BackendError, + type AddressType, + type BitcoinNetwork, + type FeeEstimates, + type OnchainTx, + type RescanState, +} from './types'; /** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */ const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const; +/** How often to ask how a `scantxoutset` is going. Frequent enough that a finished result is never missed. */ +const SCAN_POLL_MS = 2_000; + +/** Per-variant ceiling. A scan of a large UTXO set is minutes; anything past this is a wedged node. */ +const SCAN_TIMEOUT_MS = 10 * 60_000; + +/** Consecutive 404s tolerated before a variant has ever reported a status — see `scanVariant`. */ +const SCAN_NULL_GRACE = 5; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string }; export class NbxplorerChainSource implements WalletChainSource { @@ -47,6 +70,10 @@ export class NbxplorerChainSource implements WalletChainSource { */ private accounts: readonly ScanAccount[] = []; + /** The rescan in flight, or the last one's outcome. Mutated in place as the scan progresses. */ + private rescan: RescanState | null = null; + private rescanRun: Promise | null = null; + constructor(opts: NbxplorerChainSourceOptions) { this.chain = opts.chain; this.btcNetwork = networkFor(opts.network); @@ -132,6 +159,95 @@ export class NbxplorerChainSource implements WalletChainSource { return { tipHeight, addresses, utxos, txs }; } + rescanState(): RescanState | null { + return this.rescan; + } + + /** + * Run `scantxoutset` over every account variant, one at a time. + * + * ALL FOUR VARIANTS, ALWAYS. A recovered seed's coins can sit on any script type, and nothing here can + * tell which until the node has looked — the p2sh account of a wallet that only ever used native segwit + * costs one wasted scan, whereas skipping it costs the owner their money. The node serialises + * `scantxoutset` anyway, so scanning them sequentially is what happens regardless of what we ask for; + * doing it explicitly is what makes `done`/`total` mean something on screen. + * + * A second call while one is running returns the same handle rather than queueing a duplicate. + */ + startRescan(accounts: readonly ScanAccount[]): RescanHandle { + const existing = this.rescan; + if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun }; + + const state: RescanState = { + running: true, + done: 0, + total: accounts.length, + current: accounts[0]?.type ?? null, + found: 0, + startedAt: Date.now(), + finishedAt: null, + error: null, + }; + this.rescan = state; + this.rescanRun = this.runRescan(accounts, state); + return { state, done: this.rescanRun }; + } + + private async runRescan(accounts: readonly ScanAccount[], state: RescanState): Promise { + try { + for (const account of accounts) { + state.current = account.type; + state.found += await this.scanVariant(account); + state.done += 1; + } + } catch (err) { + // One variant failing stops the run, and the partial `found` stays on the state: coins already + // pulled in by an earlier variant are really there, and reporting zero would understate the wallet. + state.error = err instanceof Error ? err.message : String(err); + } finally { + state.running = false; + state.current = null; + state.finishedAt = Date.now(); + } + return state; + } + + /** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */ + private async scanVariant(account: ScanAccount): Promise { + const { accountXpub, type } = account; + await this.chain.startUtxoScan(accountXpub, type); + + const deadline = Date.now() + SCAN_TIMEOUT_MS; + let found = 0; + let seen = false; + let nulls = 0; + + while (Date.now() < deadline) { + await sleep(SCAN_POLL_MS); + const status = await this.chain.getUtxoScanStatus(accountXpub, type); + + // 404 means "no scan is running" — which before the first sighting is a race with the queue, and + // after it means the result has expired. Only the second reading is a finish, hence the flag; the + // grace bounds the first so a scan the node silently dropped does not hold the run for ten minutes. + if (!status) { + if (seen) return found; + if (++nulls >= SCAN_NULL_GRACE) return found; + continue; + } + + seen = true; + nulls = 0; + found = status.progress?.found ?? found; + + if (status.status === 'Complete') return found; + if (status.status === 'Error') { + throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN'); + } + } + + throw new BackendError(`utxo scan of the ${type} account did not finish in time`, 504, 'SCAN_TIMEOUT'); + } + private async scanAccount(ctx: ScanContext, account: ScanAccount) { const { accountXpub, type } = account; // track() is idempotent and every one of these calls makes it first, so registration cannot be diff --git a/src/servers/sidecar/wallet/chain-source.ts b/src/servers/sidecar/wallet/chain-source.ts index 94633ae1..2f74538e 100644 --- a/src/servers/sidecar/wallet/chain-source.ts +++ b/src/servers/sidecar/wallet/chain-source.ts @@ -16,7 +16,7 @@ // about which addresses are the wallet's, which is the kind of bug that loses coins rather than failing. import type { SpendableUtxo } from './psbt'; -import type { AddressType, FeeEstimates, OnchainTx } from './types'; +import type { AddressType, FeeEstimates, OnchainTx, RescanState } from './types'; /** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */ export type ChainIndex = 0 | 1; @@ -69,6 +69,15 @@ export type ScanContext = { derive: (type: AddressType, chain: ChainIndex, index: number) => AddressEntry; }; +/** + * A rescan in flight. + * + * `state` is the SAME object the source keeps and mutates as the scan progresses, so a caller that holds + * it sees the counters move without asking again. `done` is how the wallet knows to re-read the chain: + * a rescan that found coins has changed nothing until the snapshot behind it is rebuilt. + */ +export type RescanHandle = { state: RescanState; done: Promise }; + /** One coherent read of the chain — balances, coins and history as of the same moment. */ export type ScanResult = { tipHeight: number; @@ -103,6 +112,19 @@ export interface WalletChainSource { /** Read the whole wallet off the chain. */ scan(ctx: ScanContext): Promise; + + /** + * Search the chain for this wallet's coins from scratch, rather than from whenever the upstream + * started watching it. Returns immediately — a rescan takes minutes, and the caller is an HTTP route. + * + * Optional, and Esplora does not implement it — it has nothing to rescan, because a gap-limit walk + * already asks about every address every time. This exists for an upstream that *indexes*, where a + * newly registered account starts empty and stays empty until told to go and look. + */ + startRescan?(accounts: readonly ScanAccount[]): RescanHandle; + + /** The rescan in flight, or the last one's outcome. Null when this source has never run one. */ + rescanState?(): RescanState | null; } /** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */ diff --git a/src/servers/sidecar/wallet/nbxplorer.ts b/src/servers/sidecar/wallet/nbxplorer.ts index aa5d4381..29264149 100644 --- a/src/servers/sidecar/wallet/nbxplorer.ts +++ b/src/servers/sidecar/wallet/nbxplorer.ts @@ -113,6 +113,40 @@ export type NbxAddress = { redeem?: string | null; }; +/** + * `GET …/utxos/scan` — how a `scantxoutset` sweep is going. + * + * Every field below `status` is optional on purpose: NBXplorer fills `progress` only once the node has + * actually started, and a queued scan reports nothing but its place in the queue. + */ +export type NbxScanStatus = { + status: 'Queued' | 'Pending' | 'Complete' | 'Error'; + error?: string | null; + queuedAt?: string; + progress?: { + startedAt?: string; + completedAt?: string | null; + /** UTXOs pulled in. This is the number that answers "did the scan find my coins". */ + found?: number; + batchNumber?: number; + remainingBatches?: number; + currentBatchProgress?: number; + overallProgress?: number; + remainingSeconds?: number; + highestKeyIndexFound?: Partial>; + } | null; +}; + +/** + * `POST …/utxos/scan` parameters. + * + * The defaults are the values the node operator verified live. `gapLimit` 1000 is generous for any + * wallet that has not used addresses beyond index 1000; raising it costs node CPU, not correctness. + */ +export type UtxoScanOptions = { batchSize?: number; gapLimit?: number; from?: number }; + +const SCAN_DEFAULTS = { batchSize: 1000, gapLimit: 1000, from: 0 } as const; + /** A failed broadcast comes back as HTTP 200 with `success: false` — never as an HTTP error. */ export type NbxBroadcastResult = { success: boolean; @@ -153,7 +187,7 @@ export class NbxplorerChain { } /** Every request funnels through here, so a timeout and an upstream failure share one error shape. */ - private async raw(path: string, init: RequestInitLite = {}): Promise { + private async send(path: string, init: RequestInitLite): Promise<{ status: number; body: string }> { const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); @@ -173,23 +207,48 @@ export class NbxplorerChain { clearTimeout(timer); } - const body = await res.text(); - if (!res.ok) { - // NBXplorer answers with {"code","message"} where it can, and bare text where it cannot. - let message = body.slice(0, 300); - try { - const parsed = JSON.parse(body) as { message?: unknown }; - if (typeof parsed.message === 'string' && parsed.message) message = parsed.message; - } catch { - /* not its JSON envelope */ - } - throw new BackendError(`nbxplorer ${res.status} on ${path}: ${message}`, res.status, 'NBXPLORER_ERROR'); + return { status: res.status, body: await res.text() }; + } + + private fail(path: string, status: number, body: string): never { + // NBXplorer answers with {"code","message"} where it can, and bare text where it cannot. + let message = body.slice(0, 300); + try { + const parsed = JSON.parse(body) as { message?: unknown }; + if (typeof parsed.message === 'string' && parsed.message) message = parsed.message; + } catch { + /* not its JSON envelope */ } + throw new BackendError(`nbxplorer ${status} on ${path}: ${message}`, status, 'NBXPLORER_ERROR'); + } + + private async raw(path: string, init: RequestInitLite = {}): Promise { + const { status, body } = await this.send(path, init); + if (status < 200 || status >= 300) this.fail(path, status, body); return body; } private async json(path: string, init?: RequestInitLite): Promise { const body = await this.raw(path, init); + return this.parse(path, body); + } + + /** + * Like `json`, but a 404 is an answer rather than a failure. + * + * Only the scan-status endpoint needs this, and it needs it badly: NBXplorer 404s both when no scan is + * running and once a finished scan's result has expired. Treating that as an error would turn the two + * most ordinary moments of a rescan — before it starts, and a while after it ends — into upstream + * failures on the wallet screen. + */ + private async jsonOrNull(path: string, init?: RequestInitLite): Promise { + const { status, body } = await this.send(path, init ?? {}); + if (status === 404) return null; + if (status < 200 || status >= 300) this.fail(path, status, body); + return this.parse(path, body); + } + + private parse(path: string, body: string): T { try { return JSON.parse(body) as T; } catch { @@ -277,6 +336,42 @@ export class NbxplorerChain { return hex; } + /** + * `POST …/utxos/scan` — sweep the node's whole UTXO set for this account's coins. + * + * WHY THIS EXISTS AT ALL. Registering a scheme only makes NBXplorer index it *from now on*. An + * imported xpub with a history therefore reads as a real, quiet, zero-balance wallet: every call + * succeeds, nothing errors, and the coins are simply not there. This endpoint is the fix — it runs + * bitcoind's `scantxoutset`, which walks the current UTXO set directly and finds coins regardless of + * when the account was registered or how far back the node is pruned. + * + * It finds spendable coins, NOT history. Spent-transaction history predating registration stays + * missing; recovering that is a block rescan, which is a different and much heavier thing. + * + * Returns as soon as the scan is queued. `scantxoutset` is single-threaded and IO-heavy on the node, + * so concurrent scans queue and run one after another — poll `getUtxoScanStatus` for the outcome. + */ + async startUtxoScan(accountXpub: string, type: AddressType, opts: UtxoScanOptions = {}): Promise { + await this.track(accountXpub, type); + const q = new URLSearchParams({ + batchSize: String(opts.batchSize ?? SCAN_DEFAULTS.batchSize), + gapLimit: String(opts.gapLimit ?? SCAN_DEFAULTS.gapLimit), + from: String(opts.from ?? SCAN_DEFAULTS.from), + }); + await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan?${q}`, { method: 'POST' }); + } + + /** + * `GET …/utxos/scan` — the running scan, or null. + * + * Null means "nothing to report": no scan is running, or one finished long enough ago that NBXplorer + * has dropped the result. Both are 404s and neither is a failure, so poll promptly and read a null + * after a `Complete` as "it is over", not as "it vanished". + */ + getUtxoScanStatus(accountXpub: string, type: AddressType): Promise { + return this.jsonOrNull(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan`); + } + /** `GET /v1/cryptos/BTC/fees/{blockCount}` — one target per call, sat/vB as a float. */ async getFeeRate(blockCount: number): Promise { const res = await this.json<{ feeRate: number; blockCount: number }>(`${this.root}/fees/${blockCount}`); diff --git a/src/servers/sidecar/wallet/routes.ts b/src/servers/sidecar/wallet/routes.ts index 22a8c104..e1009b5b 100644 --- a/src/servers/sidecar/wallet/routes.ts +++ b/src/servers/sidecar/wallet/routes.ts @@ -273,6 +273,18 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise { makeActive: b.makeActive ?? true, }); + // An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream + // only watches an account from the moment it is registered, so a wallet with a history would otherwise + // show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it + // would burn several minutes of the node's CPU to confirm nothing. + if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id); + // Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and // has no other chance to see it without re-entering the passphrase. An imported mnemonic is never // echoed back: the caller already has it, and echoing would put it in a response log for no reason. return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201); } +/** + * Start a rescan behind the response that created the wallet. + * + * Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a + * successful wallet import into a failed one. The owner can trigger it by hand from Settings either way. + */ +async function kickOffRescan(userId: number, walletId: number): Promise { + try { + const { backend } = await resolveBackend(userId, walletId); + await backend.startRescan?.(); + } catch (err) { + console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err); + } +} + async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise { const wallet = await getWallet(ctx.userId, walletId); if (!wallet) return json({ error: 'wallet not found' }, 404); diff --git a/src/servers/sidecar/wallet/types.ts b/src/servers/sidecar/wallet/types.ts index 6fb487ce..eddac036 100644 --- a/src/servers/sidecar/wallet/types.ts +++ b/src/servers/sidecar/wallet/types.ts @@ -298,6 +298,15 @@ export interface WalletBackend { * be inventing one. */ getSyncState?(): SyncState; + + /** + * Ask the chain to search for this wallet's coins from scratch. Resolves as soon as the search is + * queued, not when it finishes — progress is read back through `getSyncState().rescan`. + * + * Optional because only a wallet-level chain source can do it. Absent means the backend has nothing to + * rescan (a node backend already knows its own coins) or the source has no such endpoint. + */ + startRescan?(): Promise; } /** @@ -312,6 +321,34 @@ export type SyncState = { stale: boolean; /** The last refresh failure, still reported while the previous good data is being served. */ lastError: string | null; + /** A deep rescan in flight, or the outcome of the last one. Null from a source that cannot rescan. */ + rescan: RescanState | null; +}; + +/** + * A deep chain rescan — the upstream searching the whole UTXO set for a wallet's coins, rather than + * indexing forward from the moment the wallet was registered. + * + * This exists for exactly one situation, and it is not a rare one: an imported seed on a node that only + * started watching the xpub today. Every read succeeds, nothing errors, and the balance is zero — which + * is indistinguishable from an empty wallet unless the UI can say "still looking". So the state is + * carried on SyncState beside `syncedAt`, on the same responses, for the same reason. + * + * It runs one account variant at a time because the node runs `scantxoutset` one at a time; four + * variants is minutes, not seconds. + */ +export type RescanState = { + running: boolean; + /** Account variants finished, out of how many were queued. */ + done: number; + total: number; + /** The variant the node is chewing on right now. Null when nothing is running. */ + current: AddressType | null; + /** UTXOs the finished variants pulled in. */ + found: number; + startedAt: number; + finishedAt: number | null; + error: string | null; }; // ── errors ─────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx b/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx new file mode 100644 index 00000000..0a6cad7a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx @@ -0,0 +1,91 @@ +import { CheckCircle2, Loader2, Search, TriangleAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ADDRESS_TYPE_LABELS } from './shared'; +import { useBalances, useWalletOperations } from './useWalletData'; + +// Search the chain for coins this wallet already owns. +// +// THIS IS THE FIX FOR AN IMPORTED WALLET THAT READS ZERO, and that symptom is why the card exists at all. +// An indexing chain source (NBXplorer in front of your own node) only watches an account from the moment +// you register it, so a seed with a history arrives at a balance of nothing: every call succeeds, no error +// is raised, the coins are simply not in the index yet. A scan walks the node's current UTXO set directly +// and finds them regardless of when the account was registered or how far back the node is pruned. +// +// It recovers SPENDABLE COINS, not history. Transactions spent before the account was registered stay +// missing — that needs a full block rescan, which is a heavier thing this does not do. +// +// Hidden entirely when the chain source cannot rescan: Esplora asks about every address on every refresh, +// so it has nothing to catch up on, and offering a button that 501s would invent a problem. + +type RescanCardProps = { walletId: number }; + +export const RescanCard = ({ walletId }: RescanCardProps) => { + const { sync } = useBalances(walletId); + const { rescan } = useWalletOperations(walletId); + + // `sync.rescan` is null from a source with no rescan endpoint AND from one that has simply never run + // one, so the card has to stay visible in the second case. `sync` itself being null is a node backend, + // which owns its own coins and has nothing to look for. + if (!sync) return null; + + const state = sync.rescan; + const running = state?.running === true; + + return ( +
+

+ Find missing coins +

+

+ Ask the node to search its whole UTXO set for this wallet’s coins. Run this after importing a seed that already + had funds — an indexing node only watches an account from the moment it is added, so an older balance shows as + zero until it has looked. It scans all four address types, one at a time, and takes a few minutes. +

+ + + + {state && } +
+ ); +}; + +type StatusProps = { state: NonNullable['sync']>['rescan'] }; + +const Status = ({ state }: StatusProps) => { + if (!state) return null; + + if (state.running) { + const label = state.current ? ADDRESS_TYPE_LABELS[state.current] : 'queued'; + return ( +

+ Scanning {label} — {state.done} of {state.total} done + {state.found > 0 && `, ${state.found} coin${state.found === 1 ? '' : 's'} found so far`}. +

+ ); + } + + if (state.error) { + return ( +

+ + + Stopped after {state.done} of {state.total}: {state.error} + +

+ ); + } + + return ( +

+ + Last scan found {state.found} coin{state.found === 1 ? '' : 's'} across {state.total} address types. +

+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx b/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx index 915cbb4e..20459efb 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx @@ -38,6 +38,22 @@ export const SyncBadge = ({ sync, className }: SyncBadgeProps) => { // would be inventing a fact. if (!sync) return null; + // A rescan outranks everything else here, including a stale age and an upstream error. While the node + // is searching, the number beside this badge is not "your balance" — it is "your balance so far", and + // that difference is the entire reason an imported wallet showing zero is alarming rather than boring. + const { rescan } = sync; + if (rescan?.running) { + return ( + + + Scanning the chain · {Math.min(rescan.done + 1, rescan.total)} of {rescan.total} + + ); + } + const failing = sync.lastError !== null; // Never read successfully AND failing: the only case where the numbers beside this are not real. Say so diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx index e1500f9c..ca06e215 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx @@ -9,6 +9,7 @@ import { ChainSourceCard } from './ChainSourceCard'; import { CopyField } from './CopyField'; import { EmptyWallet } from './EmptyWallet'; import { LockBadge } from './LockBadge'; +import { RescanCard } from './RescanCard'; import { useSelectedWallet } from './useSelectedWallet'; import { useLockCountdown } from './useLockCountdown'; import { useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData'; @@ -132,6 +133,8 @@ export const WalletSettingsView = () => { + + {config && (

Deployment

diff --git a/src/workspaces/officerdev/src/apps/Wallet/shared.ts b/src/workspaces/officerdev/src/apps/Wallet/shared.ts index 41b27a50..6d403975 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/shared.ts +++ b/src/workspaces/officerdev/src/apps/Wallet/shared.ts @@ -123,10 +123,38 @@ export type SyncState = { syncedAt: number | null; stale: boolean; lastError: string | null; + /** A deep rescan in flight, or the last one's outcome. Null from a chain source that cannot rescan. */ + rescan: RescanState | null; +}; + +/** + * The node searching its whole UTXO set for this wallet's coins. + * + * Only ever non-null on NBXplorer, and it exists because an imported wallet on a freshly-tracked xpub + * reads as a confident zero: nothing errors, the balance is just empty until the node has looked. Four + * script variants, one at a time, minutes not seconds — which is precisely why it has to be on screen. + */ +export type RescanState = { + running: boolean; + done: number; + total: number; + current: AddressType | null; + found: number; + startedAt: number; + finishedAt: number | null; + error: string | null; }; export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'; +/** What each script type is actually called out loud, for anything the owner reads rather than greps. */ +export const ADDRESS_TYPE_LABELS: Record = { + p2wpkh: 'native segwit', + 'p2sh-p2wpkh': 'wrapped segwit', + p2pkh: 'legacy', + p2tr: 'taproot', +}; + export type OnchainTx = { txid: string; /** Net effect on this wallet in sats — negative for a spend. */ diff --git a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts index 42ddb0ec..ee18f572 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts +++ b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts @@ -11,6 +11,7 @@ import type { OnchainTx, Payment, Peer, + RescanState, SendCoinsResult, SyncState, Utxo, @@ -42,6 +43,8 @@ const BALANCE_POLL_MS = 20_000; const LOCK_POLL_MS = 10_000; /** History and coins are cheaper to refresh on demand than to poll hard. */ const HISTORY_POLL_MS = 60_000; +/** While a rescan runs, the balance response doubles as its progress feed — poll it like one. */ +const RESCAN_POLL_MS = 4_000; const EMPTY_WALLETS: WalletSummary[] = []; const EMPTY_CAPS: Capability[] = []; @@ -151,7 +154,9 @@ export function useBalances(walletId: number | null) { queryKey: [...ROOT_KEY, 'balances', walletId] as const, queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`), enabled: walletId != null, - refetchInterval: BALANCE_POLL_MS, + // Faster while the node is rescanning: this response carries the rescan's progress, and a counter + // that moves once every twenty seconds reads as a hung one. Back to the ordinary poll when it ends. + refetchInterval: (query) => (query.state.data?.sync?.rescan?.running ? RESCAN_POLL_MS : BALANCE_POLL_MS), staleTime: BALANCE_POLL_MS - 1_000, }); @@ -510,7 +515,19 @@ export function useWalletOperations(walletId: number | null) { onError: (err) => toast.error(errorMessage(err, 'Could not save the label')), }); - return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel }; + // Returns as soon as the scan is queued — it takes minutes. Progress arrives on the `sync.rescan` block + // of the balances poll, which speeds up on its own while one is running, so the invalidation here is + // only to put the first "scanning…" on screen without waiting out the current interval. + const rescan = useMutation({ + mutationFn: () => post<{ rescan: RescanState }>(`${base(walletId!)}/rescan`, {}), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'balances', walletId] }); + toast.success('Scanning the chain for this wallet’s coins — this takes a few minutes'); + }, + onError: (err) => toast.error(errorMessage(err, 'Could not start the scan')), + }); + + return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel, rescan }; } /**