Skip to content

release/1.4.4 → master#243

Merged
BitHighlander merged 47 commits into
masterfrom
release/1.4.4
Jun 13, 2026
Merged

release/1.4.4 → master#243
BitHighlander merged 47 commits into
masterfrom
release/1.4.4

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Release 1.4.4

Promotes release/1.4.4 to master. CI green on the branch tip; version bumped to 1.4.4; submodule pins unchanged.

Highlights

Test plan

  • CI Build KeepKey Vault green on tip
  • Version = 1.4.4
  • Submodule gitlinks unchanged

BitHighlander and others added 30 commits June 7, 2026 18:44
v1.4.3 published as prerelease.
…-derives (#234)

Enabling/disabling BIP-39 passphrase protection from Device Settings left
every wallet address — including 'view on device' — showing the previous
(standard) wallet until a physical reconnect.

Root cause is in firmware: storage_setPassphraseProtected() only flips the
passphrase_protection flag and does NOT invalidate session.seedCached. The
seed derived earlier in the session (empty passphrase) stays cached, so
storage_getRootNode() keeps returning it even after the user enters a new
passphrase. A USB unplug power-cycles the device and clears the cache, which
is why reconnecting 'fixed' it.

Fix without requiring a reconnect: engine.applySettings() now sends
ClearSession after toggling usePassphrase, dropping the cached seed +
passphrase + PIN so the device re-prompts and re-derives from the correct
seed. The device routes back through needs_pin -> needs_passphrase, which
also resets the in-memory account managers and remounts the dashboard, so
stale addresses/balances in the UI clear too. Skipped on the emulator.

DeviceSettingsDrawer: both enable and disable now re-auth, so neither path
calls getFeatures inline (would race promptPin's getPublicKeys); the drawer
re-fetches features when it next opens.
…n toggle (#235)

* fix(passphrase): clear device session on passphrase toggle so seed re-derives

Enabling/disabling BIP-39 passphrase protection from Device Settings left
every wallet address — including 'view on device' — showing the previous
(standard) wallet until a physical reconnect.

Root cause is in firmware: storage_setPassphraseProtected() only flips the
passphrase_protection flag and does NOT invalidate session.seedCached. The
seed derived earlier in the session (empty passphrase) stays cached, so
storage_getRootNode() keeps returning it even after the user enters a new
passphrase. A USB unplug power-cycles the device and clears the cache, which
is why reconnecting 'fixed' it.

Fix without requiring a reconnect: engine.applySettings() now sends
ClearSession after toggling usePassphrase, dropping the cached seed +
passphrase + PIN so the device re-prompts and re-derives from the correct
seed. The device routes back through needs_pin -> needs_passphrase, which
also resets the in-memory account managers and remounts the dashboard, so
stale addresses/balances in the UI clear too. Skipped on the emulator.

DeviceSettingsDrawer: both enable and disable now re-auth, so neither path
calls getFeatures inline (would race promptPin's getPublicKeys); the drawer
re-fetches features when it next opens.

* feat(onboarding): make passphrase tip non-skippable with inline opt-in toggle

The post-setup 'Hidden Wallets' tutorial card is now mandatory and carries the
real passphrase toggle, so every new user makes a deliberate choice instead of
skipping past the explanation.

- TutorialCards: cards can be marked interactive:'passphrase' + nonSkippable.
  The passphrase card renders an on/off toggle and a 'change anytime in
  Settings' hint, and hides its Skip button. Next shows a spinner while the
  choice is applied.
- OobSetupWizard: the toggle choice is held locally (default OFF) and applied
  via applySettings({usePassphrase:true}) only when the user clicks Continue,
  then the wizard advances to 'complete' (the device's PIN/passphrase re-prompt
  overlays follow naturally). Skipping an earlier tip now lands on the
  passphrase card so it cannot be bypassed.
- en/setup.json: toggleLabel + settingsHint strings (defaultValue fallbacks
  keep other locales working until translated).
…#236)

* feat(onboarding): one-time passphrase intro dialog on first dashboard

Every new Vault install now sees a one-time, informational hidden-wallet /
passphrase dialog the first time they reach the dashboard — so users with an
already-initialized device (who never go through the OOB flow) still learn the
feature exists and how to enable it.

- New persisted setting passphrase_intro_shown, exposed via getAppSettings
  (AppSettings.passphraseIntroShown) + a markPassphraseIntroShown RPC.
- PassphraseIntroDialog: info-only modal (reuses the hidden-wallets explainer +
  'change anytime in Settings' hint, 'Got it' to dismiss). z-index below the
  device PIN/passphrase overlays so prompts always win.
- Dashboard shows it once when settings load with passphraseIntroShown=false
  (skipped in watch-only); dismiss persists the flag so it never reappears.
- OOB: completing the passphrase tip card marks the intro shown, so new-device
  users aren't educated twice.

* fix(onboarding): await intro-shown persistence before completing OOB

Awaiting markPassphraseIntroShown before setStep('complete') closes a race
where the dashboard's on-mount getAppSettings read could beat the fire-and-
forget write and re-show the one-time passphrase intro to a user who just saw
it in the OOB flow. Best-effort: on RPC failure we still complete.
…unresolved

The quote is gated by canQuote, but the quote effect only surfaces a reason
for two of its failure modes (destAddressError, toAddressIsXpub). Every other
false condition returns silently, leaving the YOU RECEIVE panel blank — no
quote, no error, no loading state.

The trigger is an asymmetry: fromAddress falls back to the device-derived
evmAddresses (independent of the balance cache), but the destination address
read only the balance cache and the address resolver runs for UTXO chains
only. When the balance cache is empty (e.g. balance server unavailable), an
EVM destination's toAddress is '' with no error, so canQuote is false and the
quote is never attempted.

- cachedToAddress falls back to the EVM device address for EVM destinations,
  mirroring fromAddress — fixes the blank panel at the source.
- Add a fallback "Get Quote" button + reason text in the empty receive slot,
  shown only in the address dead-end; it derives a missing non-EVM destination
  address on demand, refreshes balances, and re-fires the quote.
- Show the enter-amount hint for 0/invalid amounts instead of a blank panel.
- New i18n keys: resolvingSendAddress, resolvingReceiveAddress.
… button to dest

Addresses two review findings on the manual Get Quote fallback:

- Stale-address race: handleManualQuote could write a destination address for
  the wrong chain if the user switched the output asset while the (up to 60s)
  address-derivation RPC was still pending. Capture the requested chainId and
  discard a late result (and a late error) if the output asset changed — the
  auto-resolver useEffect already had this guard via a cancel flag.

- Button scope: the fallback button now renders only when a DESTINATION address
  is missing (the case handleManualQuote actually derives). A missing SOURCE
  address is a transient the quote effect re-runs automatically once fromAddress
  populates, so it shows the reason text without an inert button.
The destination-address resolver ran for UTXO chains only, so for non-EVM
non-UTXO destinations (Solana, Cosmos, Tron, XRP, ...) the receive address
stayed '' whenever the balance cache was empty — canQuote never went true and
the quote was never attempted automatically. Only the manual "Get Quote"
button (which derives on demand) produced a quote.

Resolve from the device for any non-EVM destination (EVM still uses the
cache-free evmAddresses fallback in cachedToAddress), so auto-quoting works
without the button. The existing cancel flag already discards a stale result
if the output asset changes mid-derivation.
…-memory swap/activity for hidden wallets

**Root cause:** isPassphraseWallet gates block LIVE/DISPLAY state, not just
disk persistence. The privacy invariant is 'never write hidden-wallet data to
disk, but operate live from the device.' Three consumers read only the
persistence-coupled layer (intentionally empty for hidden wallets) instead of
falling through to live device/server paths.

**Symptom 1 — 'Your KeepKey is empty' swap picker:** SwapDialog seeds balances
only from getCachedBalances (null for hidden wallets). Added live getBalances
fallback on open, mirroring the Dashboard pattern (Dashboard.tsx:903-921).
getBalances derives addresses live from device and persists nothing for hidden
wallets. Also fixes non-EVM FROM address dead-end (fromAddress reads
balances.find()?.address which is empty for hidden wallets).

**Symptom 2 — 'No supported routes — only NEAR Intents':** parseQuoteResponse
picked quotes[0] unconditionally, so a single unbuildable head quote killed
the pair even with a buildable route behind it. Now scans for the first
buildable quote (using the exact throw predicate), logs loudly when skipping
higher-ranked unbuildable quotes (no silent rate downgrade). Re-throws the
head-quote error when none are buildable (preserves the existing user-facing
message).

**Symptom 3 — In-session hidden-wallet swaps invisible in Activity:**
getPendingSwaps short-circuited to [] before reading the in-memory map.
ActivityTracker skipped fetchSwaps() for hidden wallets. Relaxed both to read
the RAM-only pending swaps (scoped to walletId, never leaks to DB).

**Symptom 4 — Fresh passphrase entry leaves getWalletDbScope() null:**
sendPassphrase() nulls seedEthAddress and never re-derives it; reconnect probe
sets it but is gated on !passphraseSetThisSession. Added in-memory ETH address
derivation in sendPassphrase for hidden wallets (RAM only, NO setSetting disk
write — preserves plausible deniability).

**Symptom 5 — Activity panel empty / rescan no-ops for hidden wallets:**
Added write-free getLiveActivity path: scanChainHistory with dryRun+collectRows
fetches live from Pioneer, returns rows via in-memory session store (never
written to disk). getRecentActivity serves session store for hidden wallets.
Session store cleared on needs_passphrase/disconnect/seed-changed.

Privacy invariant preserved: every DB-write gate stays. Only display/live-lookup
gates are relaxed. All write consumers of getWalletDbScope() are correctly
double-gated on !isPassphraseWallet.

Tests: 3 new B1 tests pinning buildable-quote selection behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pioneer's /swap/available-assets carries no SPL tokens, so the backend
decimals lookup misses for e.g. USDT-on-Solana and buildTx throws
"tokenDecimals required for SPL token transfers" — the preview/build
dead-ends. Forward the picker asset's decimals through ExecuteSwapParams
(Pioneer's canonical value still wins when present); the SPL builder
hard-throws when both are missing rather than guessing scale.

Adds solana-spl-decimals.test.ts pinning the build contract and wires it
into the test-unit suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getSwapByTxid and refreshSwap had a blanket isPassphraseWallet block, but
hidden-wallet swaps are in-memory only (skipPersist — no DB row), so the
block left a hidden session unable to read its OWN swap (detail view stuck
"pending") or advance it (the live poll is the only thing that can move a
skipPersist swap to completed, e.g. a NEAR Intents Solana deposit on
1Click). Replace the blanket block with walletId-scoping plus a live
tracker fallback: a hidden walletId (deviceId:hiddenSeedEthAddr) never
matches a standard swap's walletId, so standard-wallet swaps stay invisible
from a hidden session — same posture as getPendingSwaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Show a spinner in the FROM picker and asset list while balances/assets
  load, instead of a bare empty state.
- Surface a "Get Quote" button in the input view when a quote can fire but
  none is present (e.g. after returning from the confirm step), so the
  receive panel is never a silent blank.
- Forward SPL picker decimals from the dialog's preview and execute calls
  (pairs with the backend tokenDecimals fix).
- Harden canQuote (explicit boolean) and key the quote effect on asset CAIP
  rather than asset id so a switch between same-id assets requotes.
- Back button resets quote state and re-fires the quote via requoteTick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(swap): resolve silent quote dead-end when destination address is unresolved
…er.iss comment (#233)

The per-user-install warning comment in InitializeSetup wrapped onto a line
beginning (after indentation) with "[Code]". Inno Setup splits sections by
scanning for lines that start with "[" before it parses Pascal { } comments,
so it read "[Code] MsgBox, ..." as a malformed section header and aborted with
"Error on line 96 ... Invalid section tag", breaking the Windows installer
build (ISCC exit 2).

Reword the comment so no line starts with "[". Comment-only change; no
installer behavior change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetPendingSwap on a freshly-created swap reliably 404s ("Pending swap not
found") because Pioneer registers the row asynchronously — noisy in logs
with nothing to gain, since the swap already renders 'pending' from the
in-memory record. Skip the Pioneer poll for a 30s grace window after the
swap's createdAt; a manual rescan bypasses it. Hydrated/old swaps fall
through normally (createdAt is well past the window).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sending to an address not yet in the Address Book now pops a dialog after
broadcast: warns that funds are leaving the wallet, shows the external
address, and offers to save + label it. Saving is explicit (R4 opt-in) —
"Not now" leaves the address out of the book.

- recordOutbound now leaves auto-recorded external rows with saved_at=null
  (history-only) and reports `unsaved`; the book + send picker only show
  explicitly-saved contacts (listAddressBook savedOnly).
- addExternalEntry stamps saved_at on explicit save; migration backfills
  existing labeled / manually-added rows so they stay visible.
- Address Book rows display the date a contact was added.
- Replaces the prior subtle inline label prompt with the SaveRecipientDialog.
…en wallets)

The Address Book is public, non-secret data — your watch-only wallet
addresses + saved contacts — so it should not be tied to which wallet is
active. Previously every address-book RPC blanket-returned empty in a
passphrase/hidden session, so the book showed nothing (no device wallets,
no contacts) and sends couldn't be saved.

- listAddressBook no longer gates on passphrase; own (cross-device) and
  external (cross-wallet) entries load in any session. Only seedOwnFromCache
  (a write) stays standard-only.
- add/update/delete/history RPCs de-gated; mutations act globally by id
  (wallet_id is retained as provenance/dedup, no longer a read/edit scope).
- broadcastTx: split api_log (kept standard-only for tx deniability) from the
  address-book capture, so recipients can be saved from hidden-wallet sends.

Note: a contact saved while in a hidden wallet is therefore visible from the
standard wallet — the address book is intentionally outside hidden-wallet
deniability (api_log/balances writes remain gated).
Move address-book detection to where it's useful — while entering the
recipient — instead of after the funds have already left.

- New matchAddress RPC + matchAddressBook() (exact networkId equality, R5):
  returns the matching own wallet / saved contact, or null for a new address.
- SendForm runs a debounced matchAddress as the recipient is entered:
  - known → show the contact's identicon + label (gold chip)
  - new valid address → rose "New address" chip + auto-open the save dialog
    (once per address; also reachable via the chip's Save link)
- Saving now happens at form-fill (before sending); the post-broadcast save
  dialog + its confirmation are removed. recordOutbound still records
  outbound history on broadcast.

Dialog warning reworded for the pre-send context (verify the address).
…dialog

feat(addressbook): opt-in save dialog for new external recipients
…s underneath them

Recurring customer bug: the UI showed one wallet's addresses/balances while
the device OLED showed another's (field report: UI 0x4C1922..., device
0x27de622c...). The in-memory managers (btcAccounts/evmAddresses) are kept
across disconnect for watch-only and only re-derive when empty; the resets
that should empty them are event-INFERRED and every trigger has a blind spot:

- needs_passphrase reset: skipped on reconnect with a pre-cached passphrase
  (device goes straight to ready)
- device-switch reset: skipped when deviceId is unchanged
- seed-changed: skipped on hidden->standard transitions — hidden wallets never
  persist seed_eth_<id> (privacy), so the stored identity is the standard
  wallet's and MATCHES once the device returns to it; no event fires
- passphrase toggle: applySettings+clearSession resets the engine's
  fingerprint/identity but never the managers

Fix checks the RESULT instead of the cause. The seed-identity address (ETH
m/44'/60'/0'/0/0) is bit-for-bit evmAddressPath(0), so the EVM manager's
index-0 address must equal the device-derived identity. One invariant covers
every cause, including future ones. Two legs:

- getBalances: derive the identity FRESH from the device (new RAM-only
  engine.deriveSeedIdentity) on every fetch and reconcile before the init
  guards — every balance fetch is now self-verifying against the device
- state-change(ready): reconcile against engine.currentSeedEthAddress once
  checkSeedIdentity / hidden-scope derive / reconnect probe classify the
  session (probe's confirmed-hidden path now re-emits state-change so the
  guard actually runs there)

On purge: reset both managers (init guards re-derive), push empty account
sets, wipe the deviceId-scoped DB cache (standard sessions only — a stale
fetch classified as standard may have already persisted the WRONG wallet's
rows; hidden sessions never persist and their rows must not be touched), and
notify the frontend via new 'wallet-data-purged' message. Dashboard clears
the displayed balances immediately and force-refreshes (in-flight fetches
self-heal — the purge runs at their start).

Never purges on uncertainty: unknown identity or uninitialized managers no-op,
so cold start and watch-only keep their data.

evmAddressPath moved to shared/chains.ts (pure, test-importable — src/bun
pulls ./db -> electrobun import side effects that break bun test) and
re-exported from evm-addresses for existing importers.

Tests: __tests__/seed-reconcile.test.ts pins the path equivalence the
invariant rests on, the never-purge-on-uncertainty rule, both transition
directions, and the exact field-report scenario. Full suite 365 pass /
2 pre-existing environmental failures (live :1646 vault, live Solana RPC) —
identical on baseline.
…sumers

Review follow-up to the stale-wallet purge. Three gaps where stale account
managers could still be read before a full getBalances ran:

1. getBalance (single-chain) and buildTx read btcAccounts/evmAddresses with no
   reconciliation. getBalance backs AssetPage / tx-push / post-send refreshes;
   buildTx is the SIGNING path — a stale xpub/evmAddressIndex would build a tx
   against the wrong wallet's context. Added a shared ensureManagersForSeed()
   boundary (derive seed identity fresh from device → reconcile → purge if
   stale) and call it at every consumer: getBalances, getBalance, getBtcAccounts,
   getEvmAddresses, and buildTx. buildTx THROWS on purge so the half-prepared
   send is abandoned and the user re-initiates against refreshed balances.

2. The DB-cleanup on purge was gated on !isPassphraseWallet, so during the
   conservative cached-passphrase reconnect window it was skipped — and when the
   probe later reclassified to standard the managers already matched, so no
   second purge ever cleared the stale rows. Clearing only REMOVES rows (never
   writes hidden data), so it's privacy-safe: run it unconditionally.

3. reconcileSeedManagers only compared the EVM index-0 address, so a BTC-only
   initialized stale state (getBtcAccounts initializes BTC independently) had no
   proof to compare and survived. Added a seed-owner STAMP (managersSeedOwner):
   the seed the managers were derived under, set after every init and checked
   alongside the EVM index-0 proof. Either mismatch purges. All reset paths now
   go through resetSeedManagers() so the stamp stays in lock-step.

The engine reconnect probe's confirmed-hidden branch already re-emits
state-change (prior commit) so the ready-handler leg re-runs there too.

Tests: seed-reconcile.test.ts adds the seed-owner-stamp (BTC-only) cases.
Project suite 344 pass / 2 pre-existing env failures (live :1646, live Solana
RPC). Differential tsc clean; bun + vite build clean.
fix(wallet): purge stale account managers when the device seed changes underneath them
The vault discarded Pioneer's portfolio `meta`, so a chain that failed its
fresh fetch (node down/timeout) or served cache >5min old showed $0 / a stale
value with no way to tell "unavailable" from "genuinely empty."

- bun: preserve `meta` per chunk (unwrapPortfolioResponse) and merge across
  chunks (mergeMetas); after each fetch, push degraded + stale chain names to
  the webview through the existing pioneer-error channel (severity warning/none)
- schema: extend pioneer-error with optional severity + degraded/stale fields
  (missing severity stays a hard error — back-compatible)
- Dashboard: amber soft-fault banner (separate state from the hard-error
  banner, which supersedes it); window-focus refetch when idle >5min;
  degraded-chain background retry with 30/60/120/300s backoff
- i18n: English strings only; other languages fall back via fallbackLng

Server meta is live on api.keepkey.info (pioneer release/v1.3.115).
…ften degraded copy

- Banner rendered only the degraded branch; a response with both degraded
  and stale chains silently dropped the stale warning. Render both lines.
- lastFetchAttemptRef seeded at 0 made the first focus event read as
  'idle > 5 min' and force-refresh on startup. Seed with mount time.
- degradedDesc promised 'last known values', but vault-side chunk
  failures can render as zeroes. Soften the copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(portfolio): surface degraded/stale chains instead of silent $0
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.
BitHighlander and others added 17 commits June 12, 2026 01:38
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.
…s can launch

KeepKeyVault.exe is our own wrapper-launcher.zig. The Windows build compiled
it with no -mcpu, so Zig targeted the build box native AVX2 CPU and baked a
VEX vmovdqa into the main() prologue. On no-AVX CPUs (e.g. Intel Pentium
Silver N5030 Gemini Lake, SSE4.2 only) the app died instantly at launch with
0xC000001D STATUS_ILLEGAL_INSTRUCTION at +0x1b96, before bun.exe ever ran.

Pin -target x86_64-windows -mcpu=baseline (x86-64-v1, runs everywhere). This
is not an Electrobun/Bun bug; both ship baseline-clean binaries and the
wrapper faults first. UNVERIFIED on the build box -- see
docs/handoff-windows-non-avx-launcher-crash.md for build + verify steps.
…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.
feat(audit): multi-chain "Audit balances" wizard with guided recovery
fix(windows): build splash wrapper baseline so non-AVX CPUs can launch
Second half of the non-AVX launch fix (the -mcpu=baseline wrapper from
PR #242 is already in 1.4.4). Electrobun 1.13.1 defaults to Bun 1.3.9,
which sits in an upstream non-AVX regression window. Pin build.bunVersion
to 1.3.14 (first release combining baseline WebKit + JSC AVX-gating fix);
on Windows the override always pulls bun-windows-x64-baseline.zip.

Also update WINDOWS-BUILD-AND-SIGN.md so the SOP matches reality:
- step 9 now documents the load-bearing -mcpu=baseline flag + the
  "0 AVX instructions / .text VSize 0x5A46" post-build verification
- step 6 documents the bunVersion baseline pin + bun.exe banner check
- release checklist gains the two static non-AVX gates and the optional
  no-AVX hardware smoke test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-shot elevated provisioning of the Windows build toolchain (Node LTS,
Yarn, Bun, Rustup, Inno Setup, VS 2022 C++ BuildTools) via winget.
Deliberately does NOT install Zig via winget -- the build needs Zig 0.15.x
and winget ships 0.16 (breaks wrapper-launcher.zig, see WINDOWS-BUILD-QUIRKS).

Log path is $PSScriptRoot-relative; _provision.log is already gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BitHighlander
BitHighlander merged commit 28e6833 into master Jun 13, 2026
6 checks passed
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