Skip to content

Bitcoin-only firmware support (detection, onboarding, UI scoping)#353

Merged
BitHighlander merged 35 commits into
developfrom
btc-only
Jul 13, 2026
Merged

Bitcoin-only firmware support (detection, onboarding, UI scoping)#353
BitHighlander merged 35 commits into
developfrom
btc-only

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Consolidated bitcoin-only work — replaces the individually-merged #347#352, which were backed out of develop and re-collected here for a single review. Draft until on-device testing is signed off.

Commits

Flag state

BITCOIN_ONLY_ONBOARDING = false — onboarding picker stays dark until a signed btc-only asset ships. Detection / UI-scoping / balance-purge / log-fix are live regardless of the flag (they trigger off the connected device's variant).

Test

  • flags.test.ts 7/7; encoder self-test; balance-purge dry-run (16 phantom rows → 0)
  • On-device: flash btc-only → splash, dashboard BTC-only, no EVM affordance, clean logs

BitHighlander and others added 7 commits July 11, 2026 23:41
…flag OFF) (#347)

* docs: bitcoin-only onboarding spike handoff (design, blockers, risks)

* feat(btc-only): bitcoin-only firmware detection + onboarding picker (flag OFF)

Vault-side plumbing for the bitcoin-only firmware variant. Default OFF and
byte-identical to today for multi-chain devices.

- flags.ts: BITCOIN_ONLY_ONBOARDING (gates OOB picker) + isBitcoinOnlyVariant()
  (detects KeepKeyBTC/EmulatorBTC from device Features firmware_variant).
- Detection is NOT flag-gated: any device reporting a btc-only variant gets the
  Dashboard restricted to Bitcoin. Verified end-to-end fw fsm_msg_common.h ->
  proto field 22 -> hdwallet Features.AsObject.firmwareVariant -> engine ->
  Dashboard.visibleChains. Unit test flags.test.ts (7/7).
- OobSetupWizard: inline Multi-coin / Bitcoin-only selector (fresh-device only)
  with one-way seed-lock warning, behind BITCOIN_ONLY_ONBOARDING.
- engine-controller.startFirmwareUpdate(bitcoinOnly): installs the
  channel.firmwareBitcoinOnly manifest entry; throws cleanly if absent.
- RPC + useFirmwareUpdate thread bitcoinOnly through.

Known gaps (do not block flag-off): manifest firmwareBitcoinOnly is not yet
populated (no published signed asset), so in-app btc-only install is inert
until one ships; ONDEVICE_FIRMWARE_HASHES has no btc-only entries yet.
The flash-preview screen read a bitcoin-only .bin as full multi-chain — it
showed the generic v7.15.0 'What's New' (Zcash/Solana/TON/etc., none of which
a btc-only device can touch) and no variant indicator. The device can't report
firmware_variant until it BOOTS the new firmware, so flash-time detection must
come from the binary itself.

