Bitcoin-only firmware support (detection, onboarding, UI scoping)#353
Merged
Conversation
…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).
This was referenced Jul 12, 2026
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.
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.
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
isBitcoinOnlyVarianton device Featuresfirmware_variant) + onboarding variant picker, gated behindBITCOIN_ONLY_ONBOARDING(default OFF). Dashboard chain list restricts to Bitcoin when btc-only.KeepKeyBTC/EmulatorBTCstring from the.bin; shows a BITCOIN-ONLY badge + btc-only 'What's New'.deviceId, stable across variants → stale ETH etc. summed into 'All Chains').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.ts7/7; encoder self-test; balance-purge dry-run (16 phantom rows → 0)