Skip to content

feat(audit): multi-chain "Audit balances" wizard with guided recovery#241

Merged
BitHighlander merged 15 commits into
developfrom
feat/audit-wizard
Jun 13, 2026
Merged

feat(audit): multi-chain "Audit balances" wizard with guided recovery#241
BitHighlander merged 15 commits into
developfrom
feat/audit-wizard

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

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:

  • BTC on non-standard paths / un-added higher accounts → sweep-engine
  • EVM funds on higher address indices (m/44'/60'/0'/0/N) → EvmAddressManager.autoDiscover
  • Chains that silently failed to price/fetch → honest three-state coverage

Entry 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-stabilized getBalances/autoDiscover paths (PR #239/#240). The lease + suppression refactor is documented as a follow-up.

Safety invariants (all adversarially reviewed)

  • Read-only identity — uses deriveSeedIdentity, never the purge-capable ensureManagersForSeed.
  • Gen-guarded BTC scan — a forked derive loop aborts to 'aborted' (never 'complete') on a wallet-handle swap, a sticky stale flag, or N consecutive device failures. End-of-scan and auditSweep re-derive the seed identity, so a same-handle passphrase toggle can neither finalize a clean bill of health nor sign stale-seed UTXOs.
  • Honesty model — degraded/stale → unverified (matched by chainId, not symbol, so a degraded Base doesn't flag healthy ETH mainnet); unresolvable faults → residual unverified; single-address chains → checked-shallow. The green all-clear is gated on both, so a fault is never folded into "$0/clean".
  • Hidden wallet — full diagnosis, recovery framed session-scoped, no PDF action.

Layout

  • Backend: audit-engine.ts (orchestrator) + audit-coverage.ts (pure classifier) + auditStart/auditGetStatus/auditSweep/auditDismiss RPCs. pioneer-error gains additive chainId-granular fault fields (banner behaviour unchanged).
  • Frontend: poll-driven 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.
  • Device smoke (pending, reviewer/maintainer): wallet with a funded higher BTC account + higher EVM index → both surface and recover; force a degraded chain → banner CTA opens wizard, chain shows unverified, all-clear suppressed; hidden session → session-scoped framing, no PDF.

Follow-ups (not in this PR)

  • Device-lease + wallet-generation refactor (fairness + portfolio-suppression during audit).
  • i18n: new strings are English-only (literals, matching SweepDialog); run the translation pass.

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.
@BitHighlander
BitHighlander merged commit 600feb1 into develop Jun 13, 2026
@BitHighlander BitHighlander mentioned this pull request Jun 13, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant