feat(audit): multi-chain "Audit balances" wizard with guided recovery#241
Merged
Conversation
Adds a discoverable front door for users who expect a larger portfolio than the Vault shows. Diagnoses then helps recover funds across chains, reusing existing engines rather than rebuilding: - BTC on non-standard paths / un-added higher accounts (sweep-engine) - EVM funds on higher address indices (EvmAddressManager.autoDiscover) - chains that silently failed to price/fetch (three-state coverage) Entry points: an "Audit" button by the portfolio total and a "Run audit" CTA in the existing degraded/stale soft-fault banner. Diagnose-first, then inline recovery (sweep to main address, track accounts) on device. Safety (no device-lease refactor — transport already serializes IO): - Identity phase is read-only (deriveSeedIdentity) — never the purge-capable ensureManagersForSeed. - BTC scan runs a gen-guarded forked loop: aborts to 'aborted' (never 'complete') on wallet-handle swap, sticky stale flag, or N consecutive device failures. End-of-scan + auditSweep re-derive the seed identity so a same-handle passphrase toggle can't finalize clean or sign stale-seed UTXOs. - Honesty model: degraded/stale -> 'unverified' (matched by chainId, not symbol); unresolvable faults -> residual 'unverified'; single-address chains -> 'checked-shallow'. The green all-clear is gated on both, so the wizard never folds a fault into "$0/clean". - Hidden wallet: full diagnosis, recovery framed session-scoped, no PDF. Backend: audit-engine.ts (orchestrator) + audit-coverage.ts (pure classifier, unit-tested) + auditStart/auditGetStatus/auditSweep/ auditDismiss RPCs. Frontend: poll-driven AuditDialog. pioneer-error gains additive chainId-granular fault fields (banner unchanged).
…stom paths Expands the Audit wizard from a flat summary into a chain-by-chain walkthrough. Each chain is an escalation ladder the user drives: verify (balance + block-explorer link) → "Expected more?" → auto-scan 3 account/index levels on-device → "Search 3 more" → custom paths (guided steppers + advanced raw BIP32) → support handoff (copy report + open support link) Recovery is on-device: BTC track account (addBtcAccount), EVM track index (addEvmAddressIndex), BTC non-standard sweep (carried from v1). Chains without multi-account infra (cosmos/xrp/solana/…) surface the address + explorer + handoff rather than a false dead-end. Generalized, reused infra (no per-family reinvention): - chain-scan.ts: family-aware derivation (rpcMethod + ripple/ton/btc quirks), Pioneer GetBalanceAddressByNetwork, explorer-URL templates, BIP32 parse/format. Pure — unit-tested in isolation. - auditScanLevels / auditDeriveCustom RPCs (read-only, gen-guarded). Honesty fixes from adversarial review (8 findings): - UTXO excluded from the single-address level scan (would misread a funded account tree as empty); BTC discovery stays the sweep's job, and "Expected more" on BTC goes straight to custom paths + handoff. - A failed per-level balance lookup is flagged (balanceError) and shown as "could not verify", never a confident "0" — in the UI and handoff. - EVM custom guided stepper varies the same account element [2] the scan and addEvmAddressIndex use (was varying the trailing index element). - Coverage no longer labels an EVM chain "empty/$0" when index discovery found funds the pre-scan snapshot predates (→ checked-shallow). - Funded custom/non-trackable paths get an explicit "send to support" note instead of a silent dead-end; EVM discovery banner shown once; checked-shallow copy is family-aware; chainLevelPath doc corrected. Hidden wallet: handoff redacts addresses; recovery framed session-scoped.
… scanners Turns the per-chain walkthrough into a high-feature recovery/debug tool. UX redesign: - Top network-logo filmstrip stepper (ChainLogo via getAssetIcon) — done checks, current/funded/unverified status rings, click-to-jump, progress line. Cleaner, less-dense hero card; pulsing-ring scan animation. - Accounts 1-3 now AUTO-SCAN on arrival (lazy) for non-EVM chains — fixes the bug where the first levels only scanned after "Expected more". The involuntary scan never blocks the close button (separate autoScanning flag). Opt-in deep scanners under "Dig deeper": - EVM known-paths grid (AuditKnownPaths) — MyEtherWallet-style check of the well-known schemes (Ledger Live, Ledger Legacy/MEW, MetaMask/Trezor, legacy, old Ledger, testnet) via auditScanPaths; funded/unverified rows route to the support handoff (can't auto-track non-standard EVM paths). - BTC wrong-script-type + gap-limit (AuditBtcDeep) — drives the existing sweepScan/sweepExecute with custom config (new gapLimitReceive/ gapLimitChange/higherReceiveLimit on SweepScanConfig; defaults preserve the prior matrix). higherAccountScanLimit:0 so the panel never counts higher-account funds it can't show. - Raw-path inspector (AuditInspector) — derive any path, read back address + pubkey + xpub + balance (auditInspectPath, getPublicKeys). Honesty (carried + reinforced by adversarial review): - New scans are read-only + gen-guarded; nothing signs but the existing device-confirmed sweep/track. - Failed balance lookups → "could not verify" everywhere (grid, inspector, ladder, handoff) — never a confident "0". Unverified known-path results route to the handoff rather than dead-ending. - Filmstrip done-check suppressed on unverified chains. EVM_KNOWN_SCHEMES live in src/shared/evm-paths.ts (shared by RPC caller + grid + tests). 210 unit tests pass; typecheck clean.
Reworks the audit into a proper guided wizard matching the onboarding flow's feel, in a large wide dialog (not the cramped 480px card): - ~880px dialog, var(--ink-1) surface, 2px gold border — the onboarding wizard's shell. Two-column body: chain hero (logo in a glowing ring, symbol, balance, coverage) on the left, explanation + results on the right. - Conversational copy at every step — explains what we're checking and what was/wasn't found per chain, instead of bare labels. - Walks every configured chain; progress bar + logo filmstrip (click-to-jump). - Replaces the single "Dig deeper" with four explicit actions: Looks right ✓ (primary, advances) · Scan more accounts · Scan common wallet paths (EVM) / Scan unusual paths (BTC) · Scan a custom path. Plus "Still missing?" handoff. Known-paths clarity fix (the "same address under different paths" confusion): the schemes collapse at index 0 — BIP44 and Ledger Live both derive m/44'/60'/0'/0/0, the user's main address. AuditKnownPaths now DEDUPES paths across schemes (by bip32 string, which matches the backend pathToBip32), groups the wallet labels per unique path, tags the main address, and shows the full derived path on every row so it's verifiable. Funded / couldn't-verify non-default paths route to the support handoff. Adversarial review: 1 low finding fixed (flex truncation). Honesty invariants preserved (couldn't-verify never shown as 0). 210 unit tests pass; typecheck clean.
auditStart was doing the full BTC sweep + EVM auto-discovery up front, then the per-chain pages re-scanned the same accounts. Now it goes STRAIGHT into the walkthrough and scans lazily per page: - auditStart does only identity (one device call) + coverage classification (pure). No device sweeps. The walkthrough opens immediately. - Bitcoin paths scan lazily when the user opens the Bitcoin page (new auditScanBtc RPC → startBtcScan; report.btcScanState idle→scanning→done; the page polls for progress and shows findings/sweep when done). - EVM (and other) accounts scan lazily per page via the existing auditScanLevels auto-scan; the up-front autoDiscover phase is removed. - classifyCoverage gains a `lazy` mode: at start nothing is deep-confirmed, so non-funded non-degraded chains read "primary address only" (checked-shallow) rather than a premature "empty" — the page confirms them. Degraded still reads "couldn't verify". Adversarial review (1 high + 2 low, all fixed): - startBtcScan now restarts from 'error' (not just 'idle') and resets accumulators, so the Retry button actually re-runs after a failed scan. - auditScanBtc gets the device-ready guard its siblings have (fail fast vs churn through device-IO retries mid-reconnect). - a rejected scan trigger now surfaces a Retry instead of an infinite spinner. 212 unit tests pass (2 new lazy-coverage); typecheck clean.
…alk, finish Reskin/restructure AuditDialog to the design handoff while keeping all real RPC wiring and three-state honesty intact: - Scan: minimized to an honest beat (sonar + constellation + "Walk me through it" gate); drops the fake "scanning BTC/EVM paths" step ladder since that work runs lazily per page. No more auto-advance. - Walkthrough: centered single column with a windowed coin-roll carousel, coverage pill, persistent "Expected more?" action bar, footer nav. Real sweep / track / scan-levels / common / custom / handoff preserved. - Finish: new summary screen (total + recovered + per-chain status). - Gold is now the primary accent; teal reserved for funded/verified. - Honesty: no fabricated USD on finds; "Total verified" downgrades to "Total balance" when any chain is unverified; added a real "check it directly" primary-address re-derive for unverified chains.
- Order the per-chain walk by what people actually hold (BTC/ETH lead via
WALK_ORDER) instead of by coverage rank.
- EVM account scans now classify native + ERC-20 tokens (parseEvmScanResult)
so a token-only address ($0 ETH, $500 USDC) is funded, not a false empty.
- Honesty fix: a degraded (200-but-failed) single-caip portfolio fetch with
nothing found maps to balanceError ("couldn't verify"), never a clean $0.
- Show full chain name + CAIP-19 chip with a tap-to-open "what's a CAIP?"
popover; render token rows with icon/amount/USD.
- BTC level-add is session-only (not persisted yet) — recovery copy says so.
The guided custom-path editor kept defaultPath[0] (purpose 84') fixed when the user changed the script type, so picking Legacy/SegWit derived a DIFFERENT address than the displayed m/84' path implied (purpose and scriptType must agree). Remap [0] to the script's purpose (p2pkh→44', p2sh-p2wpkh→49', p2wpkh→84') via the canonical BTC_SCRIPT_TYPES table.
…ound nothing)
checkAddressBalance queried Pioneer's GetBalanceAddressByNetwork with a
bip122 networkId. Despite the name that endpoint is EVM-only (route
/evm/balance/{networkId}/{address} → ETH JSON-RPC): a Bitcoin address routes
into the ETH provider, throws on the non-hex address, and the call always
returned 0. Both the audit and the manual sweep gate on `balanceSats > 0`,
so the gate never opened — funded BTC addresses on custom/mismatch/higher
paths were silently missed (the exact "where's my money" case). The
magnitude heuristic was dead code on a broken call.
Source the per-address balance from ListUnspent instead (the only Pioneer
endpoint that serves Bitcoin); its UTXO `value` is integer satoshis, so no
unit guessing is needed.
Also forward the BTC gap-limit depths (gapLimitReceive/gapLimitChange/
higherReceiveLimit) from AUDIT_CONFIGS.deep into generatePathMatrix — the
deep audit was silently using the shallow defaults (5/1/3) and missing funds
past the gap. Verified Pioneer contract: the server `depth` query param
(ListUnspent/GetBalanceByXpub, gap=20 default, capped 200) is a separate
xpub-gap-scan knob, not used by the per-address audit path.
Requires live-device verification: a known-funded non-standard BTC address
must now report correct sats.
…C custom read 0)
The audit checked single-address balances via GetBalanceAddressByNetwork at
four sites (auditScanLevels, auditDeriveCustom, auditScanPaths,
auditInspectPath). Despite the name that endpoint is EVM-only — route
/evm/balance/{networkId}/{address} → ETH JSON-RPC. A non-EVM networkId routes
a foreign address into the Ethereum provider, which rejects it; live prod
repro:
GET /evm/balance/ripple:.../rGdmDZ... → 500 "Invalid Ethereum address"
So XRP/Cosmos/THOR — and BTC/DOGE/LTC/BCH custom-path checks — all read 0.
Route every family through the cross-family GetPortfolioBalances path (the
one the dashboard already uses): new parseNativeScanResult(entries, caip) +
an auditNativeBalance(chain, address) helper. Removed the EVM-only
parseNativeBalance. Same degraded→balanceError honesty as the EVM branch.
…CH/…) UTXO altcoins offered only a custom-path search — no "accounts 1/2/3" escalation — because chainSupportsLevelScan excludes UTXO (a single-address check misreads a UTXO account tree) and runBtcScan is Bitcoin-only. So funds on a higher DOGE/LTC account were undiscoverable in the audit. Add an xpub-based per-account scan, reusing the dashboard's exact derivation: for each account N derive m/purpose'/coinType'/N' (all script types — LTC walks 44/49/84), then balance-check the xpub(s) via GetPortfolioBalances so Pioneer gap-scans receive+change server-side. New auditScanUtxoAccounts RPC returns the existing AuditDerivedAddress shape, so the walkthrough renders it with no UI rework: UTXO altcoins now auto-scan accounts 1-3 and offer "Scan more accounts" like BTC/EVM. Extracted utxoAccountScriptPaths (shared with the dashboard's account-0 derivation so they can't drift). Found funds on a non-trackable account route to the existing support handoff (in-app tracking for altcoin accounts is a follow-up). Bitcoin keeps its own deep scan.
The landing-page support link was a raw <a target="_blank">, which the Electrobun WebView cannot navigate — no window opened. Route the click through the existing openUrl RPC (open/xdg-open/start), matching every other external-link call site in the app.
…p progress
- BTC now uses the same xpub-based per-account scan as the other UTXO chains;
drops the lazy path-matrix (auditScanBtc / btcScanState) flow from the
walkthrough UI in favor of accounts 0/1/2 via getPublicKeys.
- Account #0 (the main wallet) is shown as a verified primary with its xpubs
as derivation proof — not folded into the "found money" reveal or the
empty-accounts summary.
- Per-account xpubs (legacy / segwit / native-segwit) rendered as proof rows.
- Stream each path the sweep ("unusual paths") scan checks to the WebView
(sweepScan streamProgress -> audit-sweep-progress) so the panel shows live
progress + a found log instead of a bare counter.
- Dedupe auto-scanned accounts by level; slow eased scroll reveal on first
scan completion; show-empties defaults on (proof we checked them).
… honesty copy
Addresses PR review findings (P1–P3):
- P1 sweep signing guard: the live BTC recovery path (sweepScan/sweepExecute)
signed with no captured-wallet/seed check, bypassing the guard the dead
auditSweep path had. sweepScan now captures {wallet, seedIdentity} (derived
before startScan to avoid racing the serial USB worker) onto the scan record;
sweepExecute revalidates both before btcSignTx (dry-run unaffected) and FAILS
CLOSED when the identity couldn't be captured — never sign what we can't verify.
- P1 EVM token-only funds: the known-paths grid (auditScanPaths) and custom-path
search (auditDeriveCustom) read native-only, so a Ledger/MEW address holding
USDC with 0 ETH showed "empty" and was dropped from the support handoff. New
shared auditBalanceForAddress() routes EVM through GetPortfolioBalances +
parseEvmScanResult (token-aware, degraded→balanceError); all three audit scans
use it and surface tokens. Grid row + support clipboard now show the tokens.
- P2 all-clear honesty: finish headline/label ignored anyShallow, so a wallet
with only-primary-checked chains read "Everything's accounted for / Total
verified". Added the shallow middle state.
- P2 stale audits: direct seed/passphrase resets didn't invalidate an open audit.
seed-changed now emits wallet-data-purged + markAuditsStale; needs_passphrase
marks stale (no purge emit — avoids dashboard churn on standard unlocks).
- P3 inspector: auditInspectPath returned no symbol (rendered "undefined").
Added symbol to AuditInspectResult + the handler return.
markAuditsStale only flips RUNNING reports; a completed report keeps status 'complete' and the dialog stops polling once non-running, so the needs_passphrase handler's markAuditsStale set an internal bit the UI never observed — the user could keep walking the wizard with old coverage while lazy scans derived against the post-passphrase seed. Add a dedicated 'audit-stale' push (only AuditDialog consumes it, so no dashboard cache churn like wallet-data-purged would cause on every standard-wallet unlock). needs_passphrase now emits it after markAuditsStale; the dialog reacts with setStale(true)+stopPoll, which gates the whole wizard behind the re-run banner. seed-changed already covers this via its wallet-data-purged emit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A discoverable "Audit balances" wizard for users who expect a larger portfolio than the Vault shows. It diagnoses then helps recover funds across chains, reusing existing engines rather than rebuilding. (The BTC sweep scanner already existed but was buried behind a broom icon on the BTC→receive sub-tab; EVM index discovery had no UI at all.)
Covers the common "where's my money" causes:
sweep-enginem/44'/60'/0'/0/N) →EvmAddressManager.autoDiscoverEntry points: an "Audit" button next to the portfolio total, and a "Run audit" CTA inside the existing degraded/stale soft-fault banner. Diagnose-first, then inline recovery (sweep to main address, track accounts) confirmed on device.
Why this shape (not a device-lease refactor)
A design-hardening pass wanted a process-wide device lease + wallet-generation token. But the keepkey transport already serializes every device call (
callInProgress.main), so concurrent audit/sync calls interleave safely — the lease is a fairness/UX nicety, not a correctness requirement. This PR ships every correctness safeguard without touching the just-stabilizedgetBalances/autoDiscoverpaths (PR #239/#240). The lease + suppression refactor is documented as a follow-up.Safety invariants (all adversarially reviewed)
deriveSeedIdentity, never the purge-capableensureManagersForSeed.'aborted'(never'complete') on a wallet-handle swap, a sticky stale flag, or N consecutive device failures. End-of-scan andauditSweepre-derive the seed identity, so a same-handle passphrase toggle can neither finalize a clean bill of health nor sign stale-seed UTXOs.unverified(matched by chainId, not symbol, so a degraded Base doesn't flag healthy ETH mainnet); unresolvable faults → residualunverified; single-address chains →checked-shallow. The green all-clear is gated on both, so a fault is never folded into "$0/clean".Layout
audit-engine.ts(orchestrator) +audit-coverage.ts(pure classifier) +auditStart/auditGetStatus/auditSweep/auditDismissRPCs.pioneer-errorgains additive chainId-granular fault fields (banner behaviour unchanged).AuditDialog; two entry points share one state.Test plan
__tests__/audit-coverage.test.ts— 13 unit tests pinning the honesty model (chainId matching, unresolvable-fault residual, all-clear gating, light/deep bounds). Full suite: 187 pass / 0 fail. Typecheck clean.unverified, all-clear suppressed; hidden session → session-scoped framing, no PDF.Follow-ups (not in this PR)