- analyzeFirmware scans the payload for the embedded 'KeepKeyBTC'/'EmulatorBTC'
  literal (compiled in only under #if BITCOIN_ONLY, fsm_msg_common.h); returns
  isBitcoinOnly. Verified true on the rc7 btc-only bin, false on a full build.
- FirmwareDropZone shows a BITCOIN-ONLY badge and threads the flag to the preview.
- FirmwareUpgradePreview renders a single honest Bitcoin-only card + headline
  instead of the multi-chain release notes when isBitcoinOnly.
The dashboard chain list already restricts to Bitcoin on btc-only firmware
(#347), but the '+ Add EVM chain' button + AddChainDialog were only gated by
!watchOnly — so a BTConly device could still open the 177-chain EVM picker and
add a network it physically can't touch. Gate the button with !btcOnly (its
sole setShowAddChain trigger), so the dialog can't open.
Flashing unsigned firmware, the bootloader shows an 'unrecognized firmware,
install anyway?' confirm at the UPLOAD stage — but the progress message just
said 'Uploading firmware...', so the flash looked stuck with no hint to look
at the device (real report: user didn't press confirm, wizard stranded).

node-hid readSync blocks the event loop during the button wait (see flash
notes), so a button-request event can't reliably fire mid-upload — the message
emitted *before* the blocking firmwareUpload is the reliable signal. Reword the
upload-stage messages (firmware update, custom flash, bootloader) to tell the
user to look at the device and confirm if prompted.
Diagnosis: the balance cache is keyed by hardware deviceId alone
(getCachedBalances(deviceId) → all chain rows), and deviceId is stable across
firmware variants. A device reflashed from multi-chain to bitcoin-only keeps
its old multi-chain balance rows, so the dashboard sums phantom balances — incl.
an ETH balance at an address the btc-only firmware physically cannot derive —
into 'All Chains'. (Reproduced: device 343737340F...3B00, ETH $91.36 at
0x9f5f2e60…, Bitcoin $0.00, total $101.17; rows dated 4 days pre-flash.)

The user's assumption that the cache is keyed by an ETH address is inverted —
it's keyed by deviceId, which has no address in it, so nothing stops the stale
multi-chain data from loading.

Fix: a btc-only seed is locked to BTC (storage band 10017) and can never hold
another chain, so on connect purge every non-'bitcoin' cached balance for the
device (clearNonBitcoinBalances). Removes the phantom at the source, so every
read path (dashboard, audit, ledger, swap) is correct.
When a bitcoin-only device is connected (firmwareVariant KeepKeyBTC/EmulatorBTC),
the connect/loading splash brands itself bitcoin-only: orange logo glow, a ₿
mark + 'Bitcoin-Only KeepKey' label, and an orange status dot. Falls back to the
normal KeepKey splash for multi-chain / unknown devices (isBitcoinOnly defaults
false). Wired at the claimed / searching / loading-portfolio splash sites in App.
A btc-only device can't derive any non-Bitcoin chain, but Pioneer still polls
the multi-chain address endpoints during portfolio sync — each hit the device
with an unsupported message type, which returned 'Unknown message (code 1)' and
flooded the log ([REST] batch address failed…, [REST] Error on POST
/addresses/solana…).

Detect btc-only firmware (isBitcoinOnlyVariant on the device Features variant)
and short-circuit before touching the device:
- early guard: POST to a non-BTC /addresses/* endpoint → clean 501, no device
  call, no log line (NON_BTC_ADDRESS_PATHS set).
- /api/pubkeys/batch: skip the address-type (non-UTXO) branch entirely.

Multi-chain devices are unaffected (guard is gated on the btc-only variant).
The btc-only branded splash rendered only while the portfolio loaded, but a
purged btc-only device loads its BTC-only portfolio near-instantly, so the
splash flashed by unseen. Hold it ~2.2s once a btc-only device is detected
(state settled + firmwareVariant present), gating both the loadingPortfolio
splash and the dashboard-hide on (!portfolioLoaded || btcSplashHold). Non-btc
and disconnected devices clear the hold immediately — no behaviour change.
…self-host node

Scopes four btc-only revisions on this branch: a dedicated bitcoin-only dashboard
(no donut/chain-breakdown; per-account instead), hiding WalletConnect + ShapeShift
in the header, address-book btc-only scoping, and a new 'Self Host' node feature.
Compares node backends (blockbook / electrum / bitcoin-core / esplora) with an
integration-cost table, the proxied-vs-direct architecture decision, and a phased
plan. Everything gates on the live device variant, not the onboarding flag.
Rev 2: TopNav hides the ShapeShift tab and WalletConnect button when the
live device is bitcoin-only (isBitcoinOnly prop threaded from App).

Rev 1: the btc-only dashboard IS the Bitcoin asset page — no donut, no
chain list. Reuses AssetPage (big balance, per-account breakdown,
receive/send/swap) via a new hideBack prop. Audit-balances entry point
is dropped on btc-only (only lived on the multi-chain view).

Rev 3: address book scopes entries to chainId=='bitcoin' at the source,
which also auto-collapses the network-filter row.

All gated on isBitcoinOnlyVariant(firmwareVariant); multi-chain devices
byte-identical.
… refused

deriveSeedIdentity/checkSeedIdentity/probeReconnect/hidden-wallet-scope
all fingerprinted the seed via an ETH address (m/44'/60'/0'/0/0).
btc-only firmware refuses ETH derivation, so the fingerprint came back
null on every btc-only device — which fail-closed the BTC Sweep Scanner
('Could not verify the wallet for this scan') and left btc-only devices
with no seed-change detection or wallet DB scoping.

Consolidate the four ETH-only sites into one deriveSeedFingerprint()
helper that falls back to a fixed BTC address (m/84'/0'/0'/0/0, 'btc:'
prefixed so it can't collide with an eth address). Deterministic per
seed, which is all the guards need. Hidden-wallet write-gate/routing
control flow is unchanged — only the derivation source moves.

Requires on-device verification on btc-only: sweep execute, seed-change
detection, and passphrase reconnect classification.
BtcBackend seam unifies self-host and offline (Pioneer BTC surface is
just listUnspent/feeRate/broadcast). Locked decisions: no auto-fallback
from a failed self-host node, offline=airplane-mode zero-outbound, BC-UR
for BTC QR, PSBT+file+BC-UR for full ecosystem compat. Phased plan +
data-source choice in btc-only onboarding.
Introduces src/bun/btc-backend/: one BtcBackend interface capturing
Pioneer's entire BTC surface (listUnspent / feeRate / broadcast /
rawTxHex), with PioneerBackend as the only implementation and
getBtcBackend() as the selection point. Pure response normalizers split
into normalize.ts (import-free, unit-tested — 16 assertions).

Isolation rule: only btc-backend/pioneer.ts may import ../pioneer;
self-host (core/electrum/blockbook) and offline (device-only) backends
slot into getBtcBackend() later without touching consumers. No
auto-fallback on failure — sovereignty stance.

Proves the seam by routing the bitcoin-only sweep path (sweep-engine
UTXO/fee/rawtx + sweepExecute broadcast) through it. Behaviour-identical:
same Pioneer calls, same normalization. Shared multi-chain txbuilder/
utxo.ts migration is the next step.

Design: docs/design-offline-first-and-node-isolation.md
…iceOnlyBackend

Airplane mode for security-minded / air-gapped users. offlineMode
setting (AppSettings + db + setOfflineMode RPC) is a hard switch: when
on, getBtcBackend() returns DeviceOnlyBackend, whose four network ops
throw OFFLINE. Device, xpub read, address derive, and signing are
untouched (they don't go through BtcBackend).

- src/bun/btc-backend/device-only.ts — offline backend (+ 5-assert test)
- getBtcBackend() gates on setBtcBackendOffline(), pushed from index.ts
  so the module stays db/pioneer-free
- useOnline() hook (navigator.onLine + events); App computes
  offline = offlineMode || !online
- OFFLINE badge in TopNav; toggle in DeviceSettingsDrawer

Slice 1: state + backend gate + badge + toggle. Comprehensive network
suppression (skip portfolio/socket/market-data fetches when offline) and
the wizard data-source step are the next slice. Verified: bun tests
green (device-only 5, normalize 16); typecheck adds no new errors.
getAssetIcon fell through to the remote CDN (api.keepkey.info/coins) for
Bitcoin, so the logo broke with no network. Add a LOCAL_ICONS override
returning an inline SVG data URI for the Bitcoin caip — no asset file, no
bundler coupling, works offline and in the shared module. Primary assets
should never depend on the network for their logo.
The OFFLINE badge never showed after dropping wifi because navigator.onLine
is unreliable in the WebView — it stays true. Replace it with a real
probe: Bun pingPioneer HEADs the Pioneer base (4s timeout); any response =
online, a thrown fetch = offline. useOnline polls it every 15s + on
focus/online/offline events. Gated by `active` so it makes ZERO network
calls when airplane mode is on. App: online = useOnline(!offlineMode);
offline = offlineMode || !online → badge now reflects real connectivity.
The header OFFLINE badge alone was easy to miss. Add a persistent bottom
strip whenever offline (airplane setting OR no network) that plainly
states what still works (device, addresses, receiving) and what's paused
(balances, history, sending), with airplane vs no-network wording. Bottom
placement avoids fighting the top nav's content-offset logic; content gets
matching bottom padding so nothing hides behind it.
CoreBackend (src/bun/btc-backend/core.ts) talks JSON-RPC to a user's own
Bitcoin Core node — scantxoutset for balance+UTXOs (pruned-OK, no
history), estimatesmartfee, sendrawtransaction, getrawtransaction. xpub
paths convert SLIP-132 (zpub/ypub) to standard xpubs Core can parse and
build wpkh/sh(wpkh)/pkh descriptors; addresses use addr(). Verbose,
actionable errors (unreachable / 401 / RPC error) — NO auto-fallback to
Pioneer when a node is enabled (sovereignty stance).

getBtcBackend() gate: offline > self-host node > Pioneer. Config persists
in the DB (btc_node_enabled/url/auth — never .env, auth never returned to
the UI) and is pushed in via setBtcNodeConfig, keeping btc-backend
pioneer/db-free. RPCs: setBtcNode, testBtcNode (capability probe reporting
chain/height/pruned/txindex). AppSettings += btcNodeEnabled/btcNodeUrl.

Pure helpers unit-tested (core.test.ts, 12 assertions incl. real zpub→xpub).
Settings UI panel is the next commit.
…ction

btc-only-gated panel in DeviceSettingsDrawer: node RPC URL + auth inputs,
Test Connection (reports chain/height + warns on pruned = no history and
txindex off = can't spend legacy inputs), and Use-my-node / Disable that
persists via setBtcNode and re-points the BtcBackend. Auth is write-only
(never returned to the UI). Verbose node errors shown inline per the
no-silent-fallback stance.
…s section

The panel was buried inside the collapsed 'Feature Flags' section, so it
was effectively invisible. Move it to a dedicated 'Bitcoin node' section
(defaultOpen) shown only on btc-only devices.
… / offline)

One-time dialog when a bitcoin-only device first reaches the dashboard,
introducing how Vault gets Bitcoin data: Pioneer (default), self-host
your own node, or fully offline (airplane). Self-host opens the node
config inline (reuses SelfHostNodePanel); offline flips airplane mode.
Every choice is changeable later in Settings → Bitcoin node.

Rendered from App.tsx (not Dashboard — btc-only early-returns to the
Bitcoin AssetPage, so the Dashboard dialog slot never renders). Gated by
a persisted btcOnboardingShown flag via markBtcOnboardingShown, mirroring
the passphrase intro. AppSettings += btcOnboardingShown.
BlockbookBackend (src/bun/btc-backend/blockbook.ts) — thin /api/v2 REST
client, what Pioneer itself speaks. xpub-NATIVE (blockbook does the gap
scan + understands zpub/ypub, no descriptor/bs58check dance), archival =
full history. listUnspent/feeRate/broadcast/rawTxHex/tipHeight; verbose
errors incl. Cloudflare-Access 401/403.

Config gains a node type (blockbook | core): setBtcNodeConfig dispatches,
loadBtcNodeConfig/setBtcNode/testBtcNode thread it, AppSettings +=
btcNodeType (default blockbook). SelfHostNodePanel gets a type toggle —
auth field shown only for Core; Blockbook placeholder points at the
tailnet node.

Verified LIVE against the real node (100.117.181.111:9130 'beast'):
test ok chain=main height=957767 inSync, fees {1,1,2} sat/vB, zpub
listUnspent parses natively, invalid-address 400 propagates verbosely.
Unit tests green (normalize 16 / device-only 5 / core 12).
Panel description was hardcoded 'Bitcoin Core node' even with Blockbook
selected — now type-aware. CoreBackend's 'non-JSON response' error now
detects an HTML page and tells the user they've likely pointed the
Bitcoin Core type at a Blockbook/web URL (:9130) instead of a Core RPC
URL (:8332), and to switch node type to Blockbook.
… fields

A single user:pass field mangled bitcoin.conf credentials — a 44-char
special-char rpcpassword easily loses a char, picks up a trailing
newline, or gets colon-confused, yielding a 401 even when the node's
creds are fine. Now separate RPC username + password inputs; the Bun side
stores them apart (btc_node_rpc_user / _pass) and joins to Basic auth
internally, so nothing is hand-joined. Blank fields keep the saved value.
When a self-host node is active, a bottom strip confirms it's connected
and reports sync state: green 'connected · height N · synced', gold
pulsing 'syncing · N%' (Core verificationprogress; Blockbook inSync), or
rose with the verbose error if it's failing (no Pioneer fallback).
getBtcNodeStatus RPC probes the active backend; useBtcNodeStatus polls
every 12s (skipped while offline). Mutually exclusive with the offline
banner; content padding accounts for whichever shows.
A 404 on /api/ almost always means the URL is a Bitcoin Core RPC port
(:8332, no /api/) while the type is Blockbook. The error now says so and
points to the :9130 Blockbook port / switching node type.
The panel only offered 'Disable' once a node was enabled, so editing the
URL/type/credentials never persisted — the node kept probing the stale
config (e.g. a leftover :8332 under the Blockbook type → endless 404).
Save is now always available ('Save changes' when enabled), with Disable
as a secondary action.
When a self-host node is active (getBtcBackend().kind !== 'pioneer'),
getBalances now derives BTC balances from the node instead of Pioneer:
BTC xpubs are excluded from the Pioneer GetPortfolioBalances chunk, and
per-xpub balances come from getBtcBackend().listUnspent(xpub) summed to
sats. Entries are produced in Pioneer's exact shape so the existing BTC
loop, per-xpub cache, and ChainBalance assembly process them unchanged.

Price still via Pioneer GetMarketInfo (price isn't node data). A node
failure for an xpub is marked in failedPubkeySetForDb so its stale cached
balance isn't overwritten with 0. Default (no node) and all non-BTC
chains stay byte-identical. Empty-chunk guard added for btc-only devices
whose only pubkeys are BTC (Pioneer call skipped entirely).

This is the change that makes 'Use my node' actually serve balances.
The recurring 404 was a type/port mismatch — Blockbook selected on the
Core :8332 port (or vice versa) got saved and stuck. detectBtcNode now
probes the preferred type, then the other, and both Test and Save use it:
Test switches the selector + shows 'Connected as <type>'; Save persists
whichever actually answered. So a wrong pick self-corrects and can't get
saved. Verified live: Core-type + :9130 URL → auto-detected as Blockbook;
:8332 without creds still surfaces the actionable error.
…ress

Save changes now tests first — never persists a node that doesn't answer;
on success it closes Settings and opens NodeConnectedDialog. The dialog
confirms the node type, lists the limitations (Core: no history without
txindex+archival, legacy spends need txindex, price via KeepKey;
Blockbook: full), and — if the node is still syncing — shows live
progress %, blocks-behind, and a rough ETA (from two status samples).
Connecting also fires keepkey-refresh-balances so the dashboard re-fetches
from the node immediately. getBtcNodeStatus/testCoreNode gained headers
(for blocks-behind).
…lls)

Scopes the remaining work to make self-host truly sovereign: the send
path migration (txbuilder UTXO/fee/broadcast onto the seam, with the
per-UTXO derivation-path signing gotcha + Core descriptor parsing),
price independence (sats-only vs alternate source), history, btc-only
log-noise cleanup, a zero-Pioneer verification checklist, and the
multi-chain regression merge gate.
…money path)

When a self-host node is enabled, Bitcoin UTXOs, fees, and broadcast now go
through the node (BtcBackend seam), never Pioneer. Verified end-to-end on-device
against Bitcoin Core (broadcast ee9a4e00…) — the send path no longer touches
Pioneer for BTC.

Send path (txbuilder):
- fetchUtxosForXpub / resolveFeeRates / broadcastTx route BTC to the node when
  getBtcBackend().kind !== 'pioneer'; other UTXO coins + Pioneer mode unchanged.
- Change index derived from unspent change UTXOs instead of GetPubkeyInfo.

Backends:
- Blockbook: preserve per-UTXO path/address, derive scriptType, fetch prev-tx hex
  only for legacy p2pkh inputs.
- Core: parse the signing path out of the scantxoutset descriptor and rebuild the
  full account-0 BIP path (coreUtxoPath) — signing the raw relative path derived
  the wrong key (OP_EQUALVERIFY at broadcast). Serialize scans (Core allows one
  scantxoutset at a time) with abort-retry, add a 2-min UTXO cache cleared on
  broadcast, and a live scantxoutset progress signal.

Honesty guard (pioneer-guard):
- When a node is active, BTC ListUnspent/GetFeeRate/Broadcast on the Pioneer client
  throw instead of silently falling back. Routed the remaining BTC broadcast sites
  (sweep REST, audit sweep, broadcastViaPioneer) and BTC getFees through the seam.

UI: NodeStatusBar shows a determinate scan progress bar; status hook polls faster
while scanning.

Remaining BTC→Pioneer surface (documented follow-ups): price (GetMarketInfo),
history + address discovery (GetPubkeyInfo), rest-pioneer external proxy.
… validation, seed identity)

Five self-contained bugs from the PR #353 review:

- sweep-engine: altcoin (LTC/DOGE/BCH) audit sweeps no longer route through
  the Bitcoin self-host node — getBackendForNetwork() sends non-BTC networks
  to Pioneer, so a BTC Core node can't silently return "no funds" for them.
- engine-controller: a transient ETH derivation error on multi-chain firmware
  no longer flips the seed fingerprint to btc: (false seed-change purge). The
  btc: fallback now only fires on btc-only firmware, which legitimately refuses
  ETH; multi-chain uncertainty returns null.
- index.ts: reject a wrong-network node (testnet/regtest) at detect/save time
  instead of accepting any successful probe for a mainnet wallet.
- core.ts: never abort a scantxoutset "already in progress" — our own scans are
  serialized, so that state belongs to another client (or a leftover finishing
  server-side). Surface a retryable busy error instead of killing it.
- OOB onboarding: the Bitcoin-only choice now verifies against the btc-only
  binary's pinned hash, not the multi-coin firmware hash.

Findings on process-wide offline gating and full Pioneer isolation for
price/history/portfolio are the epic's known remaining Task 2/3 work
(non-merge-blocking) and are not addressed here.
Round-2 review of the self-host isolation:

- [P1] The persisted node config is global, but only a btc-only device may use
  it. A multichain device (which can't even see the node control to disable it)
  no longer inherits another device's node — setBtcNodeDeviceEligible() gates the
  backend on the connected firmware variant, set from the device state-change
  handler. The honesty guard follows the same gate, so a suppressed node lets
  BTC fall back to Pioneer instead of throwing.
- [P2] Seed-identity guard now sits AFTER the ETH attempt, so an ethGetAddress
  that RESOLVES address-less (undefined/{}) — not just one that throws — returns
  null on multichain firmware instead of flipping to the btc: fingerprint.
- [P2] setBtcNode validates before persisting: a wrong-network/unreachable node
  is rejected (throw) and nothing is written; the config is saved atomically
  only after a good probe.
- [P2] startFirmwareUpdate RPC schema params void → { bitcoinOnly?: boolean },
  matching the handler and hook (removes the changed-line type error).
- [P3] OOB Bitcoin-only selection now passes isBitcoinOnly to the firmware
  upgrade preview, so it shows the btc-only feature list (dormant behind flag).
getBtcNodeStatus checked the raw persisted btc_node_enabled setting, so after
switching to a multichain device the status bar kept probing the saved node and
could show "self-host active / no Pioneer fallback" — even though balances and
sends correctly use Pioneer (the node is suppressed by the deviceBtcOnly gate).

Expose isBtcNodeActive() (= nodeActive()) from btc-backend and return
{ active: false } before probing when the node isn't eligible for the connected
device.
@BitHighlander BitHighlander marked this pull request as ready for review July 13, 2026 18:27
@BitHighlander BitHighlander merged commit 31df3a7 into develop Jul 13, 2026
1 check 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