diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e0c0314..94ccf338 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -154,6 +154,25 @@ jobs: run: cd projects/keepkey-vault && bun install --frozen-lockfile shell: bash + - name: Test Bitcoin-only host boundary (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + cd projects/keepkey-vault + bun test \ + src/bun/bitcoin-only-boundary.test.ts \ + src/bun/perf-telemetry-offline.test.ts \ + src/bun/txbuilder/utxo-selfhost-policy.test.ts \ + src/bun/txbuilder/utxo-taproot.test.ts \ + __tests__/taproot-host.test.ts + bun src/bun/btc-backend/address-discovery.test.ts + bun src/bun/btc-backend/core.test.ts + bun src/bun/btc-backend/normalize.test.ts + bun src/bun/btc-backend/device-only.test.ts + bun src/bun/offline-policy.test.ts + bun src/bun/pioneer-guard.test.ts + node --check ../keepkey-sdk/tests/alpha/bitcoin-only-hardware.js + - name: Install protoc (macOS) if: runner.os == 'macOS' run: brew install protobuf diff --git a/docs/handoff-pioneer-alpha-bitcoin-only.md b/docs/handoff-pioneer-alpha-bitcoin-only.md new file mode 100644 index 00000000..6fae4d2e --- /dev/null +++ b/docs/handoff-pioneer-alpha-bitcoin-only.md @@ -0,0 +1,141 @@ +# Handoff: Pioneer requirements for alpha Bitcoin-only testing + +Date: 2026-08-23 + +From: Vault BTC-only audit + +To: Pioneer server/client owner + +Vault PR: #425 (`fix/alpha-bitcoin-only-api-boundary`) + +Pioneer source inspected: `release/v1.3.155` at `2b76a1ac6` + +Do not add a `bitcoinOnly` mode to Pioneer. Firmware identity and feature +restriction belong to Vault. In Pioneer mode, the device makes ordinary Bitcoin +mainnet or testnet requests. In self-hosted mode, Vault bypasses Pioneer for +mainnet and fails closed on testnet; offline mode blocks every network. Its +runtime guard rejects accidental BTC mainnet and testnet calls. + +Pioneer changes are still required before the whole advertised Bitcoin-only +surface can be called ready. + +## Live contract evidence + +These unauthenticated probes were run against the deployed service on +2026-08-23. They contain no wallet data: + +| Probe | Production result | Why it matters | +|---|---|---| +| mainnet `fee-rate` | HTTP 200, numeric `fastest/fast/average`, no unit | mainnet works, but the unit is implicit | +| testnet `fee-rate` | HTTP 400 `Unsupported UTXO networkId` | advertised Bitcoin testnet is not implemented | +| malformed xpub `ListUnspent` | HTTP 200 `[]` | invalid input is indistinguishable from an empty wallet | +| malformed xpub `GetPubkeyInfo` | HTTP 200 `{success:false}` | application failure is hidden behind HTTP success | +| malformed txid `LookupUtxoTx` | HTTP 200 `{success:false}` | not-found/error transport semantics are ambiguous | + +Mainnet and testnet fee probes produced the same results on +`api-blue.keepkey.info`. Preserve equivalent probes as deployment smoke tests. + +## P0: support Bitcoin testnet consistently + +Vault and Bitcoin-only firmware both expose Bitcoin testnet: + +- network: `bip122:000000000933ea01ad0ee984209779ba` +- asset: `bip122:000000000933ea01ad0ee984209779ba/slip44:1` +- extended keys: `tpub` (and script-specific testnet forms where supported) + +Pioneer's `UTXO_NETWORKS` and broadcast network map currently contain Bitcoin +mainnet but not testnet. Add one canonical testnet mapping and use it for: + +- `ListUnspent` +- `GetPubkeyInfo` +- `GetFeeRate` / `GetFeeRateByNetwork` +- `LookupUtxoTx` +- `Broadcast` +- portfolio balances and transaction history for the testnet asset CAIP + +If there is no production testnet indexer, return an explicit unsupported or +service-unavailable error. Do not return an empty wallet. + +## P0: failures must not masquerade as valid empty data + +The UTXO, fee, pubkey-info, lookup, and broadcast controllers commonly catch an +upstream exception and return HTTP 200 with `{success:false,error}`. Cold history +can also end its 30-second wait with `success:true`, `transactions:[]`, and +`loading:true`. + +Use transport status and a stable error body: + +- 400 for malformed or unsupported network/xpub/txid/hex; +- 404 only for a transaction that is genuinely absent; +- 502/503 for indexer, node, queue, or Redis failure; +- 202 with `loading:true` is acceptable for asynchronous cold history, but 200 + with an empty transaction array must mean a completed, authoritative empty + result. + +This distinction is safety-relevant: an empty UTXO set hides spendable funds; +missing address tokens can make a host reuse index 0; synthetic fallback fees +can materially alter a transaction. + +Vault PR #425 now also fails closed on Pioneer `{success:false}`, malformed +UTXO responses, missing fee rates, and missing address-token data. That protects +the current client but does not replace a correct server contract. + +## P0: freeze the BTC money-path response contract + +Return these fields consistently through the generated Pioneer client: + +```text +ListUnspent -> [{ txid, vout, value, path, address?, hex? }] +GetPubkeyInfo -> { tokens: [{ path, transfers, name? }], ... } +GetFeeRate -> { slow, average, fast|fastest, unit: "sat/vB" } +LookupUtxoTx -> { success: true, data: { txid, hex, ... } } +Broadcast -> { success: true, txid } +``` + +`value` must be an integer number of satoshis. `path` must be the full BIP32 +path belonging to the UTXO. Legacy P2PKH signing requires the complete raw +previous transaction hex, not just its txid or selected output. + +The generated operation is `LookupUtxoTx`; do not rename it silently. Vault +PR #425 accepts both that name and the older `UtxoLookup` alias during migration +and now unwraps the server's nested lookup response. + +Fee units must be explicit. Vault still recognizes the legacy sat/kB shape by +magnitude for compatibility, but a heuristic is not an acceptable permanent +money-path contract. + +## P1: history and discovery completion semantics + +For mainnet and testnet xpub/zpub/ypub/tpub account queries: + +- a cold cache must enqueue and await the indexer or return explicit `loading`; +- `forceRefresh=true` must not silently degrade to a completed empty history; +- pagination must preserve `caip`, `pubkey`, `page`, and completion state; +- `GetPubkeyInfo.tokens` must include every used receive/change address with + its exact path and positive transfer count; +- an upstream response with transactions but no token/path detail is an error, + not change index 0. + +## Acceptance gate + +Run against the exact alpha Pioneer candidate, not only mocks: + +1. Mainnet and testnet CAIP forms reach the intended node/indexer for every + listed operation; unknown `bip122:*` returns non-2xx. +2. A funded BIP44, BIP49, BIP84, and BIP86 account returns exact integer-satoshi + UTXOs and full derivation paths. +3. A legacy UTXO returns complete raw previous-transaction hex and the txid of + that hex matches the requested txid. +4. Fee response declares `sat/vB`; `slow <= average <= fast/fastest`; no default + success response is emitted when every estimator is unavailable. +5. Cold and forced history for a known-used key return transactions or explicit + in-progress/error state, never authoritative empty success. +6. Missing Blockbook token detail and simulated Redis/indexer/node outages are + visible failures. +7. Broadcast a disposable testnet transaction, assert the returned txid, then + repeat it and define the idempotent/already-known behavior. +8. Capture status code and JSON for every case as the handback evidence. + +No Pioneer deployment is required for the first alpha physical pass if testing +only Bitcoin mainnet on a healthy Pioneer backend. Testnet and degraded-backend +claims remain blocked until this gate passes. diff --git a/docs/handoff-testing/09-alpha-bitcoin-only.md b/docs/handoff-testing/09-alpha-bitcoin-only.md new file mode 100644 index 00000000..7ab730af --- /dev/null +++ b/docs/handoff-testing/09-alpha-bitcoin-only.md @@ -0,0 +1,199 @@ +# Alpha Bitcoin-only acceptance + +Owner: BTC-only test lead +Target: `alpha`, after firmware PR #534 (including merged #535 and its follow-up fixes) is included + +Safety: the automated runner never wipes, resets, recovers, loads, changes settings, or broadcasts + +## Release blocker found before device testing + +The physical Bitcoin-only artifact at firmware head `b67e53547` did not contain +`KeepKeyBTC`. Physical firmware returned the ordinary `KeepKey` variant, while +Vault's Bitcoin-only boundary deliberately recognizes only `KeepKeyBTC` and +`EmulatorBTC`. + +Consequences on physical hardware: + +- Vault would render the multi-chain UI. +- Add Chain, ShapeShift, and WalletConnect would remain reachable. +- Vault's non-Bitcoin REST fence would not run, allowing background altcoin + probes to reach a firmware that cannot service them. +- Flash-time inspection could not distinguish the Bitcoin-only image. + +Firmware PR #534 now carries #535's physical-identity fix and adds a CI +assertion over the actual flashable ARM artifact. Do not spend physical-device +time on an artifact that predates #534's final green head, has a red Bitcoin-only +ARM job, or does not +embed `KeepKeyBTC\0`. + +## Candidate preparation + +Download the exact `bitcoin-only` artifact from the CI run for the candidate +commit. Record its SHA-256; do not use a filename or branch name as identity. +Start the alpha Vault build and connect an initialized, test-safe device flashed +with that artifact. + +Run from `projects/keepkey-sdk`: + +```sh +export BTC_ALPHA_HARDWARE_TEST=1 +export BTC_ALPHA_ARTIFACT=/absolute/path/to/firmware.keepkey.bin +export BTC_ALPHA_EXPECT_FIRMWARE_HASH=<64-char-sha256> +export BTC_ALPHA_EXPECT_VERSION=7.16.0 +export BTC_ALPHA_EVIDENCE_FILE=/absolute/path/to/evidence/bitcoin-only-all.json +export KEEPKEY_URL=http://localhost:1646 +node tests/alpha/bitcoin-only-hardware.js all +``` + +Set `KEEPKEY_API_KEY` when reusing an existing pairing. Otherwise the SDK may +start a pairing approval. + +The runner fails before wallet testing unless all three identities agree: + +1. the supplied file hashes to the expected candidate hash; +2. the file embeds `KeepKeyBTC\0`; +3. the connected device reports the same hash and `firmware_variant` exactly + `KeepKeyBTC`. + +## What the runner proves + +- Every non-Bitcoin address and signing endpoint returns HTTP 501 at Vault's + REST boundary, including altcoin names passed through generic UTXO/xpub + routes; the advertised coin list contains only Bitcoin networks. +- BIP44/P2PKH, BIP49/P2SH-P2WPKH, BIP84/P2WPKH, and BIP86/P2TR derive with the + correct mainnet encoding. +- Each of those four addresses is shown on the physical OLED, compared + character-for-character and by QR, and matches the non-display derivation. +- Legacy, nested SegWit, native SegWit, and Taproot sign offline synthetic + transactions. The legacy case supplies a complete, structurally valid + nonexistent previous transaction whose output belongs to the derived BIP44 + address. +- Vault and the device independently display the expected destination, amount, + and fee. +- Device rejection propagates as a rejected SDK promise. +- The app presents the Bitcoin-only splash/navigation/portfolio/settings and + stays restricted across disconnect/reconnect. + +The Vault candidate also has executable host-boundary coverage beyond REST: + +- every privileged renderer RPC is wrapped before dispatch; chain-specific + prefixes, non-Bitcoin `chainId` values, generic UTXO coin names, xpub batches, + ClearSign, swaps, and WalletConnect pairing fail before handler side effects; +- portfolio, history, report, mobile-pairing, audit, watch-only, cached-balance, + and address-book reads are Bitcoin-scoped at their source; +- dynamic market/token requests and stale full-firmware ledger/report records + are fenced while the Bitcoin-only device is connected; +- a full-firmware → Bitcoin-only transition resets in-memory account managers + and removes persisted non-Bitcoin balances and xpubs; +- existing WalletConnect sessions are destroyed, queued deep links discarded, + and each device-signing callback independently re-checks the live firmware + variant while teardown is in progress; +- Zcash/Hive capability startup is disabled for Bitcoin-only firmware even when + its semantic version would otherwise enable those services. +- authenticated direct REST reads cannot recover stale altcoin activity by ID, + swap history/discovery, token visibility, or raw debug portfolio state; +- the MCP/BEX agent bridge is closed because its extension-owned tool catalog + and historical multichain results cannot be sanitized by Vault; +- generic Pioneer v2 routes accept only Bitcoin mainnet/testnet CAIPs and + network IDs, while multichain catalogs, account-state queries, and staking + are rejected before network access. +- a signing request queued under full firmware is revalidated against the live + device at approval time, so swapping to Bitcoin-only firmware cannot carry an + already-open Litecoin or altcoin approval across the transition; +- batch xpub requests validate both the coin and every declared network, so a + Bitcoin xpub cannot be returned under an Ethereum or other altcoin label. + +The synthetic prevouts do not exist, so signed transactions cannot be +broadcast. Evidence is written with mode `0600`, including the exact artifact, +firmware identity, addresses, serialized-transaction hashes, and operator +attestations. It contains no seed, private key, PIN, or passphrase. + +## Manual in-app matrix still required + +The runner exercises the production SDK/REST/hdwallet/device path, but it does +not prove the complete graphical send flow or backends. Use a funded disposable +test wallet and make an explicit spending decision before these cases: + +| Mode | Receive/discovery | Build and review | Broadcast | Required observation | +|---|---|---|---|---| +| Pioneer | BIP44/49/84/86 accounts | Small P2WPKH and P2TR sends | One minimal-value transaction | Correct UTXO selection, change path, amount, fee, txid, and post-confirm balance | +| Self-hosted Blockbook | Same four accounts; next-unused indexes from Blockbook | Small P2WPKH and P2TR sends | One minimal-value transaction | No Pioneer chain-data fallback; Blockbook history and post-confirm balance agree | +| Self-hosted Bitcoin Core | Same four accounts; manually select/verify receive index | No-change/send-max build only | One minimal-value no-change transaction, if deliberately funded for it | No Pioneer chain-data fallback; Core UTXOs, fees, txid, and post-confirm balance agree; history and automatic change discovery are explicitly unavailable | +| Offline | Cached account plus manually selected/verified receive index | Device address derivation and raw/synthetic signing only | Build, history, sweep, swap, report, and broadcast must reject | No outbound sockets after airplane mode is enabled; cached balances remain readable and device address/signing still work | + +Pioneer server/client follow-up is tracked in +[`../handoff-pioneer-alpha-bitcoin-only.md`](../handoff-pioneer-alpha-bitcoin-only.md). +Mainnet Pioneer testing may proceed, but do not claim Pioneer-backed testnet or +degraded-backend correctness until that handoff's acceptance gate passes. + +Bitcoin Core's `scantxoutset` sees only the current UTXO set. It cannot prove +that an empty address was previously spent from, so it cannot safely choose the +next unused receive index or reconstruct transaction history. Vault must show +that limitation and must not consult Pioneer behind the node. Full self-hosted +history/index discovery requires Blockbook (or future descriptor-wallet import +and rescan support); a silent index-0 default is a test failure. A normal Core +send that would create change must fail before device signing rather than reuse +a guessed change address. + +Also test RBF on a disposable transaction, high-fee rejection/warning, dust +handling, insufficient funds, cancellation at the Vault gate, cancellation on +the device, PIN-locked reconnect, passphrase account separation, and unplugging +during address display and signing. + +The legacy P2PKH case must keep supplying the complete previous transaction. +Reducing it to only a txid/amount would bypass the legacy streaming path and is +not acceptable evidence, even if a future host shim made that request appear to +pass. + +### Hot-transition regression (required once) + +This catches state that a cold-start test cannot see: + +1. On the same disposable device and seed, boot full firmware, load the + portfolio, save one non-Bitcoin address-book row, and establish a disposable + WalletConnect session. +2. Flash the exact Bitcoin-only candidate without restarting Vault. +3. Confirm Vault returns to the Bitcoin tab, closes WalletConnect, and the dApp + session is disconnected rather than merely hidden. +4. Confirm no non-Bitcoin balance, xpub, activity, address-book row, swap, + ClearSign, Zcash, Hive, Add Chain, or WalletConnect control remains visible. +5. Leave Vault idle through two portfolio refresh intervals and confirm the + device receives no altcoin `Unknown message` probes. +6. Disconnect/reconnect and repeat the visibility and idle checks. +7. Return to full firmware and confirm full-firmware features return normally; + this proves the restriction follows device identity rather than permanently + corrupting the app's global preferences. + +Record screenshots of the full-firmware before state and Bitcoin-only after +state, plus the dApp-side WalletConnect disconnect. Never use a production seed. + +## Host verification already completed + +- Bitcoin-only policy: 26 tests / 197 assertions, including + activity/ledger/report filtering, + watch-only snapshot authority, address-book history filtering, and the + renderer/REST/Pioneer dispatch boundaries, queued-approval revalidation, and + batch-xpub network validation. +- Focused host matrix: 33 tests / 213 assertions. +- The Linux packaging workflow now runs the broader executable gate: 41 Bun + tests / 232 expectations plus 87 standalone backend/policy assertions and a + syntax check of the physical runner. Packaging green is no longer the only + automated Vault signal. +- Bitcoin backend: Core 35 assertions, normalization 16, device-only 5. +- Taproot builder: P2TR input/change and BIP86 account-path selection, 3 cases. +- hdwallet Taproot: 8 tests, 28 assertions covering protocol enums, BIP86 + display/xpub requests, capability gating, and Schnorr input requirements. +- production Bun backend bundle and Vite renderer build both complete. + +## Merge gate + +Bitcoin-only alpha is not cleared until: + +- the identity-fix CI matrix is green for its exact head; +- the Vault REST-boundary fix and its complete signing-route matrix test are + present; +- the downloaded ARM artifact passes its CI identity assertion; +- `all` completes on physical hardware with preserved evidence; +- the in-app Pioneer, self-hosted, and offline matrix is recorded; +- a full firmware artifact on the same source head proves `KeepKeyBTC\0` is + absent, preventing accidental Bitcoin-only branding of the normal build. diff --git a/projects/keepkey-sdk/tests/alpha/bitcoin-only-hardware.js b/projects/keepkey-sdk/tests/alpha/bitcoin-only-hardware.js new file mode 100644 index 00000000..69407461 --- /dev/null +++ b/projects/keepkey-sdk/tests/alpha/bitcoin-only-hardware.js @@ -0,0 +1,529 @@ +/** + * Alpha Bitcoin-only physical-device acceptance through the production path: + * keepkey-sdk -> Vault REST -> hdwallet -> KeepKey. + * + * This runner never wipes, resets, recovers, loads, changes settings, or + * broadcasts. Signing uses synthetic, nonexistent prevouts. + */ + +const { createHash } = require('node:crypto') +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs') +const { dirname, resolve } = require('node:path') +const { createInterface } = require('node:readline/promises') +const { stdin, stdout } = require('node:process') +const { KeepKeySdk } = require('../../lib/index') + +const HARDENED = 0x80000000 +const BURN_ADDRESS = '1BitcoinEaterAddressDontSendf59kuE' +const phase = process.argv[2] +const validPhases = new Set(['preflight', 'addresses', 'signing', 'app', 'all']) + +const ADDRESS_CASES = [ + { name: 'BIP44 legacy', purpose: 44, scriptType: 'p2pkh', pattern: /^1[1-9A-HJ-NP-Za-km-z]{25,34}$/ }, + { name: 'BIP49 nested SegWit', purpose: 49, scriptType: 'p2sh-p2wpkh', pattern: /^3[1-9A-HJ-NP-Za-km-z]{25,34}$/ }, + { name: 'BIP84 native SegWit', purpose: 84, scriptType: 'p2wpkh', pattern: /^bc1q[023456789ac-hj-np-z]{38}$/ }, + { name: 'BIP86 Taproot', purpose: 86, scriptType: 'p2tr', pattern: /^bc1p[023456789ac-hj-np-z]{58}$/ }, +] + +const SIGN_CASES = [ + { name: 'BIP44 legacy', purpose: 44, scriptType: 'p2pkh', legacy: true }, + { name: 'BIP49 nested SegWit', purpose: 49, scriptType: 'p2sh-p2wpkh', prevoutByte: '33' }, + { name: 'BIP84 native SegWit', purpose: 84, scriptType: 'p2wpkh', prevoutByte: '44' }, + { name: 'BIP86 Taproot', purpose: 86, scriptType: 'p2tr', prevoutByte: '55' }, +] + +const NON_BTC_ADDRESS_ROUTES = [ + '/addresses/cosmos', + '/addresses/osmosis', + '/addresses/eth', + '/addresses/tendermint', + '/addresses/thorchain', + '/addresses/mayachain', + '/addresses/xrp', + '/addresses/solana', + '/addresses/tron', + '/addresses/ton', + '/addresses/hive', + '/addresses/bnb', +] + +const NON_BTC_SIGNING_ROUTES = [ + '/eth/sign-transaction', '/eth/sign-typed-data', '/eth/sign', + '/xrp/sign-transaction', '/solana/sign-transaction', '/solana/sign-message', + '/tron/sign-transaction', '/ton/sign-transaction', + '/hive/sign-transfer', '/hive/sign-message', '/hive/sign-operations', + '/tron/sign-message', '/tron/sign-typed-hash', '/ton/sign-message', + '/solana/sign-offchain-message', + '/cosmos/sign-amino', '/cosmos/sign-amino-delegate', '/cosmos/sign-amino-undelegate', + '/cosmos/sign-amino-redelegate', '/cosmos/sign-amino-withdraw-delegator-rewards-all', + '/cosmos/sign-amino-ibc-transfer', + '/osmosis/sign-amino', '/osmosis/sign-amino-delegate', '/osmosis/sign-amino-undelegate', + '/osmosis/sign-amino-redelegate', '/osmosis/sign-amino-withdraw-delegator-rewards-all', + '/osmosis/sign-amino-ibc-transfer', '/osmosis/sign-amino-lp-remove', + '/osmosis/sign-amino-lp-add', '/osmosis/sign-amino-swap', + '/thorchain/sign-amino-transfer', '/thorchain/sign-amino-deposit', + '/mayachain/sign-amino-transfer', '/mayachain/sign-amino-deposit', + '/api/v2/swap/open', '/api/v2/swap/set', '/api/v2/swap/quote', + '/api/v2/swap/execute', '/api/v2/swap/close', + '/eth/clearsign/load-signer', '/eth/clearsign/sign-alpha-delegate-certificate', + '/bnb/sign-transaction', +] + +const NON_BTC_READ_ROUTES = [ + '/api/zcash/shielded/status', + '/api/v1/swaps', '/api/v1/swaps/stats', '/api/v1/swap/discovery', + '/api/debug/portfolio', '/api/debug/portfolio/tokens', + '/api/debug/pioneer-audit', '/api/debug/token-visibility', + '/api/v2/assets/available', + '/wc', '/mcp', '/bex-bridge', +] + +const NON_BTC_DATA_REQUESTS = [ + ['/eth/verify', {}], + ['/ton/build-transfer', {}], + ['/tron/verify-message', {}], + ['/api/v2/portfolio/balances', { pubkeys: [{ caip: 'eip155:1/slip44:60', pubkey: '0xdead' }] }], + ['/api/v2/market/info', { caips: ['eip155:1/slip44:60'] }], + ['/api/v2/tx/history', { queries: [{ caip: 'eip155:1/slip44:60', pubkey: '0xdead' }] }], + ['/api/v2/utxo/unspent', { network: 'bip122:12a765e31ffd4059bada1e25190f6e98', xpub: 'xpub-invalid' }], + ['/api/v2/network/fee-rate', { networkId: 'eip155:1' }], + ['/api/v2/network/gas-price', { networkId: 'eip155:1' }], + ['/api/v2/assets/search', { q: 'ethereum' }], + ['/api/v2/staking/positions', { network: 'ethereum', address: '0xdead' }], +] + +if (process.env.BTC_ALPHA_HARDWARE_TEST !== '1') { + console.log(' SKIP alpha/bitcoin-only-hardware.js (set BTC_ALPHA_HARDWARE_TEST=1 and choose a phase)') + process.exit(0) +} + +if (!validPhases.has(phase)) { + console.error('Usage: node tests/alpha/bitcoin-only-hardware.js ') + process.exit(2) +} + +if (!stdin.isTTY || !stdout.isTTY) { + console.error('Bitcoin-only hardware acceptance requires an interactive terminal.') + process.exit(2) +} + +const expectedHash = process.env.BTC_ALPHA_EXPECT_FIRMWARE_HASH +const artifactPath = process.env.BTC_ALPHA_ARTIFACT +const evidencePath = process.env.BTC_ALPHA_EVIDENCE_FILE +if (!expectedHash || !/^[0-9a-f]{64}$/i.test(expectedHash)) { + console.error('BTC_ALPHA_EXPECT_FIRMWARE_HASH must be the exact 64-character candidate hash.') + process.exit(2) +} +if (!artifactPath || !existsSync(artifactPath)) { + console.error('BTC_ALPHA_ARTIFACT must name the exact flashable bitcoin-only firmware file.') + process.exit(2) +} +if (!evidencePath) { + console.error('BTC_ALPHA_EVIDENCE_FILE is required so the run cannot finish without preserving evidence.') + process.exit(2) +} + +const rl = createInterface({ input: stdin, output: stdout }) +const evidence = { + candidate: 'alpha-bitcoin-only', + transport: 'keepkey-sdk -> Vault REST -> hdwallet -> KeepKey', + phase, + started_at: new Date().toISOString(), + checks: [], +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function hash256(bytes) { + const first = createHash('sha256').update(bytes).digest() + return createHash('sha256').update(first).digest() +} + +function decodeBase58Check(address) { + const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + let value = 0n + for (const character of address) { + const digit = alphabet.indexOf(character) + if (digit < 0) throw new Error(`invalid base58 character in ${address}`) + value = value * 58n + BigInt(digit) + } + let hex = value.toString(16) + if (hex.length % 2) hex = `0${hex}` + const decoded = value === 0n ? Buffer.alloc(0) : Buffer.from(hex, 'hex') + let leadingZeroes = 0 + while (address[leadingZeroes] === '1') leadingZeroes++ + const bytes = Buffer.concat([Buffer.alloc(leadingZeroes), decoded]) + if (bytes.length < 5) throw new Error('base58check payload is too short') + const payload = bytes.subarray(0, -4) + const checksum = bytes.subarray(-4) + const first = createHash('sha256').update(payload).digest() + const expected = createHash('sha256').update(first).digest().subarray(0, 4) + if (!checksum.equals(expected)) throw new Error(`invalid base58check checksum for ${address}`) + return payload +} + +function p2pkhScript(address) { + const payload = decodeBase58Check(address) + if (payload.length !== 21 || payload[0] !== 0) throw new Error(`${address} is not a Bitcoin mainnet P2PKH address`) + return Buffer.concat([Buffer.from('76a914', 'hex'), payload.subarray(1), Buffer.from('88ac', 'hex')]).toString('hex') +} + +function parseBitcoinTransaction(serializedHex) { + const bytes = Buffer.from(serializedHex, 'hex') + let offset = 0 + const take = (length) => { + if (!Number.isSafeInteger(length) || length < 0 || offset + length > bytes.length) { + throw new Error(`truncated transaction at byte ${offset} (need ${length})`) + } + const result = bytes.subarray(offset, offset + length) + offset += length + return result + } + const readU32 = () => { + const value = take(4).readUInt32LE(0) + return value + } + const readVarInt = () => { + const prefix = take(1)[0] + if (prefix < 0xfd) return prefix + if (prefix === 0xfd) return take(2).readUInt16LE(0) + if (prefix === 0xfe) return take(4).readUInt32LE(0) + const value = take(8).readBigUInt64LE(0) + if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('transaction varint exceeds safe integer range') + return Number(value) + } + + const version = readU32() + const segwit = bytes[offset] === 0 && bytes[offset + 1] !== 0 + if (segwit) take(2) + const inputs = Array.from({ length: readVarInt() }, () => { + const txid = Buffer.from(take(32)).reverse().toString('hex') + const vout = readU32() + const scriptSig = take(readVarInt()).toString('hex') + const sequence = readU32() + return { txid, vout, scriptSig, sequence } + }) + const outputs = Array.from({ length: readVarInt() }, () => ({ + amount: take(8).readBigUInt64LE(0), + script: take(readVarInt()).toString('hex'), + })) + if (segwit) { + for (let i = 0; i < inputs.length; i++) { + const items = readVarInt() + for (let j = 0; j < items; j++) take(readVarInt()) + } + } + const locktime = readU32() + if (offset !== bytes.length) throw new Error(`transaction has ${bytes.length - offset} trailing bytes`) + return { version, segwit, inputs, outputs, locktime } +} + +const BURN_OUTPUT_SCRIPT = p2pkhScript(BURN_ADDRESS) + +function pass(name, details) { + console.log(` PASS ${name}${details ? ` — ${details}` : ''}`) + evidence.checks.push({ name, passed: true, ...(details ? { details } : {}) }) +} + +function check(name, condition, details) { + if (!condition) throw new Error(`FAIL ${name}${details ? ` — ${details}` : ''}`) + pass(name, details) +} + +async function attest(question) { + const answer = (await rl.question(`\n${question} [y/N] `)).trim().toLowerCase() + check(question, answer === 'y' || answer === 'yes', `operator answered ${answer || '(empty)'}`) +} + +function bipPath(purpose) { + return [HARDENED + purpose, HARDENED, HARDENED, 0, 0] +} + +function persistEvidence() { + const target = resolve(evidencePath) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }) + console.log(`\nEvidence: ${target}`) +} + +function verifyArtifact() { + const artifact = readFileSync(artifactPath) + const artifactHash = sha256(artifact) + check('artifact SHA-256 matches the expected candidate', artifactHash === expectedHash.toLowerCase(), artifactHash) + check('artifact embeds the physical bitcoin-only identity', artifact.includes(Buffer.from('KeepKeyBTC\0', 'latin1'))) + evidence.artifact = { path: resolve(artifactPath), sha256: artifactHash, bytes: artifact.length } +} + +async function connect() { + const sdk = await KeepKeySdk.create({ + apiKey: process.env.KEEPKEY_API_KEY, + baseUrl: process.env.KEEPKEY_URL || 'http://localhost:1646', + serviceName: 'KeepKey Alpha Bitcoin-only Acceptance', + serviceImageUrl: '', + }) + const [health, features] = await Promise.all([ + sdk.system.info.getHealth(), + sdk.system.info.getFeatures(), + ]) + const version = `${features.major_version}.${features.minor_version}.${features.patch_version}` + const expectedVersion = process.env.BTC_ALPHA_EXPECT_VERSION || '7.16.0' + + check('Vault reports a connected device', health.device_connected === true) + check('device runs the expected alpha version', version === expectedVersion, version) + check('physical firmware identifies as KeepKeyBTC', features.firmware_variant === 'KeepKeyBTC', features.firmware_variant) + check( + 'device reports a 32-byte firmware hash', + typeof features.firmware_hash === 'string' && /^[0-9a-f]{64}$/i.test(features.firmware_hash), + String(features.firmware_hash), + ) + check('device firmware hash matches the exact artifact', features.firmware_hash.toLowerCase() === expectedHash.toLowerCase()) + check('Vault exposes Taproot capability', features.supports_taproot === true) + check('device is initialized before non-destructive wallet tests', features.initialized === true) + evidence.firmware = { + version, + variant: features.firmware_variant, + hash: features.firmware_hash, + supports_taproot: features.supports_taproot, + } + return sdk +} + +async function verifyRestBoundary(sdk) { + const failures = [] + async function expect501(route, body, method = 'POST') { + try { + if (method === 'GET') await sdk.getClient().get(route) + else await sdk.getClient().post(route, body) + failures.push(`${route}: unexpectedly succeeded`) + } catch (error) { + if (error && error.status === 501 && /not available on bitcoin-only firmware/i.test(error.message)) { + pass(`${route} is rejected before device dispatch`, 'HTTP 501') + } else { + failures.push(`${route}: ${error && error.status ? `HTTP ${error.status} ` : ''}${error && error.message ? error.message : error}`) + } + } + } + + for (const route of NON_BTC_ADDRESS_ROUTES) { + await expect501(route, { address_n: bipPath(44), show_display: false }) + } + for (const route of NON_BTC_SIGNING_ROUTES) await expect501(route, {}) + for (const route of NON_BTC_READ_ROUTES) await expect501(route, undefined, 'GET') + for (const [route, body] of NON_BTC_DATA_REQUESTS) await expect501(route, body) + await expect501('/addresses/utxo', { address_n: bipPath(44), coin: 'Litecoin' }) + await expect501('/utxo/sign-transaction', { coin: 'Dogecoin', inputs: [{}], outputs: [{}] }) + await expect501('/system/info/get-public-key', { address_n: bipPath(44), coin_name: 'Zcash' }) + await expect501('/api/v1/activity/rebuild', { chainId: 'ethereum' }) + + const batch = await sdk.xpub.getPublicKeys([ + { address_n: [HARDENED + 44, HARDENED + 60, HARDENED], type: 'address', networks: ['eip155:1'] }, + { address_n: [HARDENED + 44, HARDENED + 2, HARDENED], type: 'xpub', coin: 'Litecoin' }, + { address_n: [HARDENED + 84, HARDENED, HARDENED], type: 'xpub', coin: 'Bitcoin', networks: ['eip155:1'] }, + { address_n: [HARDENED + 84, HARDENED, HARDENED], type: 'xpub', coin: 'Bitcoin', script_type: 'p2wpkh' }, + ]) + check('batch derivation reports all requested paths', batch.total_requested === 4, String(batch.total_requested)) + check('batch derivation returns only the Bitcoin path', batch.pubkeys.length === 1, `returned ${batch.pubkeys.length}`) + check('batch derivation returns a Bitcoin xpub', typeof batch.pubkeys[0]?.pubkey === 'string' && batch.pubkeys[0].pubkey.length > 0) + evidence.batch_derivation = { + requested: ['Ethereum address', 'Litecoin xpub', 'Bitcoin xpub mislabeled as Ethereum', 'Bitcoin BIP84 xpub'], + returned: batch.pubkeys.map(entry => ({ path: entry.path, type: entry.type, scriptType: entry.scriptType })), + } + + const coins = await sdk.system.info.listCoins() + check('coin listing is not empty', Array.isArray(coins) && coins.length > 0) + check( + 'coin listing exposes only Bitcoin networks', + coins.every(coin => coin && (coin.coin_name === 'Bitcoin' || coin.coin_name === 'Testnet')), + coins.map(coin => coin && coin.coin_name).join(','), + ) + check('coin listing includes Bitcoin mainnet', coins.some(coin => coin && coin.coin_name === 'Bitcoin')) + check('every non-Bitcoin device route is fenced', failures.length === 0, failures.join('; ')) + evidence.non_btc_routes = { + addresses: NON_BTC_ADDRESS_ROUTES, + signing: NON_BTC_SIGNING_ROUTES, + generic_coin_routes: ['/addresses/utxo', '/utxo/sign-transaction', '/system/info/get-public-key', '/api/pubkeys/batch'], + activity_routes: ['/api/v1/activity/rebuild'], + read_routes: NON_BTC_READ_ROUTES, + data_routes: NON_BTC_DATA_REQUESTS.map(([route]) => route), + expected_status: 501, + listed_coins: coins.map(coin => coin.coin_name), + } +} + +async function verifyAddresses(sdk) { + evidence.addresses = [] + for (const testCase of ADDRESS_CASES) { + const request = { + address_n: bipPath(testCase.purpose), + coin: 'Bitcoin', + script_type: testCase.scriptType, + show_display: false, + } + const hidden = await sdk.address.utxoGetAddress(request) + check(`${testCase.name} hidden derivation has the expected encoding`, testCase.pattern.test(hidden.address), hidden.address) + + console.log(`\nExpected ${testCase.name} address:\n\n ${hidden.address}\n`) + console.log('The device will display it now. Compare every character and the QR before approving.') + const displayed = await sdk.address.utxoGetAddress({ ...request, show_display: true }) + check(`${testCase.name} displayed response matches hidden derivation`, displayed.address === hidden.address) + await attest(`Did KeepKey display the complete ${testCase.name} address and a matching QR?`) + evidence.addresses.push({ + name: testCase.name, + path: `m/${testCase.purpose}'/0'/0'/0/0`, + script_type: testCase.scriptType, + address: hidden.address, + display_confirmed: true, + }) + } +} + +function syntheticTransaction(testCase) { + return { + coin: 'Bitcoin', + version: 2, + locktime: 0, + inputs: [{ + txid: testCase.prevoutByte ? testCase.prevoutByte.repeat(32) : '', + vout: 0, + addressNList: bipPath(testCase.purpose), + amount: '80000', + scriptType: testCase.scriptType, + sequence: 0xfffffffd, + }], + outputs: [{ + address: BURN_ADDRESS, + amount: '70000', + addressType: 'spend', + scriptType: 'p2pkh', + }], + } +} + +function syntheticLegacyPrevout(address) { + const amount = Buffer.alloc(8) + amount.writeBigUInt64LE(80000n) + const script = Buffer.from(p2pkhScript(address), 'hex') + const raw = Buffer.concat([ + Buffer.from('0100000001', 'hex'), + Buffer.alloc(32), + Buffer.from('ffffffff0100ffffffff01', 'hex'), + amount, + Buffer.from([script.length]), + script, + Buffer.alloc(4), + ]) + return { + hex: raw.toString('hex'), + txid: Buffer.from(hash256(raw)).reverse().toString('hex'), + } +} + +async function verifySigning(sdk) { + evidence.signing = [] + for (const testCase of SIGN_CASES) { + const tx = syntheticTransaction(testCase) + if (testCase.legacy) { + const source = await sdk.address.utxoGetAddress({ + address_n: bipPath(testCase.purpose), + coin: 'Bitcoin', + script_type: testCase.scriptType, + show_display: false, + }) + const prevout = syntheticLegacyPrevout(source.address) + tx.inputs[0].txid = prevout.txid + tx.inputs[0].hex = prevout.hex + } + console.log(`\n${testCase.name}: offline synthetic prevout; this transaction cannot be broadcast.`) + console.log('Vault and KeepKey must independently show 0.00070000 BTC to the BitcoinEater address and a 0.00010000 BTC fee.') + await attest(`Ready to begin the ${testCase.name} signing check?`) + const signed = await sdk.btc.btcSignTransaction(tx) + check(`${testCase.name} transaction serialized`, typeof signed.serializedTx === 'string' && /^[0-9a-f]+$/i.test(signed.serializedTx) && signed.serializedTx.length % 2 === 0) + check(`${testCase.name} returned one signature`, Array.isArray(signed.signatures) && signed.signatures.length === 1 && /^[0-9a-f]+$/i.test(signed.signatures[0])) + if (testCase.scriptType === 'p2tr') { + check('Taproot signature is 64-byte Schnorr', signed.signatures[0].length === 128) + } + const parsed = parseBitcoinTransaction(signed.serializedTx) + check(`${testCase.name} serialized version is 2`, parsed.version === 2, String(parsed.version)) + check(`${testCase.name} serialized locktime is 0`, parsed.locktime === 0, String(parsed.locktime)) + check(`${testCase.name} serialized exactly one input`, parsed.inputs.length === 1, String(parsed.inputs.length)) + check(`${testCase.name} serialized the requested prevout`, parsed.inputs[0]?.txid === tx.inputs[0].txid && parsed.inputs[0]?.vout === 0) + check(`${testCase.name} serialized the requested sequence`, parsed.inputs[0]?.sequence === tx.inputs[0].sequence) + check(`${testCase.name} serialized exactly one output`, parsed.outputs.length === 1, String(parsed.outputs.length)) + check(`${testCase.name} serialized exactly 70000 sats`, parsed.outputs[0]?.amount === 70000n, String(parsed.outputs[0]?.amount)) + check(`${testCase.name} serialized the BitcoinEater script`, parsed.outputs[0]?.script === BURN_OUTPUT_SCRIPT, parsed.outputs[0]?.script) + check(`${testCase.name} input/output difference is exactly the displayed 10000-sat fee`, BigInt(tx.inputs[0].amount) - parsed.outputs[0].amount === 10000n) + await attest(`Did Vault and KeepKey both show the exact ${testCase.name} destination, amount, and fee before approval?`) + evidence.signing.push({ + name: testCase.name, + path: `m/${testCase.purpose}'/0'/0'/0/0`, + script_type: testCase.scriptType, + synthetic_prevout: tx.inputs[0].txid, + serialized_sha256: sha256(Buffer.from(signed.serializedTx, 'hex')), + signature_bytes: signed.signatures[0].length / 2, + parsed_output_sats: parsed.outputs[0].amount.toString(), + parsed_output_script: parsed.outputs[0].script, + parsed_fee_sats: (BigInt(tx.inputs[0].amount) - parsed.outputs[0].amount).toString(), + display_confirmed: true, + }) + } + + console.log('\nCancellation: approve the Vault gate, then reject the next Taproot signing request on KeepKey.') + let rejected = false + let rejection = '' + try { + const taproot = SIGN_CASES.find(testCase => testCase.scriptType === 'p2tr') + if (!taproot) throw new Error('Taproot signing case is missing from the acceptance runner') + await sdk.btc.btcSignTransaction(syntheticTransaction(taproot)) + } catch (error) { + rejected = true + rejection = String(error && error.message ? error.message : error) + } + check('physical cancellation rejects the SDK promise with cancellation semantics', rejected && /cancel|reject|denied/i.test(rejection), rejection) + evidence.cancellation = { rejected, error: rejection } +} + +async function verifyApp() { + console.log('\nRestart Vault with the Bitcoin-only candidate connected before answering.') + const observations = [ + 'Did startup show the orange “Bitcoin-Only KeepKey” splash?', + 'Does the portfolio show Bitcoin only, with no Add Chain control?', + 'Are ShapeShift and WalletConnect absent from the top navigation?', + 'Is the WalletConnect toggle absent from Settings, with no ClearSign, Zcash, Hive, custom-chain, or swap control exposed?', + 'Does Settings contain the Bitcoin node section with Pioneer, self-hosted node, and offline choices?', + 'If first-run onboarding was eligible, did it offer exactly Pioneer, self-hosted node, and offline mode?', + 'After disconnect and reconnect, did Vault remain Bitcoin-only without altcoin cards or altcoin device prompts?', + 'During idle portfolio refresh, were there no repeated “Unknown message” device errors from altcoin polling?', + 'Did the activity list and address book contain only Bitcoin rows, with no stale altcoin data from an earlier full-firmware session?', + 'With offline mode enabled, did cached balance and device address derivation remain available while history, build, broadcast, sweep, swap, and report actions rejected as OFFLINE?', + 'While offline mode was enabled, did an OS-level connection monitor show no outbound Pioneer, Blockbook, Core, update, or relay connections?', + 'In Bitcoin Core mode, did Receive warn that address history/index discovery is unavailable instead of silently claiming index 0 is unused?', + 'In self-hosted mode, did disabling or breaking the configured node fail visibly without any Pioneer chain-data fallback?', + ] + for (const observation of observations) await attest(observation) + evidence.app = { observations: observations.map(name => ({ name, confirmed: true })) } +} + +async function main() { + verifyArtifact() + const sdk = await connect() + await verifyRestBoundary(sdk) + if (phase === 'addresses' || phase === 'all') await verifyAddresses(sdk) + if (phase === 'signing' || phase === 'all') await verifySigning(sdk) + if (phase === 'app' || phase === 'all') await verifyApp() + evidence.completed_at = new Date().toISOString() + evidence.passed = true + persistEvidence() +} + +main() + .catch((error) => { + evidence.completed_at = new Date().toISOString() + evidence.passed = false + evidence.error = String(error && error.stack ? error.stack : error) + console.error(`\n${evidence.error}`) + persistEvidence() + process.exitCode = 1 + }) + .finally(() => rl.close()) diff --git a/projects/keepkey-vault/src/bun/bitcoin-only-boundary.test.ts b/projects/keepkey-vault/src/bun/bitcoin-only-boundary.test.ts new file mode 100644 index 00000000..f5ccfb3a --- /dev/null +++ b/projects/keepkey-vault/src/bun/bitcoin-only-boundary.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, test } from 'bun:test' +import { + bitcoinOnlyAddressBookHistoryList, + bitcoinOnlyActivityList, + bitcoinOnlyBalanceList, + bitcoinOnlyChainList, + bitcoinOnlyCoinAllowed, + bitcoinOnlyCoinList, + bitcoinOnlyLedgerJournalList, + bitcoinOnlyLedgerSummaryList, + bitcoinOnlyPendingSigningRejection, + bitcoinOnlyPublicKeyPathAllowed, + bitcoinOnlyRejection, + bitcoinOnlyReportAllowed, + bitcoinOnlyRpcRejection, + bitcoinOnlySnapshot, + bitcoinOnlyWatchOnlyScope, + enforceBitcoinOnlyRpcBoundary, +} from './bitcoin-only-boundary' +import { SIGNING_ROUTES } from './signing-routes' + +describe('Bitcoin-only REST boundary', () => { + test('allows only the firmware coin table', () => { + for (const coin of [undefined, 'Bitcoin', 'Testnet']) { + expect(bitcoinOnlyCoinAllowed(coin)).toBe(true) + } + for (const coin of ['Litecoin', 'BitcoinCash', 'Dogecoin', 'Zcash', '', 'bitcoin']) { + expect(bitcoinOnlyCoinAllowed(coin)).toBe(false) + } + }) + + test('fences every non-Bitcoin signing route', () => { + for (const path of SIGNING_ROUTES) { + const body = path === '/utxo/sign-transaction' ? { coin: 'Bitcoin' } : {} + const rejected = bitcoinOnlyRejection('POST', path, body) + if (path === '/utxo/sign-transaction') expect(rejected).toBeNull() + else expect(rejected).not.toBeNull() + } + }) + + test('fences every dedicated non-Bitcoin address route', () => { + for (const path of [ + '/addresses/cosmos', '/addresses/osmosis', '/addresses/eth', '/addresses/tendermint', + '/addresses/thorchain', '/addresses/mayachain', '/addresses/xrp', '/addresses/solana', + '/addresses/tron', '/addresses/ton', '/addresses/hive', + ]) { + expect(bitcoinOnlyRejection('POST', path, {})).not.toBeNull() + } + expect(bitcoinOnlyRejection('POST', '/addresses/future-altcoin', {})).not.toBeNull() + }) + + test('fences every dedicated altcoin REST family, including non-signing helpers', () => { + for (const path of [ + '/eth/clearsign/load-signer', '/eth/verify', + '/cosmos/sign-amino', '/osmosis/sign-amino-swap', + '/thorchain/sign-amino-transfer', '/mayachain/sign-amino-deposit', + '/xrp/sign-transaction', '/solana/sign-message', '/tron/verify-message', + '/ton/build-transfer', '/hive/sign-message', '/bnb/sign-transaction', + '/api/zcash/shielded/status', '/api/zcash/shielded/build', + ]) { + expect(bitcoinOnlyRejection(path.endsWith('/status') ? 'GET' : 'POST', path, {})).not.toBeNull() + } + }) + + test('fences swap control, stale history, and discovery routes', () => { + for (const path of ['/api/v2/swap/open', '/api/v2/swap/set', '/api/v2/swap/quote', '/api/v2/swap/execute', '/api/v2/swap/close']) { + expect(bitcoinOnlyRejection('POST', path, {})).not.toBeNull() + } + for (const path of [ + '/api/v1/swaps', '/api/v1/swaps/stats', '/api/v1/swaps/old-txid', + '/api/v1/swap/availability/eip155%3A1', '/api/v1/swap/discovery', + ]) { + expect(bitcoinOnlyRejection('GET', path)).not.toBeNull() + } + }) + + test('generic UTXO routes allow Bitcoin/Testnet and reject altcoins', () => { + for (const path of ['/addresses/utxo', '/utxo/sign-transaction']) { + expect(bitcoinOnlyRejection('POST', path, {})).toBeNull() + expect(bitcoinOnlyRejection('POST', path, { coin: 'Bitcoin' })).toBeNull() + expect(bitcoinOnlyRejection('POST', path, { coin: 'Testnet' })).toBeNull() + expect(bitcoinOnlyRejection('POST', path, { coin: 'Litecoin' })).not.toBeNull() + } + }) + + test('leaves malformed generic-route bodies to schema validation', () => { + expect(bitcoinOnlyRejection('POST', '/addresses/utxo', { coin: null })).toBeNull() + expect(bitcoinOnlyRejection('POST', '/utxo/sign-transaction', { coin: 42 })).toBeNull() + expect(bitcoinOnlyRejection('POST', '/system/info/get-public-key', { coin_name: [] })).toBeNull() + }) + + test('generic public-key route rejects altcoin xpubs', () => { + expect(bitcoinOnlyRejection('POST', '/system/info/get-public-key', { coin_name: 'Bitcoin' })).toBeNull() + expect(bitcoinOnlyRejection('POST', '/system/info/get-public-key', { coin_name: 'Dogecoin' })).not.toBeNull() + }) + + test('fences multichain read surfaces but leaves Bitcoin and neutral reads alone', () => { + for (const path of [ + '/api/debug/portfolio', '/api/debug/portfolio/tokens', + '/api/debug/pioneer-audit', '/api/debug/token-visibility', + '/wc', '/wc/connect', '/mcp', '/bex-bridge', + ]) { + expect(bitcoinOnlyRejection('GET', path)).not.toBeNull() + } + expect(bitcoinOnlyRejection('GET', '/api/portfolio')).toBeNull() + expect(bitcoinOnlyRejection('GET', '/api/v1/activity')).toBeNull() + }) + + test('constrains generic Pioneer data routes to Bitcoin inputs', () => { + const btcNetwork = 'bip122:000000000019d6689c085ae165831e93' + const btcAsset = `${btcNetwork}/slip44:0` + const ethNetwork = 'eip155:1' + const ethAsset = `${ethNetwork}/slip44:60` + + expect(bitcoinOnlyRejection('POST', '/api/v2/portfolio/balances', { + pubkeys: [{ caip: btcAsset }, { caip: ethAsset }], + })).not.toBeNull() + expect(bitcoinOnlyRejection('POST', '/api/v2/portfolio/balances', { + pubkeys: [{ caip: btcAsset }], + })).toBeNull() + expect(bitcoinOnlyRejection('POST', '/api/v2/market/info', { + caips: [btcAsset, ethAsset], + })).not.toBeNull() + expect(bitcoinOnlyRejection('POST', '/api/v2/tx/history', { + queries: [{ caip: ethAsset }], + })).not.toBeNull() + + for (const path of [ + '/api/v2/utxo/unspent', '/api/v2/utxo/pubkey-info', + ]) { + expect(bitcoinOnlyRejection('POST', path, { network: ethNetwork })).not.toBeNull() + expect(bitcoinOnlyRejection('POST', path, { network: btcNetwork })).toBeNull() + } + for (const path of ['/api/v2/tx/broadcast', '/api/v2/network/fee-rate']) { + expect(bitcoinOnlyRejection('POST', path, { networkId: ethNetwork })).not.toBeNull() + expect(bitcoinOnlyRejection('POST', path, { networkId: btcNetwork })).toBeNull() + } + }) + + test('disables Pioneer multichain catalogs and account-state routes', () => { + for (const [method, path] of [ + ['GET', '/api/v2/assets/available'], + ['POST', '/api/v2/assets/search'], + ['POST', '/api/v2/network/gas-price'], + ['POST', '/api/v2/network/nonce'], + ['POST', '/api/v2/network/balance'], + ['POST', '/api/v2/network/token-decimals'], + ['POST', '/api/v2/staking/positions'], + ]) { + expect(bitcoinOnlyRejection(method, path, {})).not.toBeNull() + } + }) + + test('leaves malformed Pioneer bodies to schema validation', () => { + expect(bitcoinOnlyRejection('POST', '/api/v2/portfolio/balances', { pubkeys: 'bad' })).toBeNull() + expect(bitcoinOnlyRejection('POST', '/api/v2/market/info', { caips: null })).toBeNull() + expect(bitcoinOnlyRejection('POST', '/api/v2/utxo/unspent', { network: 42 })).toBeNull() + }) + + test('filters coin listings to Bitcoin networks', () => { + const visible = bitcoinOnlyCoinList([ + { coin: 'Bitcoin', id: 1 }, + { coin: 'Litecoin', id: 2 }, + { coin: 'Testnet', id: 3 }, + ]) + expect(visible.map(coin => coin.coin)).toEqual(['Bitcoin', 'Testnet']) + }) + + test('fences chain-specific renderer RPC before dispatch', () => { + for (const method of [ + 'ethGetAddress', 'solanaSignTx', 'cosmosSignTx', 'zcashShieldedSend', + 'clearsignAttestorSign', 'bnbGetAddress', 'binanceSignTransaction', + 'getEvmAddresses', 'executeSwap', 'getSwapQuote', 'wcPair', + ]) { + expect(bitcoinOnlyRpcRejection(method, {})).not.toBeNull() + } + for (const method of ['getBalance', 'buildTx', 'broadcastTx', 'scanChainHistory', 'auditScanPaths']) { + expect(bitcoinOnlyRpcRejection(method, { chainId: 'ethereum' })).not.toBeNull() + expect(bitcoinOnlyRpcRejection(method, { chainId: 'bitcoin' })).toBeNull() + } + }) + + test('fences generic UTXO and xpub renderer RPC by coin', () => { + expect(bitcoinOnlyRpcRejection('btcGetAddress', { coin: 'Litecoin' })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('btcSignTx', { coin: 'Dogecoin' })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('btcSignTx', { coin: 'Bitcoin' })).toBeNull() + expect(bitcoinOnlyRpcRejection('getPublicKeys', { + paths: [{ coin: 'Bitcoin' }, { coin: 'Zcash' }], + })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('getPublicKeys', { + paths: [{ coin: 'Bitcoin' }, { coin: 'Testnet' }], + })).toBeNull() + expect(bitcoinOnlyRpcRejection('getPublicKeys', { + paths: [{ type: 'address', networks: ['eip155:1'] }], + })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('getPublicKeys', { + paths: [{ coin: 'Bitcoin', type: 'xpub', networks: ['eip155:1'] }], + })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('getPublicKeys', { + paths: [{ coin: 'Bitcoin', type: 'xpub', networks: ['bip122:000000000019d6689c085ae165831e93'] }], + })).toBeNull() + expect(bitcoinOnlyPublicKeyPathAllowed({ + coin: 'Bitcoin', type: 'xpub', networks: ['eip155:1'], + })).toBe(false) + expect(bitcoinOnlyPublicKeyPathAllowed({ + coin: 'Bitcoin', type: 'xpub', networks: ['bip122:000000000019d6689c085ae165831e93'], + })).toBe(true) + }) + + test('fences dynamic altcoin market and token-state RPC', () => { + const btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + const eth = 'eip155:1/slip44:60' + expect(bitcoinOnlyRpcRejection('getMarketData', { caips: [btc] })).toBeNull() + expect(bitcoinOnlyRpcRejection('getMarketData', { caips: [btc, eth] })).not.toBeNull() + for (const method of ['setTokenVisibility', 'removeTokenVisibility', 'getTokenVisibilityMap']) { + expect(bitcoinOnlyRpcRejection(method, { caip: eth })).not.toBeNull() + } + }) + + test('blocks enabling WalletConnect but permits teardown', () => { + expect(bitcoinOnlyRpcRejection('setWalletConnectEnabled', { enabled: true })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('setWalletConnectEnabled', { enabled: false })).toBeNull() + expect(bitcoinOnlyRpcRejection('wcDisconnectSession', { topic: 'old' })).toBeNull() + }) + + test('wrapper rejects before invoking the privileged handler', async () => { + let calls = 0 + const handlers = enforceBitcoinOnlyRpcBoundary(() => true, { + ethSignTx: async () => { calls++; return 'signed' }, + btcSignTx: async () => { calls++; return 'signed' }, + }) + await expect(handlers.ethSignTx({})).rejects.toThrow('not available') + expect(calls).toBe(0) + expect(await handlers.btcSignTx({ coin: 'Bitcoin' })).toBe('signed') + expect(calls).toBe(1) + }) + + test('revalidates queued signing requests against the device present at approval', () => { + expect(bitcoinOnlyPendingSigningRejection({ + method: '/utxo/sign-transaction', + rawRequestBody: { coin: 'Bitcoin' }, + })).toBeNull() + expect(bitcoinOnlyPendingSigningRejection({ + method: '/utxo/sign-transaction', + rawRequestBody: { coin: 'Litecoin' }, + })).not.toBeNull() + expect(bitcoinOnlyPendingSigningRejection({ + method: '/eth/sign-transaction', + rawRequestBody: {}, + })).not.toBeNull() + expect(bitcoinOnlyPendingSigningRejection()).not.toBeNull() + }) + + test('filters automatic chain and cached-balance inputs at source', () => { + const chains = [{ id: 'bitcoin' }, { id: 'ethereum' }, { id: 'solana' }] + const balances = [{ chainId: 'bitcoin' }, { chainId: 'ethereum' }] + expect(bitcoinOnlyChainList(chains, true)).toEqual([{ id: 'bitcoin' }]) + expect(bitcoinOnlyChainList(chains, false)).toEqual(chains) + expect(bitcoinOnlyBalanceList(balances, true)).toEqual([{ chainId: 'bitcoin' }]) + expect(bitcoinOnlyBalanceList(balances, false)).toEqual(balances) + }) + + test('filters both activity-store row shapes to Bitcoin', () => { + const rows = [ + { id: 1, chainId: 'bitcoin' }, + { id: 2, chainId: 'ethereum' }, + { id: 3, chain: 'BTC' }, + { id: 4, chain: 'ETH' }, + { id: 5, chain: 'Bitcoin' }, + { id: 6 }, + ] + expect(bitcoinOnlyActivityList(rows, true).map(row => row.id)).toEqual([1, 3, 5]) + expect(bitcoinOnlyActivityList(rows, false)).toEqual(rows) + }) + + test('filters stale full-firmware ledger and report state', () => { + const summary = [ + { id: 1, chainId: 'bitcoin' }, + { id: 2, chainId: 'ethereum' }, + ] + expect(bitcoinOnlyLedgerSummaryList(summary, true)).toEqual([{ id: 1, chainId: 'bitcoin' }]) + + const journals = [ + { id: 1, postings: [{ asset: 'BTC' }, { asset: 'ETH' }] }, + { id: 2, postings: [{ asset: 'ETH' }] }, + ] + expect(bitcoinOnlyLedgerJournalList(journals, true)).toEqual([ + { id: 1, postings: [{ asset: 'BTC' }] }, + ]) + expect(bitcoinOnlyReportAllowed('all', true)).toBe(false) + expect(bitcoinOnlyReportAllowed('bitcoin', true)).toBe(true) + expect(bitcoinOnlyReportAllowed('all', false)).toBe(true) + }) + + test('recognizes bitcoin-only watch-only snapshots', () => { + expect(bitcoinOnlySnapshot(JSON.stringify({ firmwareVariant: 'KeepKeyBTC' }))).toBe(true) + expect(bitcoinOnlySnapshot(JSON.stringify({ firmware_variant: 'EmulatorBTC' }))).toBe(true) + expect(bitcoinOnlySnapshot(JSON.stringify({ firmwareVariant: 'KeepKey' }))).toBe(false) + expect(bitcoinOnlySnapshot('{broken')).toBe(false) + }) + + test('keeps the current Bitcoin-only device authoritative over stale watch-only snapshots', () => { + const fullSnapshot = JSON.stringify({ firmwareVariant: 'KeepKey' }) + const bitcoinSnapshot = JSON.stringify({ firmwareVariant: 'KeepKeyBTC' }) + expect(bitcoinOnlyWatchOnlyScope(true, fullSnapshot)).toBe(true) + expect(bitcoinOnlyWatchOnlyScope(false, bitcoinSnapshot)).toBe(true) + expect(bitcoinOnlyWatchOnlyScope(false, fullSnapshot)).toBe(false) + }) + + test('filters stale address-book history by Bitcoin asset', () => { + const rows = [ + { id: 1, caip: 'bip122:000000000019d6689c085ae165831e93/slip44:0' }, + { id: 2, caip: 'eip155:1/slip44:60' }, + { id: 3, caip: 'bip122:000000000933ea01ad0ee984209779ba/slip44:1' }, + ] + expect(bitcoinOnlyAddressBookHistoryList(rows, true).map(row => row.id)).toEqual([1, 3]) + expect(bitcoinOnlyAddressBookHistoryList(rows, false)).toEqual(rows) + }) + + test('fences non-Bitcoin address-book networks', () => { + expect(bitcoinOnlyRpcRejection('matchAddress', { networkId: 'eip155:1' })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('addAddressBook', { networkId: 'cosmos:cosmoshub-4' })).not.toBeNull() + expect(bitcoinOnlyRpcRejection('addAddressBook', { + networkId: 'bip122:000000000019d6689c085ae165831e93', + })).toBeNull() + }) +}) diff --git a/projects/keepkey-vault/src/bun/bitcoin-only-boundary.ts b/projects/keepkey-vault/src/bun/bitcoin-only-boundary.ts new file mode 100644 index 00000000..1845641c --- /dev/null +++ b/projects/keepkey-vault/src/bun/bitcoin-only-boundary.ts @@ -0,0 +1,299 @@ +import { SIGNING_ROUTES } from './signing-routes' + +const BITCOIN_COIN_NAMES = new Set(['Bitcoin', 'Testnet']) +const BITCOIN_NETWORK_IDS = new Set([ + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:000000000933ea01ad0ee984209779ba', +]) +const BITCOIN_ASSET_CAIPS = new Set([ + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'bip122:000000000933ea01ad0ee984209779ba/slip44:1', +]) +const NON_BITCOIN_RPC_PREFIXES = [ + 'clearsign', 'eth', 'cosmos', 'thorchain', 'mayachain', 'osmosis', + 'xrp', 'solana', 'tron', 'ton', 'hive', 'zcash', 'bnb', 'binance', +] + +const NON_BITCOIN_REST_PREFIXES = [ + '/eth/', '/cosmos/', '/thorchain/', '/mayachain/', '/osmosis/', + '/xrp/', '/solana/', '/tron/', '/ton/', '/hive/', '/bnb/', '/api/zcash/', +] + +const BITCOIN_ONLY_DISABLED_REST_PREFIXES = [ + '/api/v1/swaps', '/api/v1/swap/', '/api/v2/swap', + '/api/debug/portfolio', '/api/debug/pioneer-audit', + '/api/debug/token-visibility', + '/api/v2/assets/', '/api/v2/network/gas-price', + '/api/v2/network/nonce', '/api/v2/network/balance', + '/api/v2/network/token-decimals', '/api/v2/staking/', +] + +const NON_BITCOIN_RPC_METHODS = new Set([ + 'getDefiPositions', 'getStakingPositions', 'buildDelegateTx', + 'buildUndelegateTx', 'lookupName', 'getNameQuote', + 'buildNameRegistrationTx', 'addUtxoAccount', 'getUtxoAccounts', + 'getEvmAddresses', 'addEvmAddressIndex', 'removeEvmAddressIndex', + 'setEvmSelectedIndex', 'browseChains', 'addCustomToken', + 'removeCustomToken', 'getCustomTokens', 'setCustomTokenIcon', + 'addCustomChain', 'removeCustomChain', 'getCustomChains', + 'executeSwap', 'previewSwapBuild', 'wcPair', 'wcApprovePair', + 'setTokenVisibility', 'removeTokenVisibility', 'getTokenVisibilityMap', +]) + +/** Bitcoin-only firmware contains exactly the Bitcoin mainnet and testnet coin + * definitions. Missing coin fields use Bitcoin at the REST handlers. */ +export function bitcoinOnlyCoinAllowed(coin: unknown): boolean { + return coin === undefined || BITCOIN_COIN_NAMES.has(coin as string) +} + +/** Validate a public-key request after any route-specific coin inference. A + * Bitcoin xpub must not be returned under an altcoin network label: callers + * use that metadata to decide which wallet/account the key belongs to. */ +export function bitcoinOnlyPublicKeyPathAllowed(path: any): boolean { + if (!path || !BITCOIN_COIN_NAMES.has(path.coin)) return false + if (path.type === 'address') return false + return !Array.isArray(path.networks) + || path.networks.every((network: unknown) => + typeof network !== 'string' || BITCOIN_NETWORK_IDS.has(network)) +} + +function validButUnavailableCoin(coin: unknown): boolean { + return typeof coin === 'string' && !bitcoinOnlyCoinAllowed(coin) +} + +/** Decide whether an HTTP request must fail before it reaches approval UI or + * the device. The caller invokes this only for a detected BTC-only device. */ +export function bitcoinOnlyRejection( + method: string, + path: string, + body?: Record, +): string | null { + if (NON_BITCOIN_REST_PREFIXES.some(prefix => path.startsWith(prefix))) { + return 'non-Bitcoin route is not available on bitcoin-only firmware' + } + + if (BITCOIN_ONLY_DISABLED_REST_PREFIXES.some(prefix => path.startsWith(prefix))) { + return 'non-Bitcoin feature is not available on bitcoin-only firmware' + } + + if (path === '/wc' || path.startsWith('/wc/')) { + return 'WalletConnect is not available on bitcoin-only firmware' + } + + if (path === '/mcp' || path === '/bex-bridge') { + return 'multichain agent bridge is not available on bitcoin-only firmware' + } + + if (method !== 'POST') return null + + if (path.startsWith('/addresses/') && path !== '/addresses/utxo') { + return 'address route is not available on bitcoin-only firmware' + } + + if (SIGNING_ROUTES.has(path) && path !== '/utxo/sign-transaction') { + return 'non-Bitcoin signing is not available on bitcoin-only firmware' + } + + if (path === '/addresses/utxo' || path === '/utxo/sign-transaction') { + if (validButUnavailableCoin(body?.coin)) { + return `coin ${String(body?.coin)} is not available on bitcoin-only firmware` + } + } + + if (path === '/system/info/get-public-key' && validButUnavailableCoin(body?.coin_name)) { + return `coin ${String(body?.coin_name)} is not available on bitcoin-only firmware` + } + + const unavailableAsset = (assets: unknown): unknown => Array.isArray(assets) + ? assets.find(asset => typeof asset === 'string' && !BITCOIN_ASSET_CAIPS.has(asset)) + : undefined + const unavailablePubkeyAsset = (pubkeys: unknown): unknown => { + if (!Array.isArray(pubkeys)) return undefined + const entry = pubkeys.find((pubkey: unknown) => { + if (typeof pubkey !== 'object' || pubkey === null || !('caip' in pubkey)) return false + const caip = (pubkey as { caip?: unknown }).caip + return typeof caip === 'string' && !BITCOIN_ASSET_CAIPS.has(caip) + }) as { caip?: unknown } | undefined + return entry?.caip + } + + let unavailable: unknown + if (path === '/api/v2/portfolio/balances') unavailable = unavailablePubkeyAsset(body?.pubkeys) + if (path === '/api/v2/market/info') unavailable = unavailableAsset(body?.caips) + if (path === '/api/v2/tx/history') unavailable = unavailablePubkeyAsset(body?.queries) + if (unavailable !== undefined) { + return `asset ${String(unavailable)} is not available on bitcoin-only firmware` + } + + if (path === '/api/v2/utxo/unspent' + || path === '/api/v2/utxo/pubkey-info') { + if (typeof body?.network === 'string' && !BITCOIN_NETWORK_IDS.has(body.network)) { + return `network ${body.network} is not available on bitcoin-only firmware` + } + } + + if (path === '/api/v2/tx/broadcast' + || path === '/api/v2/network/fee-rate') { + if (typeof body?.networkId === 'string' && !BITCOIN_NETWORK_IDS.has(body.networkId)) { + return `network ${body.networkId} is not available on bitcoin-only firmware` + } + } + + return null +} + +export function bitcoinOnlyCoinList(coins: T[]): T[] { + return coins.filter(coin => bitcoinOnlyCoinAllowed(coin.coin)) +} + +/** Central policy for the privileged renderer -> Bun bridge. The renderer is + * not a security boundary: stale UI state, a future component, or injected + * script must not be able to dispatch an altcoin operation after the firmware + * has identified itself as Bitcoin-only. */ +export function bitcoinOnlyRpcRejection(method: string, params?: any): string | null { + if (method.toLowerCase().includes('swap')) { + return `${method} is not available on bitcoin-only firmware` + } + if (NON_BITCOIN_RPC_PREFIXES.some(prefix => method.startsWith(prefix))) { + return `${method} is not available on bitcoin-only firmware` + } + if (NON_BITCOIN_RPC_METHODS.has(method)) { + return `${method} is not available on bitcoin-only firmware` + } + + if (typeof params?.chainId === 'string' && params.chainId !== 'bitcoin') { + return `chain ${params.chainId} is not available on bitcoin-only firmware` + } + + if ((method === 'btcGetAddress' || method === 'btcSignTx') + && !bitcoinOnlyCoinAllowed(params?.coin)) { + return `coin ${String(params?.coin)} is not available on bitcoin-only firmware` + } + + if (method === 'getPublicKeys' && Array.isArray(params?.paths)) { + const unavailable = params.paths.find((path: any) => !bitcoinOnlyPublicKeyPathAllowed(path)) + if (unavailable) { + return 'public-key request is not available on bitcoin-only firmware' + } + } + + if (method === 'getMarketData' && Array.isArray(params?.caips)) { + const unavailable = params.caips.find((caip: unknown) => + typeof caip === 'string' && !BITCOIN_ASSET_CAIPS.has(caip)) + if (unavailable) { + return `asset ${String(unavailable)} is not available on bitcoin-only firmware` + } + } + + if (method === 'setWalletConnectEnabled' && params?.enabled === true) { + return 'WalletConnect is not available on bitcoin-only firmware' + } + + if ((method === 'matchAddress' || method === 'addAddressBook') + && typeof params?.networkId === 'string' + && !BITCOIN_NETWORK_IDS.has(params.networkId)) { + return `network ${params.networkId} is not available on bitcoin-only firmware` + } + + return null +} + +/** Revalidate a queued REST signing request at approval time. A request can be + * queued with multi-coin firmware attached and approved after the user swaps + * to a Bitcoin-only device, so the request-time boundary alone is not enough. + * Missing context fails closed: approval must never outlive its audit payload. */ +export function bitcoinOnlyPendingSigningRejection( + request?: { method: string; rawRequestBody?: Record }, +): string | null { + if (!request) return 'signing request context is unavailable on bitcoin-only firmware' + return bitcoinOnlyRejection('POST', request.method, request.rawRequestBody) +} + +type RpcHandler = (...args: any[]) => any + +/** Wrap every renderer RPC handler once. This keeps the check ahead of handler + * side effects and ensures newly-added handlers with a chainId, UTXO coin, or + * chain-specific prefix inherit the Bitcoin-only boundary automatically. */ +export function enforceBitcoinOnlyRpcBoundary>( + isBitcoinOnly: () => boolean, + handlers: T, +): T { + const wrapped: Record = {} + for (const [method, handler] of Object.entries(handlers)) { + wrapped[method] = async (...args: any[]) => { + if (isBitcoinOnly()) { + const rejection = bitcoinOnlyRpcRejection(method, args[0]) + if (rejection) throw new Error(rejection) + } + return handler(...args) + } + } + return wrapped as T +} + +export function bitcoinOnlyChainList(chains: T[], enabled: boolean): T[] { + return enabled ? chains.filter(chain => chain.id === 'bitcoin') : chains +} + +export function bitcoinOnlyBalanceList(balances: T[], enabled: boolean): T[] { + return enabled ? balances.filter(balance => balance.chainId === 'bitcoin') : balances +} + +/** Activity rows come from two stores: rebuilt history uses `chainId`, while + * REST/signing audit rows use the display symbol in `chain`. Fail closed when + * the current firmware is Bitcoin-only so stale multichain rows from the same + * device cannot leak back through a secondary host API. */ +export function bitcoinOnlyActivityList( + rows: T[], + enabled: boolean, +): T[] { + if (!enabled) return rows + return rows.filter(row => row.chainId === 'bitcoin' || row.chain === 'BTC' || row.chain === 'Bitcoin') +} + +export function bitcoinOnlyLedgerSummaryList(rows: T[], enabled: boolean): T[] { + return enabled ? rows.filter(row => row.chainId === 'bitcoin') : rows +} + +export function bitcoinOnlyLedgerJournalList< + T extends { postings: Array<{ asset: string }> }, +>(rows: T[], enabled: boolean): T[] { + if (!enabled) return rows + return rows + .map(row => ({ ...row, postings: row.postings.filter(posting => posting.asset === 'BTC') })) + .filter(row => row.postings.length > 0) as T[] +} + +export function bitcoinOnlyReportAllowed(chain: string | undefined, enabled: boolean): boolean { + return !enabled || chain === 'bitcoin' +} + +export function bitcoinOnlySnapshot(featuresJson: string | undefined): boolean { + if (!featuresJson) return false + try { + const features = JSON.parse(featuresJson) + return features?.firmwareVariant === 'KeepKeyBTC' + || features?.firmwareVariant === 'EmulatorBTC' + || features?.firmware_variant === 'KeepKeyBTC' + || features?.firmware_variant === 'EmulatorBTC' + } catch { + return false + } +} + +/** A connected Bitcoin-only device remains the authority even when the caller + * selects an older full-firmware snapshot for watch-only display. Without this + * union, stale snapshot metadata can re-enable altcoin portfolio requests. */ +export function bitcoinOnlyWatchOnlyScope( + currentDeviceBitcoinOnly: boolean, + snapshotFeaturesJson: string | undefined, +): boolean { + return currentDeviceBitcoinOnly || bitcoinOnlySnapshot(snapshotFeaturesJson) +} + +export function bitcoinOnlyAddressBookHistoryList( + rows: T[], + enabled: boolean, +): T[] { + return enabled ? rows.filter(row => BITCOIN_ASSET_CAIPS.has(row.caip)) : rows +} diff --git a/projects/keepkey-vault/src/bun/btc-backend/address-discovery.test.ts b/projects/keepkey-vault/src/bun/btc-backend/address-discovery.test.ts new file mode 100644 index 00000000..a76f4463 --- /dev/null +++ b/projects/keepkey-vault/src/bun/btc-backend/address-discovery.test.ts @@ -0,0 +1,28 @@ +import { addressIndicesFromTokens } from './address-discovery' + +let pass = 0 +function eq(actual: unknown, expected: unknown, label: string) { + if (actual !== expected) throw new Error(`FAIL ${label}: got ${String(actual)} want ${String(expected)}`) + pass++ +} + +const result = addressIndicesFromTokens([ + { path: "m/84'/0'/0'/0/0", transfers: 2 }, + { path: "m/84'/0'/0'/0/8", transfers: 1 }, + { path: "m/84'/0'/0'/1/3", transfers: 5 }, + { path: "m/84'/0'/0'/1/99", transfers: 0 }, + { path: "m/84'/0'/0'/2/7", transfers: 9 }, + { path: "m/84'/0'/0'/0/not-a-number", transfers: 1 }, + null, +], 'blockbook') + +eq(result.receiveIndex, 9, 'highest used receive + 1') +eq(result.changeIndex, 4, 'highest used change + 1') +eq(result.discoveryAvailable, true, 'discovery marked available') +eq(result.source, 'blockbook', 'source preserved') + +const empty = addressIndicesFromTokens([], 'pioneer') +eq(empty.receiveIndex, 0, 'empty receive starts at zero') +eq(empty.changeIndex, 0, 'empty change starts at zero') + +console.log(`[btc-backend] address discovery OK — ${pass} assertions passed`) diff --git a/projects/keepkey-vault/src/bun/btc-backend/address-discovery.ts b/projects/keepkey-vault/src/bun/btc-backend/address-discovery.ts new file mode 100644 index 00000000..52ccbb4b --- /dev/null +++ b/projects/keepkey-vault/src/bun/btc-backend/address-discovery.ts @@ -0,0 +1,31 @@ +import type { BtcAddressIndices, BtcBackendKind } from './types' + +/** Parse Blockbook/Pioneer address tokens into the first indexes after the + * highest used receive and change addresses. Malformed paths never advance an + * index. This is intentionally pure so the privacy-critical selection rule can + * be exhaustively fixture-tested without a network client. */ +export function addressIndicesFromTokens(tokens: unknown, source: BtcBackendKind): BtcAddressIndices { + let maxReceive = -1 + let maxChange = -1 + + if (Array.isArray(tokens)) { + for (const token of tokens) { + if (!token || typeof token !== 'object') continue + const { path, transfers } = token as { path?: unknown; transfers?: unknown } + if (typeof path !== 'string' || Number(transfers) <= 0) continue + const match = /(?:^|\/)([01])\/(\d+)$/.exec(path) + if (!match) continue + const index = Number(match[2]) + if (!Number.isSafeInteger(index) || index < 0) continue + if (match[1] === '0') maxReceive = Math.max(maxReceive, index) + else maxChange = Math.max(maxChange, index) + } + } + + return { + receiveIndex: maxReceive + 1, + changeIndex: maxChange + 1, + discoveryAvailable: true, + source, + } +} diff --git a/projects/keepkey-vault/src/bun/btc-backend/blockbook.ts b/projects/keepkey-vault/src/bun/btc-backend/blockbook.ts index 335bcdc0..91983455 100644 --- a/projects/keepkey-vault/src/bun/btc-backend/blockbook.ts +++ b/projects/keepkey-vault/src/bun/btc-backend/blockbook.ts @@ -10,6 +10,7 @@ */ import type { BtcBackend, BtcUtxo, BtcFeeRates } from './types' import { utxoDiscoveryKey } from './types' +import { addressIndicesFromTokens } from './address-discovery' export interface BlockbookConfig { url: string // base, no trailing slash — e.g. http://host:9130 @@ -98,6 +99,15 @@ export function makeBlockbookBackend(cfg: BlockbookConfig): BtcBackend { try { return (await bbFetch(cfg, `/api/v2/tx-specific/${txid}`))?.hex } catch { return undefined } }, + async addressIndices({ xpub, scriptType }) { + const key = utxoDiscoveryKey(xpub, scriptType) + const data = await bbFetch( + cfg, + `/api/v2/xpub/${encodeURIComponent(key)}?details=tokenBalances&tokens=used&pageSize=1`, + ) + return addressIndicesFromTokens(data?.tokens || [], 'blockbook') + }, + async tipHeight() { const s = await bbFetch(cfg, `/api/`) return s?.backend?.blocks ?? s?.blockbook?.bestHeight ?? 0 diff --git a/projects/keepkey-vault/src/bun/btc-backend/device-only.test.ts b/projects/keepkey-vault/src/bun/btc-backend/device-only.test.ts index 1eed81d4..2c4cdef1 100644 --- a/projects/keepkey-vault/src/bun/btc-backend/device-only.test.ts +++ b/projects/keepkey-vault/src/bun/btc-backend/device-only.test.ts @@ -1,9 +1,15 @@ /** * DeviceOnlyBackend must refuse every network op in offline (airplane) mode — - * the "stays offline" guarantee. Import-free (device-only.ts only imports types), - * so no electrobun/pioneer chain. Run: bun src/bun/btc-backend/device-only.test.ts + * the "stays offline" guarantee. It also exercises the network selector so + * testnet/altcoin requests cannot retain a Pioneer path while offline. + * Run: bun src/bun/btc-backend/device-only.test.ts */ import { DeviceOnlyBackend } from './device-only' +import { + broadcastBtcTx, + getBackendForNetwork, + setBtcBackendOffline, +} from './index' let pass = 0 async function throwsOffline(fn: () => Promise, label: string) { @@ -25,4 +31,24 @@ if (DeviceOnlyBackend.capabilities.history || DeviceOnlyBackend.capabilities.pus throw new Error('FAIL: offline backend must advertise no history/push') pass++ +// Selection must be global while offline. Testnet and non-BTC UTXO networks +// must not retain a hidden Pioneer path. +setBtcBackendOffline(true) +for (const network of [ + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:000000000933ea01ad0ee984209779ba', + 'bip122:12a765e31ffd4059bada1e25190f6e98', +]) { + if (getBackendForNetwork(network).kind !== 'device-only') { + throw new Error(`FAIL: offline ${network} did not select device-only`) + } + pass++ +} +await throwsOffline( + () => broadcastBtcTx({ Broadcast: () => { throw new Error('Pioneer reached') } }, + 'bip122:000000000933ea01ad0ee984209779ba', '00'), + 'testnet broadcast selection', +) +setBtcBackendOffline(false) + console.log(`[btc-backend] device-only OK — ${pass} assertions passed`) diff --git a/projects/keepkey-vault/src/bun/btc-backend/index.ts b/projects/keepkey-vault/src/bun/btc-backend/index.ts index 4f443e03..5209c696 100644 --- a/projects/keepkey-vault/src/bun/btc-backend/index.ts +++ b/projects/keepkey-vault/src/bun/btc-backend/index.ts @@ -16,6 +16,7 @@ import { DeviceOnlyBackend } from './device-only' import { makeCoreBackend } from './core' import { makeBlockbookBackend } from './blockbook' import { setPioneerGuardActive } from '../pioneer-guard' +import { assertPioneerSuccess, extractTxid } from './normalize' /** Persisted self-host node config. Blockbook (xpub-native, what Pioneer speaks) * or Bitcoin Core (scantxoutset). */ @@ -83,6 +84,9 @@ const BTC_NETWORK_ID = 'bip122:000000000019d6689c085ae165831e93' * or a BTC Core node silently returns nothing for their addresses. Use this * anywhere the network isn't guaranteed to be Bitcoin (e.g. the audit sweep). */ export function getBackendForNetwork(networkId: string): BtcBackend { + // Offline is global, not a mainnet-only preference. No UTXO network may + // retain a Pioneer path merely because it does not match the local BTC node. + if (offlineMode) return DeviceOnlyBackend return networkId === BTC_NETWORK_ID ? getBtcBackend() : PioneerBackend } @@ -90,13 +94,14 @@ export function getBackendForNetwork(networkId: string): BtcBackend { * Unifies every BTC broadcast site (send / sweep / REST) so none can silently cheat * past the node. Returns the txid. */ export async function broadcastBtcTx(pioneer: any, networkId: string, serialized: string): Promise { - if (networkId === BTC_NETWORK_ID && getBtcBackend().kind !== 'pioneer') { - return (await getBtcBackend().broadcast({ network: networkId, rawTxHex: serialized })).txid + const backend = getBackendForNetwork(networkId) + if (backend.kind !== 'pioneer') { + return (await backend.broadcast({ network: networkId, rawTxHex: serialized })).txid } const resp = await pioneer.Broadcast({ networkId, serialized }) - const data = resp?.data ?? resp - const txid = data?.txid || data?.tx_hash || data?.hash - if (!txid) throw new Error(`Broadcast failed: ${JSON.stringify(data).slice(0, 200)}`) + assertPioneerSuccess(resp, 'Broadcast') + const txid = extractTxid(resp) + if (!txid) throw new Error(`Broadcast failed: ${JSON.stringify(resp?.data ?? resp).slice(0, 200)}`) return String(txid) } diff --git a/projects/keepkey-vault/src/bun/btc-backend/normalize.test.ts b/projects/keepkey-vault/src/bun/btc-backend/normalize.test.ts index 5a1b51e6..032547d6 100644 --- a/projects/keepkey-vault/src/bun/btc-backend/normalize.test.ts +++ b/projects/keepkey-vault/src/bun/btc-backend/normalize.test.ts @@ -3,31 +3,46 @@ * breaks (response un-wrapping, sat/kB↔sat/vB, txid extraction). No network. * Run: bun src/bun/btc-backend/pioneer.test.ts */ -import { unwrapUtxos, normalizeUtxo, normalizeFeeRates, extractTxid } from './normalize' +import { + assertPioneerSuccess, + unwrapUtxos, + normalizeUtxo, + normalizeFeeRates, + extractTxid, + extractRawTxHex, +} from './normalize' let pass = 0 function eq(a: any, b: any, msg: string) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`FAIL ${msg}: got ${JSON.stringify(a)} want ${JSON.stringify(b)}`) pass++ } +function throws(fn: () => unknown, msg: string) { + try { fn() } catch { pass++; return } + throw new Error(`FAIL ${msg}: expected throw`) +} // unwrapUtxos — every wrapper shape Pioneer/Axios produces eq(unwrapUtxos([{ v: 1 }]), [{ v: 1 }], 'unwrap: bare array') eq(unwrapUtxos({ data: [{ v: 2 }] }), [{ v: 2 }], 'unwrap: {data:[]}') eq(unwrapUtxos({ data: { data: [{ v: 3 }] } }), [{ v: 3 }], 'unwrap: {data:{data:[]}}') eq(unwrapUtxos({ utxos: [{ v: 4 }] }), [{ v: 4 }], 'unwrap: {utxos:[]}') -eq(unwrapUtxos({ nope: 1 }), [], 'unwrap: unknown → []') +throws(() => unwrapUtxos({ nope: 1 }), 'unwrap: unknown fails closed') +throws(() => assertPioneerSuccess({ data: { success: false, error: 'node down' } }, 'ListUnspent'), 'application error fails closed') // normalizeUtxo — string value → int, hex from tx.hex or hex eq(normalizeUtxo({ txid: 'a', vout: 0, value: '99705' }).value, 99705, 'utxo: string value → int') eq(normalizeUtxo({ txid: 'a', vout: 1, value: 5, tx: { hex: 'deadbeef' } }).hex, 'deadbeef', 'utxo: hex from tx.hex') eq(normalizeUtxo({ txid: 'a', vout: 1, value: 5, hex: 'cafe' }).hex, 'cafe', 'utxo: hex from hex') +eq(normalizeUtxo({ txid: 'a', vout: 1, value: 5, addr: 'bc1qtest' }).address, 'bc1qtest', 'utxo: address from addr') +throws(() => normalizeUtxo({ txid: 'a', vout: 1, value: '12oops' }), 'utxo: malformed value fails closed') +throws(() => normalizeUtxo({ txid: 'a', vout: -1, value: 12 }), 'utxo: invalid vout fails closed') // normalizeFeeRates — sat/vB stays; sat/kB (>500) divides by 1000; picks fast/fastest eq(normalizeFeeRates({ data: { slow: 3, average: 5, fast: 15 } }).fast, 15, 'fee: sat/vB fast') eq(normalizeFeeRates({ data: { slow: 3000, average: 5000, fast: 15000 } }).fast, 15, 'fee: sat/kB → /1000') eq(normalizeFeeRates({ data: { fastest: 22 } }).fast, 22, 'fee: fastest field') -eq(normalizeFeeRates({}).fast >= 1, true, 'fee: empty → floor 1') +throws(() => normalizeFeeRates({}), 'fee: empty fails closed') // extractTxid — every id field a broadcast can return eq(extractTxid({ data: { txid: 'x' } }), 'x', 'txid: .txid') @@ -35,4 +50,9 @@ eq(extractTxid({ tx_hash: 'y' }), 'y', 'txid: .tx_hash') eq(extractTxid({ hash: 'z' }), 'z', 'txid: .hash') eq(extractTxid({ nope: 1 }), undefined, 'txid: none → undefined') +// LookupUtxoTx is double-wrapped by Axios + the server response. +eq(extractRawTxHex({ data: { data: { hex: '00cafe' } } }), '00cafe', 'raw tx: nested LookupUtxoTx') +eq(extractRawTxHex({ data: { tx: { hex: '00beef' } } }), '00beef', 'raw tx: legacy tx.hex') +eq(extractRawTxHex({ success: true }), undefined, 'raw tx: absent') + console.log(`[btc-backend] OK — ${pass} assertions passed`) diff --git a/projects/keepkey-vault/src/bun/btc-backend/normalize.ts b/projects/keepkey-vault/src/bun/btc-backend/normalize.ts index 7461418f..b6d00024 100644 --- a/projects/keepkey-vault/src/bun/btc-backend/normalize.ts +++ b/projects/keepkey-vault/src/bun/btc-backend/normalize.ts @@ -4,38 +4,68 @@ */ import type { BtcUtxo, BtcFeeRates } from './types' +/** Pioneer historically returns application failures as successful HTTP + * responses. Never let a money-path adapter reinterpret one as empty data. */ +export function assertPioneerSuccess(resp: any, operation: string): any { + for (const layer of [resp, resp?.data, resp?.data?.data]) { + if (layer && typeof layer === 'object' && layer.success === false) { + const reason = typeof layer.error === 'string' ? layer.error : 'Pioneer reported failure' + throw new Error(`${operation} failed: ${reason}`) + } + } + return resp +} + /** Pioneer ListUnspent double-wraps (Swagger + Axios). Peel to the array. */ export function unwrapUtxos(resp: any): any[] { - return Array.isArray(resp) ? resp + const utxos = Array.isArray(resp) ? resp : Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp.data.data : Array.isArray(resp?.utxos) ? resp.utxos - : [] + : undefined + if (!utxos) throw new Error('ListUnspent failed: malformed Pioneer response') + return utxos } export function normalizeUtxo(u: any): BtcUtxo { + const value = Number(u?.value) + if (typeof u?.txid !== 'string' || u.txid.length === 0) { + throw new Error('ListUnspent failed: UTXO omitted txid') + } + if (!Number.isSafeInteger(u?.vout) || u.vout < 0) { + throw new Error(`ListUnspent failed: invalid vout for ${u.txid}`) + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`ListUnspent failed: invalid satoshi value for ${u.txid}:${u.vout}`) + } return { txid: u.txid, vout: u.vout, - value: parseInt(u.value, 10) || 0, + value, hex: u.tx?.hex || u.hex || undefined, scriptType: u.scriptType || undefined, path: u.path || undefined, + address: u.address || u.addr || undefined, } } -/** Pioneer fee response → sat/vByte. Auto-detects sat/kB (values >500) and divides. */ +/** Pioneer fee response → sat/vByte. Auto-detects legacy sat/kB responses + * (values >500) until Pioneer exposes an explicit unit field. */ export function normalizeFeeRates(resp: any): BtcFeeRates { const d = resp?.data || resp || {} const fast = d.fastest ?? d.fast - const vals = [d.slow, d.average, fast].filter((v: any): v is number => typeof v === 'number') + const vals = [d.slow, d.average, fast].filter( + (v: any): v is number => typeof v === 'number' && Number.isFinite(v) && v > 0, + ) + if (vals.length === 0) throw new Error('GetFeeRate failed: Pioneer returned no valid fee rates') const perKb = vals.some((v) => v > 500) - const conv = (v: number | undefined, fallback: number) => - Math.max(1, Math.ceil((typeof v === 'number' ? v : fallback) / (perKb ? 1000 : 1))) + const fallback = vals[0] + const conv = (v: number | undefined) => + Math.max(1, Math.ceil((typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback) / (perKb ? 1000 : 1))) return { - slow: conv(d.slow ?? d.average, 3), - average: conv(d.average ?? fast, 5), - fast: conv(fast ?? d.average, 15), + slow: conv(d.slow ?? d.average), + average: conv(d.average ?? fast), + fast: conv(fast ?? d.average), } } @@ -43,3 +73,15 @@ export function extractTxid(resp: any): string | undefined { const d = resp?.data || resp return d?.txid || d?.tx_hash || d?.hash || undefined } + +/** Axios adds one data layer and LookupUtxoTx adds another. */ +export function extractRawTxHex(resp: any): string | undefined { + const candidates = [ + resp?.data?.data?.hex, + resp?.data?.hex, + resp?.data?.tx?.hex, + resp?.hex, + resp?.tx?.hex, + ] + return candidates.find((value): value is string => typeof value === 'string' && value.length > 0) +} diff --git a/projects/keepkey-vault/src/bun/btc-backend/pioneer.ts b/projects/keepkey-vault/src/bun/btc-backend/pioneer.ts index da709a89..fac4ca7e 100644 --- a/projects/keepkey-vault/src/bun/btc-backend/pioneer.ts +++ b/projects/keepkey-vault/src/bun/btc-backend/pioneer.ts @@ -6,7 +6,15 @@ */ import type { BtcBackend } from './types' import { utxoDiscoveryKey } from './types' -import { unwrapUtxos, normalizeUtxo, normalizeFeeRates, extractTxid } from './normalize' +import { + assertPioneerSuccess, + unwrapUtxos, + normalizeUtxo, + normalizeFeeRates, + extractTxid, + extractRawTxHex, +} from './normalize' +import { addressIndicesFromTokens } from './address-discovery' // Keep pure consumers of btc-backend/index.ts (transaction building, path // generation, and their unit tests) from eagerly loading the Pioneer → DB → @@ -26,6 +34,7 @@ export const PioneerBackend: BtcBackend = { if (!key) return [] const pioneer = await getPioneerClient() const resp = await pioneer.ListUnspent({ network, xpub: key }) + assertPioneerSuccess(resp, 'ListUnspent') return unwrapUtxos(resp).map(normalizeUtxo).filter((u) => u.value > 0) }, @@ -34,12 +43,14 @@ export const PioneerBackend: BtcBackend = { const resp = typeof pioneer.GetFeeRateByNetwork === 'function' ? await pioneer.GetFeeRateByNetwork({ networkId: network }) : await pioneer.GetFeeRate({ networkId: network }) + assertPioneerSuccess(resp, 'GetFeeRate') return normalizeFeeRates(resp) }, async broadcast({ network, rawTxHex }) { const pioneer = await getPioneerClient() const resp = await pioneer.Broadcast({ networkId: network, serialized: rawTxHex }) + assertPioneerSuccess(resp, 'Broadcast') const txid = extractTxid(resp) if (!txid) throw new Error(`Broadcast failed: ${JSON.stringify(resp?.data || resp).slice(0, 200)}`) return { txid } @@ -47,8 +58,22 @@ export const PioneerBackend: BtcBackend = { async rawTxHex({ network, txid }) { const pioneer = await getPioneerClient() - const resp = await pioneer.UtxoLookup({ networkId: network, txid }) - const d = resp?.data || resp - return d?.hex || d?.tx?.hex || undefined + const lookup = pioneer.LookupUtxoTx || pioneer.UtxoLookup + if (typeof lookup !== 'function') throw new Error('Pioneer client has no UTXO transaction lookup operation') + const resp = await lookup.call(pioneer, { networkId: network, txid }) + assertPioneerSuccess(resp, 'LookupUtxoTx') + return extractRawTxHex(resp) + }, + + async addressIndices({ network, xpub, scriptType }) { + const pioneer = await getPioneerClient() + const resp = await pioneer.GetPubkeyInfo({ network, xpub: utxoDiscoveryKey(xpub, scriptType) }) + assertPioneerSuccess(resp, 'GetPubkeyInfo') + const outer = resp?.data ?? resp + const data = outer?.data ?? outer + if (!Array.isArray(data?.tokens)) { + throw new Error('GetPubkeyInfo failed: Pioneer response omitted address tokens') + } + return addressIndicesFromTokens(data.tokens, 'pioneer') }, } diff --git a/projects/keepkey-vault/src/bun/btc-backend/types.ts b/projects/keepkey-vault/src/bun/btc-backend/types.ts index dd42a11a..c68a2a91 100644 --- a/projects/keepkey-vault/src/bun/btc-backend/types.ts +++ b/projects/keepkey-vault/src/bun/btc-backend/types.ts @@ -30,6 +30,16 @@ export interface BtcFeeRates { fast: number } +export interface BtcAddressIndices { + receiveIndex: number + changeIndex: number + /** False means the returned zeroes are conservative defaults, not proof that + * address 0 is unused. The UI must surface this before an address is copied. */ + discoveryAvailable: boolean + source: BtcBackendKind + warning?: string +} + export interface BtcBackend { readonly kind: BtcBackendKind /** history=false → e.g. a pruned Core node via scantxoutset (balance+UTXO only). */ @@ -42,6 +52,10 @@ export interface BtcBackend { broadcast(q: { network: string; rawTxHex: string }): Promise<{ txid: string }> rawTxHex(q: { network: string; txid: string }): Promise + /** Next-unused receive/change indexes require address history, not just the + * current UTXO set. Core's scantxoutset backend deliberately omits this. */ + addressIndices?(q: { network: string; xpub: string; scriptType?: string }): Promise + /** Tip height for a "Test connection" health check. Optional — only self-host needs it. */ tipHeight?(): Promise } diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index ce72a73f..eefaec9b 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -701,14 +701,16 @@ export function deleteCachedChainBalance(deviceId: string, chainId: string) { } } -/** Purge every non-Bitcoin cached balance for a device. Used when the device +/** Purge every non-Bitcoin cached balance and public key for a device. Used when the device * runs bitcoin-only firmware — its seed is locked to BTC and it can't derive - * any other chain, so multi-chain balances cached from a prior firmware are - * phantom (they'd sum into "All Chains"). Returns rows removed. */ + * any other chain, so multi-chain state cached from a prior firmware is both + * misleading and an avoidable disclosure surface. Returns rows removed. */ export function clearNonBitcoinBalances(deviceId: string): number { try { if (!db) return 0 - return db.run("DELETE FROM balances WHERE device_id = ? AND chain_id != 'bitcoin'", [deviceId]).changes + const balances = db.run("DELETE FROM balances WHERE device_id = ? AND chain_id != 'bitcoin'", [deviceId]).changes + const pubkeys = db.run("DELETE FROM cached_pubkeys WHERE device_id = ? AND chain_id != 'bitcoin'", [deviceId]).changes + return balances + pubkeys } catch (e: any) { console.warn('[db] clearNonBitcoinBalances failed:', e.message) return 0 diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index fac76bec..d703368b 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -132,6 +132,7 @@ export class EngineController extends EventEmitter { private latestBootloader = FALLBACK_BOOTLOADER private manifest: FirmwareManifest | null = null private alphaFirmware = false + private offlineMode = false private syncing = false private lastError: string | null = null private retryTimer: ReturnType | null = null @@ -524,9 +525,19 @@ export class EngineController extends EventEmitter { // ── Firmware Manifest ────────────────────────────────────────────────── + setOfflineMode(value: boolean) { + this.offlineMode = value + } + private async fetchFirmwareManifest() { // Always load bundled manifest first — guaranteed to exist, ships inside signed DMG. const bundled = this.loadBundledManifest() + if (this.offlineMode) { + console.log('[Engine] Offline mode — using bundled firmware manifest without a network request') + this.manifest = this.mergeManifests(bundled, null) + this.applyChannel() + return + } // Try remote — newer versions may exist between vault releases. let remote: FirmwareManifest | null = null try { diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 42d415be..b849aaba 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -133,9 +133,12 @@ import { runUsbDiagnostic as runUsbDiagnosticProbe } from "./windows-usb-probe" import { startRestApi, clearFeaturesCache, setUiActive, uiHeartbeat, type RestApiCallbacks } from "./rest-api" import { signSolanaWireTransaction } from "./solana-signing" import { AuthStore } from "./auth" -import { getPioneer, getPioneerApiBase, resetPioneer, DEFAULT_API_BASE, getQueryKey as getPioneerQueryKey } from "./pioneer" +import { getPioneer, getPioneerApiBase, resetPioneer, setPioneerOffline, DEFAULT_API_BASE, getQueryKey as getPioneerQueryKey } from "./pioneer" import { setBtcBackendOffline, setBtcNodeConfig, setBtcNodeDeviceEligible, isBtcNodeActive, getBtcBackend, broadcastBtcTx } from "./btc-backend" import { isBitcoinOnlyVariant } from "../shared/flags" +import { bitcoinOnlyActivityList, bitcoinOnlyAddressBookHistoryList, bitcoinOnlyBalanceList, bitcoinOnlyChainList, bitcoinOnlyLedgerJournalList, bitcoinOnlyLedgerSummaryList, bitcoinOnlyPendingSigningRejection, bitcoinOnlyReportAllowed, bitcoinOnlyWatchOnlyScope, enforceBitcoinOnlyRpcBoundary } from "./bitcoin-only-boundary" +import { assertOnline } from "./offline-policy" +import { setPerfTelemetryOffline } from "./perf-telemetry" import { fetchDefiPositions } from "./zapper" import { loadSupportedChains } from "../shared/swap-support-matrix" import { PioneerSocket } from "./pioneer-socket" @@ -150,7 +153,7 @@ import { CHAINS, customChainToChainDef, isChainSupported, hiveRolePath, btcTapro import { versionCompare } from "../shared/firmware-versions" import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" -import { utxoDiscoveryKey, unwrapUtxoDiscoveryKey } from "./btc-backend/types" +import { unwrapUtxoDiscoveryKey, utxoDiscoveryKey } from "./btc-backend/types" import { EvmAddressManager, evmAddressPath } from "./evm-addresses" import { shouldResetManagersOnReady, nextReadyDeviceId } from "../shared/device-switch" import { isManagerSeedStale } from "../shared/seed-reconcile" @@ -775,6 +778,7 @@ const evmAddresses = new EvmAddressManager() // passes through 'disconnected', so nulling there would skip the reset). See // src/shared/device-switch.ts for the pure decision + invariants. let lastReadyDeviceId: string | null = null +let lastReadyFirmwareVariant: string | null = null function attachSigningPolicySnapshot(info: SigningRequestInfo): SigningRequestInfo { const features = engine.getCachedFeaturesSnapshot() @@ -811,6 +815,20 @@ evmAddresses.canPersist = () => !engine.isPassphraseWallet let customChainDefs: ChainDef[] = [] let dbReady = false +async function initSwapTrackerIfAllowed(): Promise { + if (offlineMode) return + const { initSwapTracker } = await import('./swap-tracker') + await initSwapTracker((msg: string, data: any) => { + try { + if (msg === 'swap-update') rpc.send['swap-update'](data) + else if (msg === 'swap-complete') rpc.send['swap-complete'](data) + else console.error(`[swap-tracker] Unknown message: ${msg}`) + } catch (e: any) { + console.warn(`[swap-tracker] Failed to send '${msg}':`, e.message) + } + }, { getDeviceId: () => getWalletDbScope()?.deviceId, getWalletId: () => getWalletDbScope()?.walletId }) +} + function deferredInit() { perf('deferredInit start') initDb() @@ -826,18 +844,8 @@ function deferredInit() { // rehydrate pending swaps from history on cold boot — they'd stall until // executeSwap lazy-init kicks in. loadSettings() - loadSupportedChains(getPioneerApiBase()).catch(() => { /* static fallback handles it */ }) - import('./swap-tracker').then(async ({ initSwapTracker }) => { - await initSwapTracker((msg: string, data: any) => { - try { - if (msg === 'swap-update') rpc.send['swap-update'](data) - else if (msg === 'swap-complete') rpc.send['swap-complete'](data) - else console.error(`[swap-tracker] Unknown message: ${msg}`) - } catch (e: any) { - console.warn(`[swap-tracker] Failed to send '${msg}':`, e.message) - } - }, { getDeviceId: () => getWalletDbScope()?.deviceId, getWalletId: () => getWalletDbScope()?.walletId }) - }).catch((e) => { + if (!offlineMode) loadSupportedChains(getPioneerApiBase()).catch(() => { /* static fallback handles it */ }) + initSwapTrackerIfAllowed().catch((e) => { console.error('[swap-tracker] Failed to initialize swap tracker (swaps will be unavailable):', e.message || e) }) } @@ -864,6 +872,7 @@ const auth = new AuthStore() // approval RPC records the one request for which the user clicked "Allow once"; // the awaiting REST callback consumes and deletes it immediately. const blindSigningApprovalIds = new Set() +const pendingSigningApprovalInfo = new Map() async function requestSigningApprovalDecision(id: string) { const approved = await auth.requestSigningApproval(id) const allowBlindSigning = approved && blindSigningApprovalIds.has(id) @@ -909,6 +918,10 @@ let preReleaseUpdates = false let alphaFirmware = false let privateModeEnabled = false +function requireOnline(operation: string): void { + assertOnline(offlineMode, operation) +} + function loadSettings() { restApiEnabled = getSetting('rest_api_enabled') === '1' walletConnectEnabled = getSetting('walletconnect_enabled') === '1' @@ -921,6 +934,9 @@ function loadSettings() { privateModeEnabled = getSetting('private_mode_enabled') === '1' offlineMode = getSetting('offline_mode') === '1' setBtcBackendOffline(offlineMode) + setPioneerOffline(offlineMode) + setPerfTelemetryOffline(offlineMode) + engine.setOfflineMode(offlineMode) loadBtcNodeConfig() // Normalize emulator flag on platforms with no emulator support. The @@ -1063,10 +1079,12 @@ function getOrCreateWcManager(): WalletConnectManager { if (wcManager) return wcManager wcManager = new WalletConnectManager({ getEvmAddressInfo: () => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) return null const sel = evmAddresses.getSelectedAddress() return sel ? { address: sel.address, addressIndex: sel.addressIndex } : null }, ensureEvmAddressInfo: async () => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) return null if (!engine.wallet) return null if (!evmAddresses.isInitialized) { try { await evmAddresses.initialize(engine.wallet) } @@ -1075,10 +1093,11 @@ function getOrCreateWcManager(): WalletConnectManager { const sel = evmAddresses.getSelectedAddress() return sel ? { address: sel.address, addressIndex: sel.addressIndex } : null }, - ethSignTx: (params) => { if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignTx(params) }, - ethSignMessage: (params) => { if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignMessage(params) }, - ethSignTypedData: (params) => { if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignTypedData(params) }, + ethSignTx: (params) => { if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) throw new Error('WalletConnect is not available on bitcoin-only firmware'); if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignTx(params) }, + ethSignMessage: (params) => { if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) throw new Error('WalletConnect is not available on bitcoin-only firmware'); if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignMessage(params) }, + ethSignTypedData: (params) => { if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) throw new Error('WalletConnect is not available on bitcoin-only firmware'); if (!engine.wallet) throw new Error('Device disconnected'); return engine.wallet.ethSignTypedData(params) }, getCosmosAccountInfo: async (caipChain) => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) return null if (!engine.wallet) return null // Only cosmoshub-4 supported in v1; THOR/Maya/Osmosis use the cosmos // namespace too but need different signers and bech32 prefixes — follow-up. @@ -1103,6 +1122,7 @@ function getOrCreateWcManager(): WalletConnectManager { } }, cosmosSignAmino: async ({ addressNList, signDoc }) => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) throw new Error('WalletConnect is not available on bitcoin-only firmware') if (!engine.wallet) throw new Error('Device disconnected') // Translate WC StdSignDoc → hdwallet CosmosSignTx. // StdSignDoc: { chain_id, account_number, sequence, fee, msgs, memo } @@ -1129,6 +1149,7 @@ function getOrCreateWcManager(): WalletConnectManager { return { signatureBase64 } }, getSolanaAccountInfo: async (caipChain) => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) return null if (!engine.wallet) return null if (caipChain !== 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') return null // Solana uses ed25519 with a 4-element fully hardened path m/44'/501'/0'/0' @@ -1145,6 +1166,7 @@ function getOrCreateWcManager(): WalletConnectManager { } }, solanaSignMessageRaw: async ({ addressNList, messageBase58 }) => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) throw new Error('WalletConnect is not available on bitcoin-only firmware') if (!engine.wallet) throw new Error('Device disconnected') const bs58 = (await import('bs58')).default const messageBytes = Buffer.from(bs58.decode(messageBase58)) @@ -1163,6 +1185,7 @@ function getOrCreateWcManager(): WalletConnectManager { return await broadcastBtcTx(pioneer, networkId, serialized) }, solanaSignTransactionRaw: async ({ addressNList, signerAddress, transactionBase64 }) => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) throw new Error('WalletConnect is not available on bitcoin-only firmware') if (!engine.wallet) throw new Error('Device disconnected') const result = await signSolanaWireTransaction( { addressNList, rawTx: transactionBase64 }, @@ -1454,11 +1477,13 @@ const restCallbacks: RestApiCallbacks = { }, onSigningRequest: async (info: SigningRequestInfo) => { attachSigningPolicySnapshot(info) + pendingSigningApprovalInfo.set(info.id, info) try { rpc.send['signing-request'](info) } catch { /* webview not ready */ } acquireWindowFocus() try { return await requestSigningApprovalDecision(info.id) } finally { + pendingSigningApprovalInfo.delete(info.id) releaseWindowFocus() } }, @@ -1886,6 +1911,7 @@ function schedulePostZcashTxRescans(): void { // the in-app RPC path pushes to the WebView; the REST path passes NOOP. // The device still gates every signature in both paths. async function headlessSwapQuote(params: SwapQuoteParams): Promise { + requireOnline('swap quote') const { getSwapQuote } = await import('./swap') // Firmware gate (see buildTx): selling a THORChain/Maya bank token (TCY, @@ -2037,6 +2063,7 @@ async function headlessSwapQuote(params: SwapQuoteParams): Promise { } async function headlessExecuteSwap(params: ExecuteSwapParams, pushSubStage: (stage: SwapSubStage) => void): Promise { + requireOnline('swap execution') if (!engine.wallet) throw new Error('No device connected') // Firmware gate ENFORCED at execute time, not just quote time: /api/v2/swap/ @@ -2228,12 +2255,14 @@ const rpc = BrowserView.defineRPC({ // dispatcher drops non-Error throws without sending a response (see // scripts/patch-electrobun.sh, which normalizes them), so handlers here // may throw device failures without hanging the renderer. - requests: { + requests: enforceBitcoinOnlyRpcBoundary( + () => isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + { // ── Device lifecycle ────────────────────────────────────── getDeviceState: async () => engine.getDeviceState(), retryConnect: async () => { await engine.retryConnect() }, - startBootloaderUpdate: async () => { await engine.startBootloaderUpdate() }, - startFirmwareUpdate: async (params: { bitcoinOnly?: boolean }) => { await engine.startFirmwareUpdate(params?.bitcoinOnly) }, + startBootloaderUpdate: async () => { requireOnline('bootloader update'); await engine.startBootloaderUpdate() }, + startFirmwareUpdate: async (params: { bitcoinOnly?: boolean }) => { requireOnline('firmware update'); await engine.startFirmwareUpdate(params?.bitcoinOnly) }, flashFirmware: async () => { await engine.flashFirmware() }, analyzeFirmware: async (params) => { if (params.data.length > 10_000_000) throw new Error('Firmware data too large (max ~7.5MB)') @@ -3269,6 +3298,16 @@ const rpc = BrowserView.defineRPC({ // ── Pioneer integration (batch portfolio API) ──────────────── getBalances: async ({ forceRefresh = false, swapDestCaips = [] }: { forceRefresh?: boolean; swapDestCaips?: string[] } = {}) => { if (!engine.wallet) throw new Error('No device connected') + if (offlineMode) { + if (engine.isPassphraseWallet) return [] + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) return [] + const cached = getCachedBalances(deviceId) + return bitcoinOnlyBalanceList( + cached?.balances || [], + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + ) + } // Initialize Pioneer client — isolate failure so device derivation still works let pioneer: any = null @@ -3310,7 +3349,9 @@ const rpc = BrowserView.defineRPC({ // Filter chains by firmware version — don't derive addresses for unsupported chains // Zcash (transparent + shielded) gated behind feature flag const fwVersion = engine.getDeviceState().firmwareVersion - const allChains = getAllChains().filter(c => { + const bitcoinOnly = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) + if (bitcoinOnly) swapDestCaips = [] + const allChains = bitcoinOnlyChainList(getAllChains(), bitcoinOnly).filter(c => { if (!isChainSupported(c, fwVersion)) return false if ((c.id === 'zcash' || c.id === 'zcash-shielded') && !zcashPrivacyEnabled) return false if (c.id === 'hive' && !hiveEnabled) return false @@ -3373,7 +3414,7 @@ const rpc = BrowserView.defineRPC({ const evmChains = nonUtxoChains.filter(c => c.chainFamily === 'evm') const nonEvmChains = nonUtxoChains.filter(c => c.chainFamily !== 'evm') - if (!evmAddresses.isInitialized) { + if (evmChains.length > 0 && !evmAddresses.isInitialized) { try { await evmAddresses.initialize(wallet) } catch (e: any) { console.warn('[getBalances] EVM addresses init failed:', e.message) } @@ -3500,7 +3541,7 @@ const rpc = BrowserView.defineRPC({ const results: ChainBalance[] = [] try { if (!pioneer) throw (pioneerInitError || new Error('Pioneer client not available')) - const customContracts: PortfolioExtraContract[] = getCustomTokens().map(ct => ({ + const customContracts: PortfolioExtraContract[] = (bitcoinOnly ? [] : getCustomTokens()).map(ct => ({ networkId: ct.networkId, contractAddress: ct.contractAddress, decimals: ct.decimals, @@ -4311,6 +4352,12 @@ const rpc = BrowserView.defineRPC({ if (!engine.wallet) throw new Error('No device connected') const chain = getAllChains().find(c => c.id === params.chainId) if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) + if (offlineMode) { + const deviceId = engine.getDeviceState().deviceId + const cached = !engine.isPassphraseWallet && deviceId ? getCachedBalances(deviceId) : null + return cached?.balances.find(balance => balance.chainId === chain.id) + || { chainId: chain.id, symbol: chain.symbol, balance: '0', balanceUsd: 0, address: '' } + } // forceRefresh defaults TRUE: single-chain fetches are user-clicked // refreshes, post-send resyncs, or tx pushes — chain state changed, so // Pioneer's cache must be bypassed. Callers pass false only when the @@ -4334,7 +4381,8 @@ const rpc = BrowserView.defineRPC({ console.warn(`[getBalance] ${chain.id}: firmware does not implement ${chain.rpcMethod} — returning empty balance`) return { chainId: chain.id, symbol: chain.symbol, balance: '0', balanceUsd: 0, address: '' } } - const pioneer = await getPioneer() + const bitcoinSelfHost = chain.id === 'bitcoin' && getBtcBackend().kind !== 'pioneer' + const pioneer = bitcoinSelfHost ? {} : await getPioneer() const wallet = engine.wallet as any // Shared seed-staleness boundary — a single-chain refresh (AssetPage, @@ -4484,23 +4532,56 @@ const rpc = BrowserView.defineRPC({ // Other families can't have Zapper apps and the extra round-trip is wasted. if (isEvm) portfolioBody.includeDefi = true let resp: any - try { - resp = await withTimeout( - pioneer.GetPortfolioBalances(portfolioBody, { forceRefresh }), - PIONEER_TIMEOUT_MS, - 'GetPortfolioBalances' - ) - } catch (err: any) { - if (!extraContracts.length || !isExtraContractsSchemaError(err)) throw err - console.warn(`[getBalance] ${chain.coin}: Pioneer rejected extraContracts; retrying without custom tokens`) - resp = await withTimeout( - pioneer.GetPortfolioBalances( - { pubkeys: portfolioBody.pubkeys, ...(isEvm ? { includeDefi: true } : {}) }, - { forceRefresh } - ), - PIONEER_TIMEOUT_MS, - 'GetPortfolioBalances' - ) + if (bitcoinSelfHost) { + const backend = getBtcBackend() + let priceUsd = 0 + try { + const marketPioneer = await getPioneer() + const market: any = await withTimeout(marketPioneer.GetMarketInfo([chain.caip]), PIONEER_TIMEOUT_MS, 'GetMarketInfo(BTC)') + const row = Array.isArray(market?.data) ? market.data[0] : (Array.isArray(market) ? market[0] : market?.data ?? market) + priceUsd = Number(row?.priceUsd ?? row?.price ?? 0) || 0 + } catch (error: any) { + console.warn('[getBalance] BTC price fetch failed (USD may show 0):', error?.message) + } + const nodeEntries = await Promise.all(pubkeys.map(async (entry) => { + const sourceXpub = entry.sourcePubkey || unwrapUtxoDiscoveryKey(entry.pubkey) + const utxos = await backend.listUnspent({ + network: chain.networkId, + xpub: sourceXpub, + scriptType: entry.scriptType, + }) + const btc = utxos.reduce((sum, utxo) => sum + utxo.value, 0) / 1e8 + return { + caip: chain.caip, + pubkey: entry.pubkey, + chainId: 'bitcoin', + networkId: chain.networkId, + symbol: 'BTC', + type: 'native', + balance: btc.toFixed(8), + valueUsd: btc * priceUsd, + } + })) + resp = { data: { balances: nodeEntries } } + } else { + try { + resp = await withTimeout( + pioneer.GetPortfolioBalances(portfolioBody, { forceRefresh }), + PIONEER_TIMEOUT_MS, + 'GetPortfolioBalances' + ) + } catch (err: any) { + if (!extraContracts.length || !isExtraContractsSchemaError(err)) throw err + console.warn(`[getBalance] ${chain.coin}: Pioneer rejected extraContracts; retrying without custom tokens`) + resp = await withTimeout( + pioneer.GetPortfolioBalances( + { pubkeys: portfolioBody.pubkeys, ...(isEvm ? { includeDefi: true } : {}) }, + { forceRefresh } + ), + PIONEER_TIMEOUT_MS, + 'GetPortfolioBalances' + ) + } } const { entries: allEntries, meta: portfolioMeta, defiPositions: respDefiPositions } = unwrapPortfolioResponse(resp) const rawDefiPositions: ServerDefiPosition[] = respDefiPositions || [] @@ -4516,7 +4597,7 @@ const rpc = BrowserView.defineRPC({ } catch { /* webview not ready */ } } - console.log(`[getBalance] ${chain.coin}: ${allEntries.length} entries from Pioneer (${pubkeys.length} pubkeys)`) + console.log(`[getBalance] ${chain.coin}: ${allEntries.length} entries from ${bitcoinSelfHost ? getBtcBackend().kind : 'Pioneer'} (${pubkeys.length} pubkeys)`) // Pioneer may return cross-chain data with forceRefresh — filter to THIS chain // AND to the pubkeys we actually requested (prevents same-network contamination). @@ -4708,6 +4789,9 @@ const rpc = BrowserView.defineRPC({ } } } catch (e: any) { + if (bitcoinSelfHost) { + throw new Error(`Self-host node balance error: ${e?.message || e}`) + } const message = getPioneerPortfolioErrorMessage(e) console.warn(`[getBalance] ${chain.coin} portfolio failed:`, message) try { rpc.send['pioneer-error']({ message, url: getPioneerApiBase() }) } catch { /* webview not ready */ } @@ -4772,6 +4856,7 @@ const rpc = BrowserView.defineRPC({ buildTx: async (params) => { console.debug(`[buildTx] isMax=${params.isMax} chainId=${params.chainId}`) if (!engine.wallet) throw new Error('No device connected') + requireOnline('build transaction') const chain = getAllChains().find(c => c.id === params.chainId) if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) @@ -4787,7 +4872,8 @@ const rpc = BrowserView.defineRPC({ } } - const pioneer = await getPioneer() + const bitcoinSelfHost = chain.id === 'bitcoin' && getBtcBackend().kind !== 'pioneer' + const pioneer = bitcoinSelfHost ? {} : await getPioneer() // Seed-staleness boundary on the SIGNING path. params (xpubOverride, // evmAddressIndex, amount, recipient) were prepared against the UI's @@ -4989,6 +5075,7 @@ const rpc = BrowserView.defineRPC({ }, broadcastTx: async (params) => { + requireOnline('broadcast transaction') if (!params.signedTx) throw new Error('Missing signedTx payload') const chain = getAllChains().find(c => c.id === params.chainId) if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) @@ -5063,21 +5150,23 @@ const rpc = BrowserView.defineRPC({ }, getMarketData: async (params) => { + requireOnline('market data') const pioneer = await getPioneer() const resp = await withTimeout(pioneer.GetMarketInfo(params.caips), PIONEER_TIMEOUT_MS, 'GetMarketInfo') return resp?.data || [] }, getFees: async (params) => { + requireOnline('fee data') const chain = getAllChains().find(c => c.id === params.chainId) if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) - const pioneer = await getPioneer() if (chain.chainFamily === 'utxo') { // BTC self-host: fees from the node, not Pioneer. if (chain.networkId === 'bip122:000000000019d6689c085ae165831e93' && getBtcBackend().kind !== 'pioneer') { return { feeRate: await getBtcBackend().feeRate(chain.networkId), unit: 'sat/byte' } } + const pioneer = await getPioneer() // Same client-version fallback as btc-backend/pioneer.ts. const resp: any = await withTimeout( typeof pioneer.GetFeeRateByNetwork === 'function' @@ -5086,6 +5175,7 @@ const rpc = BrowserView.defineRPC({ PIONEER_TIMEOUT_MS, 'GetFeeRateByNetwork') return { feeRate: resp?.data, unit: 'sat/byte' } } else if (chain.chainFamily === 'evm') { + const pioneer = await getPioneer() const resp = await withTimeout(pioneer.GetGasPriceByNetwork({ networkId: chain.networkId }), PIONEER_TIMEOUT_MS, 'GetGasPriceByNetwork') return { gasPrice: resp?.data, unit: 'gwei' } } else { @@ -5352,35 +5442,34 @@ const rpc = BrowserView.defineRPC({ getBtcAddressIndices: async (params) => { const { xpub, scriptType } = params if (!xpub) throw new Error('xpub required') - const pioneer = await getPioneer() - let receiveIndex = 0 - let changeIndex = 0 + const backend = getBtcBackend() + if (!backend.addressIndices) { + const mode = backend.kind === 'core' ? 'Bitcoin Core scantxoutset' : 'offline mode' + return { + receiveIndex: 0, + changeIndex: 0, + discoveryAvailable: false, + source: backend.kind, + warning: `${mode} cannot prove which addresses were previously used. Index 0 is shown as a manual starting point; verify or select the intended index before receiving.`, + } + } try { const btcNetworkId = CHAINS.find(c => c.id === 'bitcoin')!.networkId - const resp = await withTimeout(pioneer.GetPubkeyInfo({ - network: btcNetworkId, - xpub: utxoDiscoveryKey(xpub, scriptType), - }), PIONEER_TIMEOUT_MS, 'GetPubkeyInfo') - const tokens = resp?.data?.tokens || [] - let maxReceive = -1 - let maxChange = -1 - for (const token of tokens) { - if (token.path && token.transfers > 0) { - const parts = token.path.split('/') - if (parts.length === 6) { - const idx = parseInt(parts[5], 10) - if (isNaN(idx)) continue - if (parts[4] === '0' && idx > maxReceive) maxReceive = idx - if (parts[4] === '1' && idx > maxChange) maxChange = idx - } - } - } - receiveIndex = maxReceive + 1 - changeIndex = maxChange + 1 + return await withTimeout( + backend.addressIndices({ network: btcNetworkId, xpub, scriptType }), + PIONEER_TIMEOUT_MS, + `${backend.kind} address discovery`, + ) } catch (e: any) { - console.warn('[getBtcAddressIndices] GetPubkeyInfo failed:', e.message) + console.warn(`[getBtcAddressIndices] ${backend.kind} discovery failed:`, e.message) + return { + receiveIndex: 0, + changeIndex: 0, + discoveryAvailable: false, + source: backend.kind, + warning: `Address history lookup failed. Index 0 is not proven unused; verify or select the intended index before receiving.`, + } } - return { receiveIndex, changeIndex } }, // ── EVM multi-address ──────────────────────────────────── @@ -5857,6 +5946,14 @@ const rpc = BrowserView.defineRPC({ auth.rejectPairing() }, approveSigningRequest: async (params) => { + if (isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) { + const rejection = bitcoinOnlyPendingSigningRejection(pendingSigningApprovalInfo.get(params.id)) + if (rejection) { + blindSigningApprovalIds.delete(params.id) + auth.rejectSigningRequest(params.id) + throw new Error(rejection) + } + } if (params.allowBlindSigning === true) blindSigningApprovalIds.add(params.id) if (!auth.approveSigningRequest(params.id)) { blindSigningApprovalIds.delete(params.id) @@ -5877,6 +5974,7 @@ const rpc = BrowserView.defineRPC({ // ── Mobile pairing (relay via vault.keepkey.com) ───────── generateMobilePairing: async () => { + requireOnline('mobile pairing') if (!engine.wallet) throw new Error('No device connected') const wallet = engine.wallet as any @@ -5893,7 +5991,8 @@ const rpc = BrowserView.defineRPC({ // Only use built-in CHAINS (not custom chains — those may lack rpc methods) const fwVersion = engine.getDeviceState().firmwareVersion - const builtinChains = CHAINS.filter(c => { + const bitcoinOnly = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) + const builtinChains = bitcoinOnlyChainList(CHAINS, bitcoinOnly).filter(c => { if (!isChainSupported(c, fwVersion)) return false // Zcash: gated by feature flag, not by hidden (hidden keeps it off Dashboard grid) if (c.id === 'zcash' || c.id === 'zcash-shielded') return zcashPrivacyEnabled @@ -6088,6 +6187,7 @@ const rpc = BrowserView.defineRPC({ return getAppSettings() }, setPioneerApiBase: async (params) => { + requireOnline('Pioneer server configuration') const url = (params.url || '').trim() if (url && !/^https?:\/\//i.test(url)) { throw new Error('URL must start with http:// or https://') @@ -6119,12 +6219,26 @@ const rpc = BrowserView.defineRPC({ offlineMode = params.enabled setSetting('offline_mode', params.enabled ? '1' : '0') setBtcBackendOffline(offlineMode) + setPioneerOffline(offlineMode) + setPerfTelemetryOffline(offlineMode) + engine.setOfflineMode(offlineMode) + if (offlineMode) { + pioneerSocket?.stop() + pioneerSocket = null + clearPioneerEventDebounce() + stopEventStream() + } else { + startPioneerSocketIfAllowed() + loadSupportedChains(getPioneerApiBase()).catch(() => { /* static fallback handles it */ }) + initSwapTrackerIfAllowed().catch((e) => console.warn('[swap-tracker] Reconnect init failed:', e.message || e)) + } console.log('[settings] Offline (airplane) mode:', params.enabled) return getAppSettings() }, // Self-host Bitcoin node. Persist url/auth/enabled and re-point the // BtcBackend. Empty url with enabled=false clears it → back to Pioneer. setBtcNode: async (params) => { + if (params.enabled) requireOnline('Bitcoin node configuration') // Only overwrite each credential when provided — lets the user toggle // enable without re-typing (undefined = keep existing). if (params.enabled && params.url) { @@ -6159,6 +6273,7 @@ const rpc = BrowserView.defineRPC({ // the type (Blockbook vs Core) so the user doesn't have to get the port right; // detectedType tells the UI which one actually answered. testBtcNode: async (params) => { + requireOnline('Bitcoin node test') const user = params.rpcUser !== undefined ? params.rpcUser : (getSetting('btc_node_rpc_user') || '') const pass = params.rpcPass !== undefined ? params.rpcPass : (getSetting('btc_node_rpc_pass') || '') const auth = user || pass ? `${user}:${pass}` : undefined @@ -6168,6 +6283,7 @@ const rpc = BrowserView.defineRPC({ // Live status of the ACTIVE self-host node (for the bottom status bar). // active:false when no node is enabled → the bar hides. getBtcNodeStatus: async () => { + requireOnline('Bitcoin node status') // Actual eligibility, not the raw persisted setting: a saved node is // suppressed on a multichain device (nodeActive gate), so the status bar // must show inactive there rather than probing + claiming "self-host active". @@ -6186,6 +6302,9 @@ const rpc = BrowserView.defineRPC({ return { active: true as const, kind: 'blockbook' as const, ok: r.ok, error: r.error, height: r.blocks, syncing: r.ok ? r.inSync === false : undefined } }, setWalletConnectEnabled: async (params) => { + if (params.enabled && isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) { + throw new Error('WalletConnect is not available on bitcoin-only firmware') + } walletConnectEnabled = params.enabled setSetting('walletconnect_enabled', params.enabled ? '1' : '0') console.log('[settings] WalletConnect enabled:', params.enabled) @@ -6268,6 +6387,7 @@ const rpc = BrowserView.defineRPC({ }, addPioneerServer: async (params) => { + requireOnline('Pioneer server test') const url = (params.url || '').trim().replace(/\/+$/, '') const label = (params.label || '').trim() if (!url || !/^https?:\/\//i.test(url)) throw new Error('URL must start with http:// or https://') @@ -6303,6 +6423,7 @@ const rpc = BrowserView.defineRPC({ return getAppSettings() }, setActivePioneerServer: async (params) => { + requireOnline('Pioneer server test') const url = (params.url || '').trim().replace(/\/+$/, '') if (!url) throw new Error('URL is required') // Verify the server exists in our list @@ -6339,10 +6460,13 @@ const rpc = BrowserView.defineRPC({ // ── API Audit Log ──────────────────────────────────────── getApiLogs: async (params) => { // PRIVACY: Don't expose standard-wallet activity logs during hidden sessions. - if (engine.isPassphraseWallet) return [] - const scope = getWalletDbScope() - if (!scope) return [] - return getApiLogs(params?.limit ?? 200, params?.offset ?? 0, scope.deviceId, scope.walletId) + if (engine.isPassphraseWallet) return [] + const scope = getWalletDbScope() + if (!scope) return [] + return bitcoinOnlyActivityList( + getApiLogs(params?.limit ?? 200, params?.offset ?? 0, scope.deviceId, scope.walletId), + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + ) }, clearApiLogs: async () => { const scope = getWalletDbScope() @@ -6363,11 +6487,12 @@ const rpc = BrowserView.defineRPC({ // (a write) is gated, since hidden sessions persist nothing new. if (!engine.isPassphraseWallet) { try { seedOwnFromCache() } catch { /* never block the read */ } } const labels = getDeviceLabelMap() + const bitcoinOnly = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) const networkId = params?.networkId const search = params?.search // own = every device's wallets (cross-device); external = all explicitly-saved // contacts (cross-wallet; R4 opt-in — history-only recipients stay hidden). - const own = getAddressBookList({ kind: 'own', networkId, search }) + const own = getAddressBookList({ kind: 'own', networkId, search, chainId: bitcoinOnly ? 'bitcoin' : undefined }) // ZEC own rows hold an xpub — swap in the derived index-0 t-addr so // they're actual send targets (other devices' wallets included). for (const e of own) { @@ -6376,7 +6501,7 @@ const rpc = BrowserView.defineRPC({ if (t) e.address = t } } - const external = getAddressBookList({ kind: 'external', networkId, search, savedOnly: true }) + const external = getAddressBookList({ kind: 'external', networkId, search, savedOnly: true, chainId: bitcoinOnly ? 'bitcoin' : undefined }) return [...own, ...external].map(e => ({ ...e, deviceLabel: labels[e.deviceId] || e.deviceLabel })) }, addAddressBook: async (params) => { @@ -6404,25 +6529,39 @@ const rpc = BrowserView.defineRPC({ try { rpc.send['addressbook-changed']({}) } catch { /* webview not ready */ } }, getAddressBookHistory: async (params) => { - return getAddressBookHistory(params.entryId, null) + return bitcoinOnlyAddressBookHistoryList( + getAddressBookHistory(params.entryId, null), + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + ) }, // ── Accounting ledger ──────────────────────────────────── getLedgerSummary: async () => { const deviceId = engine.getDeviceState().deviceId if (!deviceId) return [] - return getLedgerSummary(deviceId) + return bitcoinOnlyLedgerSummaryList( + getLedgerSummary(deviceId), + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + ) }, getLedgerJournals: async ({ limit }: { limit?: number }) => { const deviceId = engine.getDeviceState().deviceId if (!deviceId) return [] - return getLedgerJournals(deviceId, limit ?? 50) + return bitcoinOnlyLedgerJournalList( + getLedgerJournals(deviceId, limit ?? 50), + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + ) }, // ── Reports ───────────────────────────────────────────── generateReport: async () => { + requireOnline('report generation') const deviceId = engine.getDeviceState().deviceId if (!deviceId) throw new Error('No device connected') + const reportBackend = getBtcBackend() + if (reportBackend.kind !== 'pioneer') { + throw new Error(`Detailed BTC reports are unavailable with the ${reportBackend.kind} backend because Vault cannot silently use Pioneer for address history.`) + } // PRIVACY: Reports read from DB cache, which is intentionally empty // for passphrase wallets. Generating a report would either fail or @@ -6432,10 +6571,11 @@ const rpc = BrowserView.defineRPC({ } const reportId = `rpt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + const reportChain = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) ? 'bitcoin' : 'all' // Get cached balances for report data const cached = getCachedBalances(deviceId) - const balances = cached?.balances || [] + const balances = bitcoinOnlyBalanceList(cached?.balances || [], isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) if (balances.length === 0) { throw new Error('No cached balances available. Please refresh your portfolio first.') } @@ -6480,7 +6620,7 @@ const rpc = BrowserView.defineRPC({ const deviceLabel = engine.getDeviceState().label || 'KeepKey' // Save placeholder (lod=5 always) - saveReport(deviceId, reportId, 'all', 5, 0, 'generating', '{}') + saveReport(deviceId, reportId, reportChain, 5, 0, 'generating', '{}') // Send initial progress try { rpc.send['report-progress']({ id: reportId, message: 'Starting...', percent: 0 }) } catch {} @@ -6499,7 +6639,7 @@ const rpc = BrowserView.defineRPC({ const totalUsd = balances.reduce((s, b) => s + (b.balanceUsd || 0), 0) // M7: Only save final result if report wasn't deleted during generation if (reportExists(reportId)) { - saveReport(deviceId, reportId, 'all', 5, totalUsd, 'complete', JSON.stringify(reportData)) + saveReport(deviceId, reportId, reportChain, 5, totalUsd, 'complete', JSON.stringify(reportData)) } try { rpc.send['report-progress']({ id: reportId, message: 'Complete', percent: 100 }) } catch {} @@ -6514,7 +6654,7 @@ const rpc = BrowserView.defineRPC({ } catch (e: any) { // M9: Sanitize error messages — strip auth keys and URLs const safeMsg = e.message?.replace(/key:[^\s"',}]+/gi, 'key:***').replace(/https?:\/\/[^\s"',}]+/gi, '') || 'Report generation failed' - saveReport(deviceId, reportId, 'all', 5, 0, 'error', '{}', safeMsg) + saveReport(deviceId, reportId, reportChain, 5, 0, 'error', '{}', safeMsg) try { rpc.send['report-progress']({ id: reportId, message: `Error: ${safeMsg}`, percent: 100 }) } catch {} throw new Error(safeMsg) } @@ -6525,7 +6665,8 @@ const rpc = BrowserView.defineRPC({ if (engine.isPassphraseWallet) return [] const deviceId = engine.getDeviceState().deviceId if (!deviceId) return [] - return getReportsList(deviceId) + const bitcoinOnly = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) + return getReportsList(deviceId).filter(report => bitcoinOnlyReportAllowed(report.chain, bitcoinOnly)) }, // H1: Scope getReport/deleteReport to the current device @@ -6533,7 +6674,12 @@ const rpc = BrowserView.defineRPC({ if (engine.isPassphraseWallet) return null const deviceId = engine.getDeviceState().deviceId if (!deviceId) throw new Error('No device connected') - return getReportById(params.id, deviceId) + const report = getReportById(params.id, deviceId) + if (!report) return null + return bitcoinOnlyReportAllowed( + report.meta.chain, + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + ) ? report : null }, deleteReport: async (params) => { @@ -6549,6 +6695,9 @@ const rpc = BrowserView.defineRPC({ if (!deviceId) throw new Error('No device connected') const report = getReportById(params.id, deviceId) if (!report) throw new Error('Report not found') + if (!bitcoinOnlyReportAllowed(report.meta.chain, isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant))) { + throw new Error('This report contains full-firmware data and is unavailable while a Bitcoin-only device is connected.') + } const dateSuffix = new Date(report.meta.createdAt).toISOString().split('T')[0] const year = new Date(report.meta.createdAt).getFullYear() @@ -6600,6 +6749,7 @@ const rpc = BrowserView.defineRPC({ // ── Swap (quote cache for tracker) ────────────────────── getSwappableChainIds: async () => { + requireOnline('swap asset discovery') const { getSwapAssets } = await import('./swap') const assets = await getSwapAssets() const fw = engine.getDeviceState().firmwareVersion @@ -6612,13 +6762,18 @@ const rpc = BrowserView.defineRPC({ ) return [...chainIds] }, - getSwapAssets: async () => deviceSwapAssets(), + getSwapAssets: async () => { + requireOnline('swap asset discovery') + return deviceSwapAssets() + }, searchSwapAssets: async (params) => { + requireOnline('swap asset search') const { searchDiscoveryAssets } = await import('./swap') return searchDiscoveryAssets(params.query) }, getSwapHealth: async () => { + requireOnline('swap health') const base = await (await import('./pioneer')).getPioneerApiBase() try { const resp = await fetch(`${base}/api/v1/swap/health`, { signal: AbortSignal.timeout(8000) }) @@ -6844,6 +6999,7 @@ const rpc = BrowserView.defineRPC({ } }, refreshSwap: async (params) => { + requireOnline('swap refresh') // PRIVACY via scoping, NOT a blanket passphrase block. A hidden session // must still refresh ITS OWN in-memory swaps (they're skipPersist, so // the live poll is the only thing that can advance them to completed — @@ -6925,16 +7081,24 @@ const rpc = BrowserView.defineRPC({ // PRIVACY: Don't expose standard-wallet activity during hidden sessions. // Hidden sessions get the RAM-only session store instead (populated by // scanChainHistory's live fetch below) — display without persistence. - if (engine.isPassphraseWallet) return relabelZcashShieldedRows(getSessionActivity(params?.limit || 50, params?.chainId)) + if (engine.isPassphraseWallet) { + const rows = relabelZcashShieldedRows(getSessionActivity(params?.limit || 50, params?.chainId)) + return bitcoinOnlyActivityList(rows, isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) + } const scope = getWalletDbScope() if (!scope) return [] - return relabelZcashShieldedRows(getRecentActivityFromLog(params?.limit || 50, params?.chainId, scope.deviceId, scope.walletId)) + const rows = relabelZcashShieldedRows(getRecentActivityFromLog(params?.limit || 50, params?.chainId, scope.deviceId, scope.walletId)) + return bitcoinOnlyActivityList(rows, isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)) }, getActivityScanState: async () => ({ running: activityScanRunning }), scanChainHistory: async (params) => { + requireOnline('activity history') const chain = getAllChains().find(c => c.id === params.chainId) if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) if (!engine.wallet) throw new Error('No device connected') + if (chain.id === 'bitcoin' && getBtcBackend().kind !== 'pioneer') { + throw new Error(`Bitcoin history is unavailable with the ${getBtcBackend().kind} backend; Vault will not fall back to Pioneer.`) + } // PRIVACY: hidden sessions never write api_log — but the server lookup // only needs an address. Fetch the same Pioneer history live (dryRun) @@ -6948,7 +7112,7 @@ const rpc = BrowserView.defineRPC({ const result = await rebuildActivityHistory({ wallet: engine.wallet, scope, - chains: getAllChains().filter(c => c.id !== 'hive' || hiveEnabled), + chains: bitcoinOnlyChainList(getAllChains(), isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)).filter(c => c.id !== 'hive' || hiveEnabled), firmwareVersion: engine.getDeviceState().firmwareVersion, options: { chainId: params.chainId, dryRun: true, collectRows: true }, }) @@ -6964,7 +7128,7 @@ const rpc = BrowserView.defineRPC({ const result = await rebuildActivityHistory({ wallet: engine.wallet, scope, - chains: getAllChains().filter(c => c.id !== 'hive' || hiveEnabled), + chains: bitcoinOnlyChainList(getAllChains(), isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant)).filter(c => c.id !== 'hive' || hiveEnabled), firmwareVersion: engine.getDeviceState().firmwareVersion, options: { chainId: params.chainId }, }) @@ -7001,7 +7165,8 @@ const rpc = BrowserView.defineRPC({ // - other hidden chains stay internal-only // Without this, freshly-enabled chains are never flagged as missing and // the dashboard never auto-refreshes them into the cache. - const supportedChains = getAllChains().filter(c => { + const bitcoinOnly = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) + const supportedChains = bitcoinOnlyChainList(getAllChains(), bitcoinOnly).filter(c => { if (!isChainSupported(c, fwVersion)) return false if (c.id === 'zcash-shielded') return false if (c.id === 'zcash') return zcashPrivacyEnabled @@ -7037,7 +7202,7 @@ const rpc = BrowserView.defineRPC({ // Stale cache from a prior 7.14+ session can contain Solana/TRON/TON // entries — without this filter they bleed into the swap FROM picker, // letting the user select them and then hitting a device signing error. - const filteredBalances = result.balances.filter(b => { + const filteredBalances = bitcoinOnlyBalanceList(result.balances, bitcoinOnly).filter(b => { const chain = getAllChains().find(c => c.id === b.chainId) if (!chain) return true // keep unknowns (tokens) if (chain.id === 'hive' && !hiveEnabled) return false // honor feature flag — drop stale Hive rows @@ -7059,7 +7224,11 @@ const rpc = BrowserView.defineRPC({ : getLatestDeviceSnapshot() if (!snap) return null const result = getCachedBalances(snap.deviceId) - return result?.balances ?? null + const bitcoinOnly = bitcoinOnlyWatchOnlyScope( + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + snap.featuresJson, + ) + return result ? bitcoinOnlyBalanceList(result.balances, bitcoinOnly) : null }, // Re-fetch watch-only balances from Pioneer using addresses reconstructed // from cache — NO device required. Self-contained: deliberately does NOT @@ -7068,6 +7237,7 @@ const rpc = BrowserView.defineRPC({ // no addressbook sync (those need device-derived state). Good enough for a // read-only snapshot view; do not use this path for signing/sends. refreshWatchOnlyBalances: async (params) => { + requireOnline('watch-only balance refresh') const { getDeviceSnapshotById } = await import('./db') const snap = params?.deviceId ? getDeviceSnapshotById(params.deviceId) @@ -7076,12 +7246,16 @@ const rpc = BrowserView.defineRPC({ const deviceId = snap.deviceId // 1. Reconstruct the pubkey list from cache (no device available) - const allChains = getAllChains() + const snapshotBitcoinOnly = bitcoinOnlyWatchOnlyScope( + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + snap.featuresJson, + ) + const allChains = bitcoinOnlyChainList(getAllChains(), snapshotBitcoinOnly) const chainById = new Map(allChains.map(c => [c.id, c])) const pubkeys: Array<{ caip: string; pubkey: string; sourcePubkey?: string; chainId: string; symbol: string; networkId: string }> = [] const cached = getCachedBalances(deviceId) - for (const b of cached?.balances ?? []) { + for (const b of bitcoinOnlyBalanceList(cached?.balances ?? [], snapshotBitcoinOnly)) { if (b.chainId === 'bitcoin') continue // cached BTC address isn't the xpub — handled below if (!b.address) continue const chain = chainById.get(b.chainId) @@ -7249,7 +7423,11 @@ const rpc = BrowserView.defineRPC({ ? getDeviceSnapshotById(params.deviceId) : getLatestDeviceSnapshot() if (!snap) return [] - return getCachedPubkeys(snap.deviceId) + const pubkeys = getCachedPubkeys(snap.deviceId) + return bitcoinOnlyWatchOnlyScope( + isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant), + snap.featuresJson, + ) ? pubkeys.filter(p => p.chainId === 'bitcoin') : pubkeys }, // ── Registered devices (device history) ───────────────── @@ -7264,6 +7442,7 @@ const rpc = BrowserView.defineRPC({ // ── Sweep (non-standard BTC path recovery) ────────────── sweepScan: async (params) => { + requireOnline('sweep scan') if (!engine.wallet) throw new Error('No device connected') const { startScan, getScan } = await import('./sweep-engine') // Capture the signing guard BEFORE starting the scan worker — USB is @@ -7369,11 +7548,13 @@ const rpc = BrowserView.defineRPC({ // ── Balance Audit (multi-chain "where's my money" wizard) ──── auditStart: async (params) => { + requireOnline('balance audit') if (!engine.wallet) throw new Error('No device connected') if (engine.getDeviceState().state !== 'ready') throw new Error('Device not ready') const wallet = engine.wallet const fwVersion = engine.getDeviceState().firmwareVersion - const enabledChains = getAllChains().filter(c => { + const bitcoinOnly = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) + const enabledChains = bitcoinOnlyChainList(getAllChains(), bitcoinOnly).filter(c => { if (!isChainSupported(c, fwVersion)) return false if ((c.id === 'zcash' || c.id === 'zcash-shielded') && !zcashPrivacyEnabled) return false if (c.id === 'hive' && !hiveEnabled) return false @@ -8211,6 +8392,7 @@ udevadm trigger --subsystem-match=usb --attr-match=idVendor=2b24 || udevadm trig // ── App Updates ────────────────────────────────────────── checkForUpdate: async () => { + requireOnline('update check') const localVer = await Updater.localInfo.version() // Always use GitHub API to check for updates. @@ -8263,6 +8445,7 @@ udevadm trigger --subsystem-match=usb --attr-match=idVendor=2b24 || udevadm trig } }, downloadUpdate: async () => { + requireOnline('app update download') if (process.platform === 'win32' || process.platform === 'darwin') { openUpdatePage() return @@ -8288,6 +8471,7 @@ udevadm trigger --subsystem-match=usb --attr-match=idVendor=2b24 || udevadm trig // a thrown fetch (DNS/socket/timeout) = offline. Skipped by the client // when offline mode is on, so this never fires in airplane mode. pingPioneer: async () => { + if (offlineMode) return { online: false } try { await fetch(getPioneerApiBase(), { method: 'HEAD', signal: AbortSignal.timeout(4000) }) return { online: true } @@ -8312,8 +8496,8 @@ udevadm trigger --subsystem-match=usb --attr-match=idVendor=2b24 || udevadm trig windowGetFrame: async () => { if (!_mainWindow) throw new Error('Window not ready'); return _mainWindow.getFrame() }, windowSetPosition: async ({ x, y }) => { _mainWindow?.setPosition(x, y) }, windowSetFrame: async ({ x, y, width, height }) => { _mainWindow?.setFrame(x, y, width, height) }, - }, - messages: {}, + }), + messages: {}, }, }) @@ -8333,14 +8517,75 @@ sendFatal = (source, err) => { // would TDZ on this binding if it were declared near the URL handler. let pendingDeepLinkUri: string | null = null +function onPioneerSocketEvent(event: string, data: unknown): void { + // ONLY 'transaction:incoming'. Balance-update events echo Vault's own queries + // and would create a self-sustaining refresh loop. + if (event !== 'transaction:incoming' || offlineMode) return + let d: any = data + if (typeof d === 'string') { + try { d = JSON.parse(d) } catch { return } + } + const address = d?.address ?? d?.pubkey ?? undefined + const txid = d?.txid ?? d?.tx?.txid ?? undefined + if (txidRecentlyPushed(txid)) return + const rawType = d?.type + const type = rawType === 'incoming' || rawType === 'outgoing' ? rawType + : rawType === 'confirmation_update' ? 'confirmed' as const + : undefined + const allChains = [...CHAINS, ...customChainDefs] + let chain: string | undefined + for (const cand of [d?.caip, d?.networkId, d?.chain]) { + if (typeof cand !== 'string' || !cand) continue + if (cand.includes('/')) { chain = cand; break } + const def = allChains.find(c => c.networkId === cand || c.id === cand) + if (def) { chain = def.caip; break } + } + if (!chain) return + const networkId = chain.split('/')[0] + const pending = pioneerEventDebounce.get(networkId) + if (pending) clearTimeout(pending.timer) + const mergedType = pending?.payload.type === 'incoming' ? 'incoming' : type + const payload = { chain, address, txid, type: mergedType } + pioneerEventDebounce.set(networkId, { payload, timer: setTimeout(() => { + pioneerEventDebounce.delete(networkId) + if (offlineMode) return + console.log(`[PioneerSocket] push event '${event}' chain=${chain} type=${mergedType} → forwarding`) + try { rpc.send['tx-push-received'](payload) } catch { /* webview not ready */ } + }, 2000) }) +} + +function startPioneerSocketIfAllowed(): void { + if (offlineMode || pioneerSocket || engine.getDeviceState().state !== 'ready') return + pioneerSocket = new PioneerSocket({ + queryKey: getPioneerQueryKey(), + onEvent: onPioneerSocketEvent, + onConnect: () => console.log('[PioneerSocket] connected to Pioneer'), + onDisconnect: () => console.log('[PioneerSocket] disconnected from Pioneer'), + }) + pioneerSocket.start() +} + // Push engine events to WebView engine.on('state-change', (state) => { try { rpc.send['device-state'](state) } catch { /* webview not ready yet */ } + const bitcoinOnly = isBitcoinOnlyVariant(state.firmwareVariant) // Scope the self-host node to the connected device: only a btc-only device may // use the (global) persisted node. A multichain device — which can't even see // the node control to disable it — keeps its BTC on Pioneer. Absent variant // (connecting/disconnected) → suppressed. - setBtcNodeDeviceEligible(isBitcoinOnlyVariant(state.firmwareVariant)) + setBtcNodeDeviceEligible(bitcoinOnly) + // Hiding WalletConnect is not enough: a persisted session remains capable of + // delivering signing requests. Drop every live session on the transition to + // Bitcoin-only and discard queued deep links. Device callbacks independently + // re-check the variant so the asynchronous teardown has no signing window. + if (bitcoinOnly) { + pendingDeepLinkUri = null + if (wcManager) { + const staleManager = wcManager + wcManager = null + staleManager.destroy().catch(e => console.warn('[WC] bitcoin-only shutdown failed:', e?.message || e)) + } + } // Device-to-device swap: a *different* device just reached 'ready'. Reset the // in-memory account managers so device B re-derives its own xpubs/addresses // instead of reusing device A's (the existing `if (!isInitialized)` guards @@ -8353,8 +8598,11 @@ engine.on('state-change', (state) => { // cold-start race that sank the prior attempt. We do NOT clear DB caches here // (they are deviceId-scoped and self-correct); that stays exclusive to the // seed-changed handler. See src/shared/device-switch.ts. - if (shouldResetManagersOnReady(state, lastReadyDeviceId)) { - console.warn(`[Vault] Device swap ${lastReadyDeviceId} → ${state.deviceId}: resetting in-memory account managers`) + const deviceChanged = shouldResetManagersOnReady(state, lastReadyDeviceId) + const variantChanged = state.state === 'ready' && !!state.firmwareVariant + && !!lastReadyFirmwareVariant && state.firmwareVariant !== lastReadyFirmwareVariant + if (deviceChanged || variantChanged) { + console.warn(`[Vault] Device/firmware identity changed (${lastReadyDeviceId}/${lastReadyFirmwareVariant} → ${state.deviceId}/${state.firmwareVariant}): resetting in-memory account managers`) resetSeedManagers() // Device-to-device swap is exactly the case `seed-changed` does NOT catch. // Force Zcash to re-prove the cached FVK against the NEW device before any @@ -8371,6 +8619,7 @@ engine.on('state-change', (state) => { try { rpc.send['wallet-data-purged']({ reason: 'device-swap' }) } catch { /* webview not ready yet */ } } lastReadyDeviceId = nextReadyDeviceId(state, lastReadyDeviceId) + if (state.state === 'ready' && state.firmwareVariant) lastReadyFirmwareVariant = state.firmwareVariant // Seed-staleness guard (event-driven leg): once the engine has classified the // session and derived the seed identity (checkSeedIdentity / hidden-wallet // scope derive / reconnect probe — all re-emit state-change after setting it), @@ -8385,7 +8634,7 @@ engine.on('state-change', (state) => { // Replay any WC deep link that was queued while no device was connected. // Without this, a deep link delivered before the device was ready would // sit in pendingDeepLinkUri until the next mount of WalletConnectPanel. - if (state.state === 'ready' && pendingDeepLinkUri && walletConnectEnabled) { + if (state.state === 'ready' && pendingDeepLinkUri && walletConnectEnabled && !bitcoinOnly) { const uri = pendingDeepLinkUri pendingDeepLinkUri = null try { rpc.send['wc-deep-link-pair']({ uri }) } @@ -8403,7 +8652,7 @@ engine.on('state-change', (state) => { // connected device runs firmware >= 7.15.0, OFF otherwise. The setting // row is kept as a mirror of the derived value so the getSetting() gates // in rest-api.ts keep reading the same answer from one source. - const has715 = !!fw && versionCompare(fw, '7.15.0') >= 0 + const has715 = !bitcoinOnly && !!fw && versionCompare(fw, '7.15.0') >= 0 if (zcashPrivacyEnabled !== has715) { zcashPrivacyEnabled = has715 setSetting('zcash_privacy_enabled', has715 ? '1' : '0') @@ -8420,83 +8669,21 @@ engine.on('state-change', (state) => { console.log(`[settings] Hive auto-${has715 ? 'enabled' : 'disabled'} — firmware ${fw || 'unknown'}`) } } - if (state.state === 'ready' && !pioneerSocket) { - pioneerSocket = new PioneerSocket({ - queryKey: getPioneerQueryKey(), - onEvent: (event, data) => { - // ONLY 'transaction:incoming'. Pioneer also emits 'balance:update' / - // 'balance:cache:update' — but those fire from INSIDE its - // GetPortfolioBalances controller, per pubkey, to the requesting - // user's own socket (balance.controller.ts:776/788/798, :974): they - // are echoes of the vault's own queries, not background-worker - // signals. Consuming them creates a self-sustaining refresh loop - // (each getBalance → echo → getBalance, with device USB traffic per - // cycle). Re-add only after Pioneer separates genuine worker pushes - // from per-request emits — see docs/handoff-pioneer-hive-push-refresh.md. - if (event !== 'transaction:incoming') return - // Some payloads arrive JSON-STRINGIFIED (server emit style varies) — - // parse both shapes. - let d: any = data - if (typeof d === 'string') { - try { d = JSON.parse(d) } catch { return } - } - const address = d?.address ?? d?.pubkey ?? undefined - const txid = d?.txid ?? d?.tx?.txid ?? undefined - // The SSE leg usually delivers the same tx first — don't double-toast - // or double-fetch it. - if (txidRecentlyPushed(txid)) return - // The server reuses the 'transaction:incoming' event name for - // confirmation updates; map its payload type into the schema union. - const rawType = d?.type - const type = rawType === 'incoming' || rawType === 'outgoing' ? rawType - : rawType === 'confirmation_update' ? 'confirmed' as const - : undefined - // Normalize to a canonical CAIP-19 string. Prefer explicit caip, then - // networkId — the server's 'chain' field is symbol-ish ("ETH") and - // ambiguous (ETH = Ethereum, Arbitrum, Optimism, Base), so it's only - // consulted for CAIP/id-shaped values, never as a symbol. - const allChains = [...CHAINS, ...customChainDefs] - let chain: string | undefined - for (const cand of [d?.caip, d?.networkId, d?.chain]) { - if (typeof cand !== 'string' || !cand) continue - if (cand.includes('/')) { chain = cand; break } // already CAIP-19 - // CAIP-2 (networkId like "eip155:1") or internal id like "ethereum" - const def = allChains.find(c => c.networkId === cand || c.id === cand) - if (def) { chain = def.caip; break } - } - if (!chain) return - // Debounce per network (CAIP-2 prefix) so rapid-fire events on the same - // network collapse into one refresh. When replacing a pending forward, - // keep 'incoming' if either had it — a confirmation update arriving in - // the window must not eat the incoming-payment toast. - const networkId = chain.split('/')[0] - const pending = pioneerEventDebounce.get(networkId) - if (pending) clearTimeout(pending.timer) - const mergedType = pending?.payload.type === 'incoming' ? 'incoming' : type - const payload = { chain, address, txid, type: mergedType } - pioneerEventDebounce.set(networkId, { payload, timer: setTimeout(() => { - pioneerEventDebounce.delete(networkId) - console.log(`[PioneerSocket] push event '${event}' chain=${chain} type=${mergedType} → forwarding`) - try { rpc.send['tx-push-received'](payload) } catch { /* webview not ready */ } - }, 2000) }) - }, - onConnect: () => console.log('[PioneerSocket] connected to Pioneer'), - onDisconnect: () => console.log('[PioneerSocket] disconnected from Pioneer'), - }) - pioneerSocket.start() - } - if (state.state === 'ready' && !engine.isPassphraseWallet) { + if (state.state === 'ready') startPioneerSocketIfAllowed() + const btcOnlySelfHost = isBitcoinOnlyVariant(state.firmwareVariant) && getBtcBackend().kind !== 'pioneer' + if (state.state === 'ready' && !engine.isPassphraseWallet && !offlineMode && !btcOnlySelfHost) { // Fire-and-forget background history scan on every ready transition (startup + reconnect). // 3s delay lets wallet address derivation settle before hitting Pioneer. activityScanRunning = true setTimeout(() => { + if (offlineMode) { activityScanRunning = false; return } const scope = getWalletDbScope() if (!scope || !engine.wallet) { activityScanRunning = false; return } console.log('[activity] Auto-scanning history on device ready...') rebuildActivityHistory({ wallet: engine.wallet, scope, - chains: getAllChains().filter(c => c.id !== 'hive' || hiveEnabled), + chains: bitcoinOnlyChainList(getAllChains(), bitcoinOnly).filter(c => c.id !== 'hive' || hiveEnabled), firmwareVersion: engine.getDeviceState().firmwareVersion, }).then(result => { console.log(`[activity] Auto-scan complete: ${result.totals.inserted} new txs across ${result.totals.chains} chains`) @@ -8827,8 +9014,9 @@ Updater.localInfo.version().then(v => { appVersionCache = v }).catch(() => {}) // - Windows: no update.json is published, so Electrobun check always 404s // - macOS: update.json version is stale (generated before release is published) Updater.localInfo.channel().then(ch => { - if (ch !== 'dev') { + if (ch !== 'dev' && !offlineMode) { setTimeout(async () => { + if (offlineMode) return try { const localVer = await Updater.localInfo.version() if (!localVer) return @@ -8935,7 +9123,11 @@ function handleKeepKeyUrl(url: string) { console.log('[Vault] URL handler:', url) const wcUri = getWalletConnectUri(url) if (wcUri) { - if (walletConnectEnabled && engine.wallet) { + const bitcoinOnly = isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) + if (bitcoinOnly) { + pendingDeepLinkUri = null + try { rpc.send['walletconnect-uri'](wcUri) } catch {} + } else if (walletConnectEnabled && engine.wallet) { // Hand the URI to the panel so it mounts *before* the WC // session_proposal arrives. The pair-approval modal lives inside // WalletConnectPanel; pairing directly from here while the panel diff --git a/projects/keepkey-vault/src/bun/offline-policy.test.ts b/projects/keepkey-vault/src/bun/offline-policy.test.ts new file mode 100644 index 00000000..15f8beb3 --- /dev/null +++ b/projects/keepkey-vault/src/bun/offline-policy.test.ts @@ -0,0 +1,27 @@ +import { assertOnline, isOfflineNetworkRoute } from './offline-policy' + +let pass = 0 +function ok(value: boolean, label: string) { + if (!value) throw new Error(`FAIL ${label}`) + pass++ +} + +for (const path of [ + '/api/v2/portfolio/balances', + '/api/v2/tx/broadcast', + '/api/v2/swap/quote', + '/api/v2/sweep/scan', +]) ok(isOfflineNetworkRoute(path, 'POST'), `${path} is blocked offline`) + +ok(isOfflineNetworkRoute('/api/v1/activity/rebuild', 'POST'), 'activity rebuild blocked offline') +ok(!isOfflineNetworkRoute('/api/v1/activity/rebuild', 'GET'), 'activity read is not classified as a network mutation') +ok(!isOfflineNetworkRoute('/api/v2/devices', 'GET'), 'device inventory remains local') +ok(!isOfflineNetworkRoute('/api/v1/btc/sign-transaction', 'POST'), 'raw device signing remains available') + +assertOnline(false, 'test') +pass++ +let threw = false +try { assertOnline(true, 'test') } catch (error: any) { threw = /OFFLINE.*test/.test(error.message) } +ok(threw, 'offline operation throws a typed, actionable error') + +console.log(`[offline-policy] OK — ${pass} assertions passed`) diff --git a/projects/keepkey-vault/src/bun/offline-policy.ts b/projects/keepkey-vault/src/bun/offline-policy.ts new file mode 100644 index 00000000..6a38fcee --- /dev/null +++ b/projects/keepkey-vault/src/bun/offline-policy.ts @@ -0,0 +1,14 @@ +export const OFFLINE_OPERATION_ERROR = + 'OFFLINE: Vault is in offline (airplane) mode — network balance, history, build, and broadcast operations are disabled.' + +export function assertOnline(offline: boolean, operation: string): void { + if (offline) throw new Error(`${OFFLINE_OPERATION_ERROR} (${operation})`) +} + +/** REST surfaces that can initiate outbound traffic. Device metadata routes and + * raw v1 device signing routes are intentionally absent. */ +export function isOfflineNetworkRoute(path: string, method: string): boolean { + if (path === '/api/v1/activity/rebuild' && method === 'POST') return true + if (!path.startsWith('/api/v2/')) return false + return !path.startsWith('/api/v2/devices') +} diff --git a/projects/keepkey-vault/src/bun/perf-telemetry-offline.test.ts b/projects/keepkey-vault/src/bun/perf-telemetry-offline.test.ts new file mode 100644 index 00000000..82eed13d --- /dev/null +++ b/projects/keepkey-vault/src/bun/perf-telemetry-offline.test.ts @@ -0,0 +1,32 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { buildRecord, flush, instrumentPortfolio, pushRecord, setPerfTelemetryOffline } from './perf-telemetry' + +const originalFetch = globalThis.fetch +let fetches = 0 + +afterAll(() => { + setPerfTelemetryOffline(true) + globalThis.fetch = originalFetch +}) + +describe('performance telemetry offline policy', () => { + test('buffered telemetry cannot flush while offline and resumes online', async () => { + globalThis.fetch = (async () => { + fetches++ + return new Response('{}', { status: 200 }) + }) as typeof fetch + + setPerfTelemetryOffline(true) + instrumentPortfolio({ GetPortfolioBalances: async () => ({ data: {} }) }, { + apiBase: 'https://telemetry.invalid', + queryKey: 'test-key', + }) + pushRecord(buildRecord({ clientTotalMs: 1 })) + await flush() + expect(fetches).toBe(0) + + setPerfTelemetryOffline(false) + await flush() + expect(fetches).toBe(1) + }) +}) diff --git a/projects/keepkey-vault/src/bun/perf-telemetry.ts b/projects/keepkey-vault/src/bun/perf-telemetry.ts index bcb801fa..04bbf2e6 100644 --- a/projects/keepkey-vault/src/bun/perf-telemetry.ts +++ b/projects/keepkey-vault/src/bun/perf-telemetry.ts @@ -90,6 +90,23 @@ export function buildRecord(opts: { let cfg: { apiBase: string; queryKey: string } | null = null const buffer: PerfRecord[] = [] let flushTimer: ReturnType | null = null +let offline = false + +function startFlushTimer(): void { + if (offline || flushTimer || !cfg) return + flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS) + if (typeof (flushTimer as any).unref === 'function') (flushTimer as any).unref() +} + +export function setPerfTelemetryOffline(value: boolean): void { + offline = value + if (offline && flushTimer) { + clearInterval(flushTimer) + flushTimer = null + } else if (!offline) { + startFlushTimer() + } +} export function pushRecord(rec: PerfRecord): void { buffer.push(rec) @@ -105,7 +122,7 @@ export function recentPerfRecords(n = 20): PerfRecord[] { let flushInFlight = false export async function flush(): Promise { - if (!cfg || buffer.length === 0 || flushInFlight) return + if (offline || !cfg || buffer.length === 0 || flushInFlight) return flushInFlight = true const records = buffer.slice() // clear only on success — a failed flush retries next cycle try { @@ -129,10 +146,7 @@ export async function flush(): Promise { */ export function instrumentPortfolio(client: any, options: { apiBase: string; queryKey: string }): void { cfg = options - if (!flushTimer) { - flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS) - if (typeof (flushTimer as any).unref === 'function') (flushTimer as any).unref() - } + startFlushTimer() if (typeof client?.GetPortfolioBalances !== 'function' || client.__perfInstrumented) return const orig = client.GetPortfolioBalances.bind(client) client.GetPortfolioBalances = async (...args: any[]) => { diff --git a/projects/keepkey-vault/src/bun/pioneer-guard.test.ts b/projects/keepkey-vault/src/bun/pioneer-guard.test.ts index 9604e49a..de0968be 100644 --- a/projects/keepkey-vault/src/bun/pioneer-guard.test.ts +++ b/projects/keepkey-vault/src/bun/pioneer-guard.test.ts @@ -6,6 +6,7 @@ import { installPioneerGuard, setPioneerGuardActive } from './pioneer-guard' const BTC = 'bip122:000000000019d6689c085ae165831e93' +const BTC_TESTNET = 'bip122:000000000933ea01ad0ee984209779ba' const LTC = 'bip122:12a765e31ffd4059bada1e25190f6e98' let pass = 0 function ok(c: boolean, msg: string) { if (!c) throw new Error(`FAIL ${msg}`); pass++ } @@ -16,8 +17,13 @@ const client: any = { ListUnspent: (a: any) => ({ called: 'ListUnspent', a }), GetFeeRateByNetwork: (a: any) => ({ called: 'fee', a }), Broadcast: (a: any) => ({ called: 'Broadcast', a }), - GetMarketInfo: (a: any) => ({ called: 'price', a }), // NOT guarded — price exception - GetPubkeyInfo: (a: any) => ({ called: 'pubkey', a }), // NOT guarded — address discovery + GetMarketInfo: (a: any) => ({ called: 'price', a }), + GetPubkeyInfo: (a: any) => ({ called: 'pubkey', a }), + GetTransactionHistory: (a: any) => ({ called: 'history', a }), + GetPortfolioBalances: (a: any) => ({ called: 'portfolio', a }), + GetBalanceAddressByNetwork: (a: any) => ({ called: 'balance-address', a }), + LookupUtxoTx: (a: any) => ({ called: 'lookup-current', a }), + UtxoLookup: (a: any) => ({ called: 'lookup', a }), } installPioneerGuard(client) @@ -26,14 +32,23 @@ setPioneerGuardActive(false) ok(client.ListUnspent({ network: BTC }).called === 'ListUnspent', 'inactive: BTC ListUnspent passes') ok(client.Broadcast({ networkId: BTC }).called === 'Broadcast', 'inactive: BTC Broadcast passes') -// Self-host on: BTC money calls throw, other coins + price/pubkey pass. +// Self-host on: every BTC chain-data call throws; other coins + price pass. setPioneerGuardActive(true) ok(threw(() => client.ListUnspent({ network: BTC })), 'active: BTC ListUnspent blocked') +ok(threw(() => client.ListUnspent({ network: BTC_TESTNET })), 'active: BTC testnet ListUnspent blocked') ok(threw(() => client.GetFeeRateByNetwork({ networkId: BTC })), 'active: BTC fee blocked') ok(threw(() => client.Broadcast({ networkId: BTC })), 'active: BTC Broadcast blocked') ok(client.ListUnspent({ network: LTC }).called === 'ListUnspent', 'active: LTC ListUnspent passes') ok(client.GetMarketInfo([BTC]).called === 'price', 'active: price (GetMarketInfo) passes') -ok(client.GetPubkeyInfo({ network: BTC }).called === 'pubkey', 'active: BTC GetPubkeyInfo passes (not guarded)') +ok(threw(() => client.GetPubkeyInfo({ network: BTC })), 'active: BTC GetPubkeyInfo blocked') +ok(threw(() => client.GetTransactionHistory({ network: BTC })), 'active: BTC history blocked') +ok(threw(() => client.GetTransactionHistory({ queries: [{ caip: `${BTC}/slip44:0`, pubkey: 'xpub' }] })), 'active: nested BTC history blocked') +ok(threw(() => client.GetTransactionHistory({ queries: [{ caip: `${BTC_TESTNET}/slip44:1`, pubkey: 'tpub' }] })), 'active: nested BTC testnet history blocked') +ok(threw(() => client.GetPortfolioBalances({ pubkeys: [{ caip: `${BTC}/slip44:0`, pubkey: 'xpub' }] })), 'active: BTC portfolio blocked') +ok(threw(() => client.GetBalanceAddressByNetwork({ networkId: BTC, address: 'bc1q' })), 'active: BTC address balance blocked') +ok(threw(() => client.LookupUtxoTx({ networkId: BTC, txid: '00' })), 'active: BTC current raw lookup blocked') +ok(threw(() => client.UtxoLookup({ networkId: BTC, txid: '00' })), 'active: BTC raw lookup blocked') +ok(client.GetPubkeyInfo({ network: LTC }).called === 'pubkey', 'active: LTC GetPubkeyInfo passes') // Idempotent install must not double-wrap. installPioneerGuard(client) diff --git a/projects/keepkey-vault/src/bun/pioneer-guard.ts b/projects/keepkey-vault/src/bun/pioneer-guard.ts index 12e1d9bf..7c808de5 100644 --- a/projects/keepkey-vault/src/bun/pioneer-guard.ts +++ b/projects/keepkey-vault/src/bun/pioneer-guard.ts @@ -5,20 +5,26 @@ * loudly instead of silently "cheating" back to Pioneer. * * Scope: only Bitcoin (matched by networkId in the call args). Other UTXO coins - * (LTC/DOGE/BCH/Dash/Zcash) and price/history (GetMarketInfo/GetTransactionHistory — - * the documented Task-2/3 exceptions) pass through untouched. + * (LTC/DOGE/BCH/Dash/Zcash) and price data pass through untouched. * * Leaf module (no imports) so both pioneer.ts and btc-backend/index.ts can use it * without an import cycle. */ -const BTC_NETWORK_ID = 'bip122:000000000019d6689c085ae165831e93' +const BTC_NETWORK_IDS = new Set([ + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:000000000933ea01ad0ee984209779ba', +]) // Pioneer methods FULLY replaced by the BtcBackend seam — forbidden for BTC when a -// node is on. Deliberately excludes GetPubkeyInfo: the send path already skips it -// (change index is derived from UTXOs), but receive-address discovery + reports still -// use it for BTC and have no node equivalent yet (Task 3), so blocking it globally -// would break those. The money path (UTXOs/fees/broadcast) is what must never cheat. -const GUARDED = ['ListUnspent', 'GetFeeRateByNetwork', 'GetFeeRate', 'Broadcast'] +// node is on. Address discovery and history are included: Bitcoin Core cannot +// answer them from scantxoutset, and silently consulting Pioneer would make the +// self-host/offline privacy claim false. Blockbook supplies its own xpub-native +// discovery through BtcBackend instead. +const GUARDED = [ + 'ListUnspent', 'GetFeeRateByNetwork', 'GetFeeRate', 'Broadcast', + 'GetPubkeyInfo', 'GetTransactionHistory', 'GetPortfolioBalances', + 'GetBalanceAddressByNetwork', 'LookupUtxoTx', 'UtxoLookup', +] let active = false /** Set from btc-backend when the node/offline state changes. */ @@ -28,7 +34,12 @@ export function setPioneerGuardActive(v: boolean): void { } function isBtcArg(arg: any): boolean { - return (arg?.network ?? arg?.networkId) === BTC_NETWORK_ID + const network = arg?.network ?? arg?.networkId ?? arg?.caip + if (typeof network === 'string' && [...BTC_NETWORK_IDS].some(id => network === id || network.startsWith(`${id}/`))) return true + for (const list of [arg?.queries, arg?.pubkeys]) { + if (Array.isArray(list) && list.some((entry: any) => isBtcArg(entry))) return true + } + return false } /** Patch the client's guarded methods in place (idempotent — safe to call repeatedly). */ diff --git a/projects/keepkey-vault/src/bun/pioneer.ts b/projects/keepkey-vault/src/bun/pioneer.ts index 0220f247..57a40dcd 100644 --- a/projects/keepkey-vault/src/bun/pioneer.ts +++ b/projects/keepkey-vault/src/bun/pioneer.ts @@ -29,6 +29,11 @@ const MIN_RETRY_DELAY = 5000 // 5s minimum between init retries let pioneerInstance: any = null let initPromise: Promise | null = null let lastInitAttempt = 0 +let offline = false + +export function setPioneerOffline(value: boolean): void { + offline = value +} /** Resolve the Pioneer API base URL (no trailing slash). */ export function getPioneerApiBase(): string { @@ -45,6 +50,7 @@ export function resetPioneer(): void { } export async function getPioneer(): Promise { + if (offline) throw new Error('OFFLINE: Pioneer is disabled in offline (airplane) mode') if (pioneerInstance) return pioneerInstance // Deduplicate concurrent init calls @@ -56,6 +62,7 @@ export async function getPioneer(): Promise { if (lastInitAttempt > 0 && timeSinceLast < MIN_RETRY_DELAY) { await new Promise(r => setTimeout(r, MIN_RETRY_DELAY - timeSinceLast)) } + if (offline) throw new Error('OFFLINE: Pioneer is disabled in offline (airplane) mode') lastInitAttempt = Date.now() @@ -81,6 +88,10 @@ export async function getPioneer(): Promise { const client = new Pioneer(specUrl, { queryKey: qk, timeout: 60000, overrideHost }) pioneerInstance = await client.init() if (!pioneerInstance) throw new Error('Pioneer client init returned null') + if (offline) { + pioneerInstance = null + throw new Error('OFFLINE: Pioneer initialization was cancelled by offline mode') + } // Honesty guard: block BTC→Pioneer calls whenever a self-host node is enabled. const { installPioneerGuard } = await import('./pioneer-guard') installPioneerGuard(pioneerInstance) diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 731936a5..1b538827 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -20,9 +20,19 @@ import { join } from 'path' import * as S from './schemas' import { parseRequest, validateResponse } from './validate' import { SIGNING_ROUTES, requiredSigningFields } from './signing-routes' +import { + bitcoinOnlyActivityList, + bitcoinOnlyBalanceList, + bitcoinOnlyChainList, + bitcoinOnlyCoinAllowed, + bitcoinOnlyCoinList, + bitcoinOnlyPublicKeyPathAllowed, + bitcoinOnlyRejection, +} from './bitcoin-only-boundary' import { handleV2DataRoute } from './rest-pioneer' import { handleSwapRoute } from './rest-swap' import { handleSweepRoute } from './rest-sweep' +import { isOfflineNetworkRoute } from './offline-policy' import { handleLedgerRoute } from './rest-ledger' import { getSetting, findApiLogs, getApiLogById, getRecentActivityFromLog, getSwapHistory, getSwapHistoryByTxid, getSwapHistoryStats, getCachedBalances, getCachedPubkeys, getAllTokenVisibility, getTokensByVisibility, setTokenVisibility, removeTokenVisibility, insertClearSignEvent } from './db' import { detectSpamToken, categorizeTokens } from '../shared/spamFilter' @@ -1141,16 +1151,6 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return fn() } - /** Non-Bitcoin address-derivation endpoints. Bitcoin-only firmware can't - * derive any of these — Pioneer still polls them during portfolio sync, so - * we short-circuit them (below) instead of hitting the device, which would - * return "Unknown message" and flood the log. `/addresses/utxo` is BTC. */ - const NON_BTC_ADDRESS_PATHS = new Set([ - '/addresses/cosmos', '/addresses/osmosis', '/addresses/eth', '/addresses/tendermint', - '/addresses/thorchain', '/addresses/mayachain', '/addresses/xrp', '/addresses/solana', - '/addresses/tron', '/addresses/ton', '/addresses/hive', - ]) - /** True when the connected device runs bitcoin-only firmware. */ function deviceIsBitcoinOnly(): boolean { return isBitcoinOnlyVariant(engine.getDeviceState().firmwareVariant) @@ -1184,12 +1184,21 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 server.timeout(req, 0) } - // Bitcoin-only firmware can't derive any non-Bitcoin chain. Pioneer still - // polls these address endpoints during portfolio sync — short-circuit with - // 501 before touching the device, which would otherwise reject the message - // ("Unknown message") and flood the log with multi-chain spam. - if (method === 'POST' && NON_BTC_ADDRESS_PATHS.has(path) && deviceIsBitcoinOnly()) { - return new Response(JSON.stringify({ error: 'not available on bitcoin-only firmware' }), + // Fail before auth approval UI, stale-state reads, network access, and + // device dispatch. Dedicated altcoin feature families are rejected for + // every HTTP method; generic POST routes are rejected by requested coin. + // Malformed bodies continue to normal schema validation instead of being + // misreported as BTC-only. + if (deviceIsBitcoinOnly()) { + let boundaryBody: Record | undefined + if ((method === 'POST' && path.startsWith('/api/v2/')) + || path === '/addresses/utxo' + || path === '/utxo/sign-transaction' + || path === '/system/info/get-public-key') { + boundaryBody = await req.clone().json().catch(() => undefined) + } + const rejection = bitcoinOnlyRejection(method, path, boundaryBody) + if (rejection) return new Response(JSON.stringify({ error: rejection }), { status: 501, headers: { 'Content-Type': 'application/json', ...corsHeaders(req) } }) } @@ -3286,7 +3295,8 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const ds = engine.getDeviceState() if (!ds.deviceId) return json({ devices: [], total_value_usd: 0 }) const cached = engine.isPassphraseWallet ? null : getCachedBalances(ds.deviceId) - const totalUsd = cached ? cached.balances.reduce((sum, b) => sum + b.balanceUsd, 0) : 0 + const balances = bitcoinOnlyBalanceList(cached?.balances || [], deviceIsBitcoinOnly()) + const totalUsd = balances.reduce((sum, b) => sum + b.balanceUsd, 0) return json({ devices: [{ state: ds.state }], total_value_usd: totalUsd, @@ -3301,7 +3311,8 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return json({ error: 'Device not found' }, 404) } const cached = engine.isPassphraseWallet ? null : getCachedBalances(ds.deviceId) - const totalUsd = cached ? cached.balances.reduce((sum, b) => sum + b.balanceUsd, 0) : 0 + const balances = bitcoinOnlyBalanceList(cached?.balances || [], deviceIsBitcoinOnly()) + const totalUsd = balances.reduce((sum, b) => sum + b.balanceUsd, 0) return json({ device_id: ds.deviceId, state: ds.state, @@ -3717,6 +3728,13 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 } // ── xpub/ypub/zpub-type paths (UTXO chains) ── + const coinType = p.address_n.length >= 2 ? (p.address_n[1] >= 0x80000000 ? p.address_n[1] - 0x80000000 : p.address_n[1]) : 0 + const rawCoin = p.coin || SLIP44_TO_COIN[coinType] || 'Bitcoin' + const coin = TICKER_TO_COIN[rawCoin] || rawCoin + // Filter before the cache lookup. The same physical device id may + // have cached altcoin xpubs before being flashed BTC-only. + if (deviceIsBitcoinOnly() && !bitcoinOnlyPublicKeyPathAllowed({ ...p, coin })) continue + const cacheKey = scopedKey(engine, 'batch-pubkey', { address_n: p.address_n, script_type: p.script_type }) const cached = pubkeyCache.get(cacheKey) if (cached) { @@ -3733,9 +3751,6 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 }) continue } - const coinType = p.address_n.length >= 2 ? (p.address_n[1] >= 0x80000000 ? p.address_n[1] - 0x80000000 : p.address_n[1]) : 0 - const rawCoin = p.coin || SLIP44_TO_COIN[coinType] || 'Bitcoin' - const coin = TICKER_TO_COIN[rawCoin] || rawCoin try { const result = await wallet.getPublicKeys([{ addressNList: p.address_n, @@ -3788,12 +3803,12 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (!Number.isFinite(limit)) { throw new HttpError(400, 'Invalid limit: must be a number') } - const entries = getRecentActivityFromLog( + const entries = bitcoinOnlyActivityList(getRecentActivityFromLog( Math.min(Math.max(limit, 1), 500), q.get('chainId') || q.get('chain') || undefined, scope.deviceId, scope.walletId, - ) + ), deviceIsBitcoinOnly()) return json({ entries, count: entries.length }) } @@ -3813,7 +3828,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 } return n } - const entries = findApiLogs({ + const entries = bitcoinOnlyActivityList(findApiLogs({ ...scope, route: q.get('route') || undefined, activityType: q.get('activityType') || undefined, @@ -3823,12 +3838,15 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 until: parseNumParam('until'), limit: parseNumParam('limit'), offset: parseNumParam('offset'), - }) + }), deviceIsBitcoinOnly()) return json({ entries, count: entries.length }) } if (path === '/api/v1/activity/rebuild' && method === 'POST') { auth.requireAuth(req) + if (getSetting('offline_mode') === '1' && isOfflineNetworkRoute(path, method)) { + throw new HttpError(503, 'OFFLINE: activity rebuild is disabled in offline mode') + } if (engine.isPassphraseWallet) { return json({ error: 'Activity rebuild is not available for passphrase-protected wallets' }, 403) } @@ -3843,6 +3861,9 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 ...(Array.isArray(body.chainIds) ? body.chainIds : []), ...(typeof body.chainId === 'string' ? [body.chainId] : []), ] + if (deviceIsBitcoinOnly() && chainIds.some(id => id !== 'bitcoin' && id !== 'BTC')) { + throw new HttpError(501, 'non-Bitcoin activity rebuild is not available on bitcoin-only firmware') + } const unknown = chainIds.filter(id => !CHAINS.some(c => c.id === id || c.symbol === id)) if (unknown.length > 0) { return json({ error: `Unknown chain id(s): ${unknown.join(', ')}` }, 400) @@ -3850,7 +3871,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const result = await rebuildActivityHistory({ wallet, scope, - chains: CHAINS, + chains: bitcoinOnlyChainList(CHAINS, deviceIsBitcoinOnly()), firmwareVersion: engine.getDeviceState().firmwareVersion, options: body, }) @@ -3868,8 +3889,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return json({ error: 'Invalid id' }, 400) } const entry = getApiLogById(id, scope.deviceId, scope.walletId) - if (!entry) return json({ error: 'Not found' }, 404) - return json(entry) + const visible = entry + ? bitcoinOnlyActivityList([entry], deviceIsBitcoinOnly())[0] + : undefined + if (!visible) return json({ error: 'Not found' }, 404) + return json(visible) } // ═══════════════════════════════════════════════════════════════ @@ -4022,7 +4046,8 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // ═══════════════════════════════════════════════════════════════ if (path === '/system/info/list-coins' && method === 'POST') { auth.requireAuth(req) - return json(CHAINS.map(c => ({ + const visibleChains = deviceIsBitcoinOnly() ? bitcoinOnlyCoinList(CHAINS) : CHAINS + return json(visibleChains.map(c => ({ coin_name: c.coin, coin_shortcut: c.symbol, chain: c.chain, @@ -4452,18 +4477,27 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // the dedicated handler instead of falling through to the legacy // pioneer-passthrough quote endpoint. if (path.startsWith('/api/v2/swap')) { + if (getSetting('offline_mode') === '1' && isOfflineNetworkRoute(path, method)) { + throw new HttpError(503, 'OFFLINE: swap routes are disabled in offline mode') + } const resp = await handleSwapRoute(path, method, req, auth, json, callbacks) if (resp) return resp } // ── REST v2 data routes (balances, market, UTXOs, etc.) ── if (path.startsWith('/api/v2/') && !path.startsWith('/api/v2/devices') && !path.startsWith('/api/v2/sweep/') && !path.startsWith('/api/v2/swap')) { + if (getSetting('offline_mode') === '1' && isOfflineNetworkRoute(path, method)) { + throw new HttpError(503, 'OFFLINE: network data routes are disabled in offline mode') + } const resp = await handleV2DataRoute(path, method, req, auth, json) if (resp) return resp } // ── BTC Sweep tool ────────────────────────────────────────── if (path.startsWith('/api/v2/sweep/')) { + if (getSetting('offline_mode') === '1' && isOfflineNetworkRoute(path, method)) { + throw new HttpError(503, 'OFFLINE: sweep routes are disabled in offline mode') + } const resp = await handleSweepRoute(path, method, req, engine, auth, json, callbacks) if (resp) return resp } diff --git a/projects/keepkey-vault/src/bun/rest-pioneer.ts b/projects/keepkey-vault/src/bun/rest-pioneer.ts index 82a772c9..d78188a7 100644 --- a/projects/keepkey-vault/src/bun/rest-pioneer.ts +++ b/projects/keepkey-vault/src/bun/rest-pioneer.ts @@ -10,6 +10,7 @@ import { getPioneer } from './pioneer' import { parseRequest } from './validate' import * as S from './schemas' import { utxoDiscoveryKey } from './btc-backend/types' +import { assertPioneerSuccess } from './btc-backend/normalize' const TAG = '[rest-v2]' @@ -35,6 +36,7 @@ export async function handleV2DataRoute( const resp = await pioneer.GetPortfolioBalances({ pubkeys: body.pubkeys.map(p => ({ caip: p.caip, pubkey: utxoDiscoveryKey(p.pubkey, p.scriptType) })), }, { forceRefresh: true }) + assertPioneerSuccess(resp, 'GetPortfolioBalances') return json({ data: resp?.data || resp }) } @@ -43,6 +45,7 @@ export async function handleV2DataRoute( const body = await parseRequest(req, S.MarketInfoRequest) const pioneer = await getPioneer() const resp = await pioneer.GetMarketInfo(body.caips) + assertPioneerSuccess(resp, 'GetMarketInfo') return json({ data: resp?.data || resp }) } @@ -73,6 +76,7 @@ export async function handleV2DataRoute( network: body.network, xpub: utxoDiscoveryKey(body.xpub, body.scriptType), }) + assertPioneerSuccess(resp, 'ListUnspent') return json({ data: resp?.data || resp }) } @@ -84,6 +88,7 @@ export async function handleV2DataRoute( network: body.network, xpub: utxoDiscoveryKey(body.xpub, body.scriptType), }) + assertPioneerSuccess(resp, 'GetPubkeyInfo') return json({ data: resp?.data || resp }) } @@ -96,6 +101,7 @@ export async function handleV2DataRoute( const resp = await pioneer.GetTransactionHistory({ queries: body.queries.map(q => ({ caip: q.caip, pubkey: utxoDiscoveryKey(q.pubkey, q.scriptType) })), }) + assertPioneerSuccess(resp, 'GetTransactionHistory') return json({ data: resp?.data || resp }) } @@ -104,6 +110,7 @@ export async function handleV2DataRoute( const body = await parseRequest(req, S.BroadcastRequest) const pioneer = await getPioneer() const resp = await pioneer.Broadcast({ networkId: body.networkId, serialized: body.serialized }) + assertPioneerSuccess(resp, 'Broadcast') return json({ data: resp?.data || resp }) } @@ -119,6 +126,7 @@ export async function handleV2DataRoute( const resp = typeof pioneer.GetFeeRateByNetwork === 'function' ? await pioneer.GetFeeRateByNetwork({ networkId: body.networkId }) : await pioneer.GetFeeRate({ networkId: body.networkId }) + assertPioneerSuccess(resp, 'GetFeeRate') return json({ data: resp?.data || resp }) } diff --git a/projects/keepkey-vault/src/bun/txbuilder/utxo-selfhost-policy.test.ts b/projects/keepkey-vault/src/bun/txbuilder/utxo-selfhost-policy.test.ts new file mode 100644 index 00000000..0bbe3e86 --- /dev/null +++ b/projects/keepkey-vault/src/bun/txbuilder/utxo-selfhost-policy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { selfHostChangeIndex } from './utxo' + +const query = { network: 'btc', xpub: 'xpub-test', scriptType: 'p2wpkh' } + +describe('self-host change-address policy', () => { + test('Core may build a transaction that has no change output', async () => { + await expect(selfHostChangeIndex({ kind: 'core' }, query, false)).resolves.toBe(0) + }) + + test('Core fails closed when a change output needs history', async () => { + await expect(selfHostChangeIndex({ kind: 'core' }, query, true)).rejects.toThrow(/cannot prove an unused change address/) + }) + + test('Blockbook supplies the history-derived change index', async () => { + await expect(selfHostChangeIndex({ + kind: 'blockbook', + addressIndices: async () => ({ + receiveIndex: 4, + changeIndex: 9, + discoveryAvailable: true, + source: 'blockbook', + }), + }, query, true)).resolves.toBe(9) + }) + + test('an unavailable or malformed discovery result fails closed', async () => { + await expect(selfHostChangeIndex({ + kind: 'blockbook', + addressIndices: async () => ({ + receiveIndex: 0, + changeIndex: -1, + discoveryAvailable: false, + source: 'blockbook', + }), + }, query, true)).rejects.toThrow(/trustworthy unused change index/) + }) +}) diff --git a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts index 46f172ed..3711bb47 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts @@ -12,6 +12,7 @@ import { bech32, bech32m } from '@scure/base' import bs58check from 'bs58check' import type { ChainDef } from '../../shared/chains' import { getBtcBackend } from '../btc-backend' +import type { BtcBackend } from '../btc-backend' import { utxoDiscoveryKey } from '../btc-backend/types' import { filterSpendableZcashUtxos, summarizeZcashMaturity, ZCASH_MIN_CONFIRMATIONS } from '../../shared/zcash-maturity' @@ -272,7 +273,9 @@ async function resolveFeeRates( if (HARDCODED_FEES[chain.networkId]) return HARDCODED_FEES[chain.networkId] if (btcSelfHostActive(chain.networkId)) { try { return await getBtcBackend().feeRate(chain.networkId) } - catch { return DEFAULT_FEES[chain.networkId] || { slow: 3, average: 5, fast: 15 } } + catch (error: any) { + throw new Error(`Self-host fee lookup failed; refusing to guess a fee rate: ${error?.message || error}`) + } } try { const feeResp = pioneer.GetFeeRateByNetwork @@ -291,6 +294,25 @@ async function resolveFeeRates( } } +export async function selfHostChangeIndex( + backend: Pick, + query: { network: string; xpub: string; scriptType?: string }, + needsChange: boolean, +): Promise { + if (!needsChange) return 0 + if (!backend.addressIndices) { + throw new Error( + `${backend.kind} cannot prove an unused change address from the current UTXO set. ` + + `Use Blockbook for normal sends, or construct a no-change transaction. Vault will not reuse a guessed change index.`, + ) + } + const discovered = await backend.addressIndices(query) + if (!discovered.discoveryAvailable || !Number.isSafeInteger(discovered.changeIndex) || discovered.changeIndex < 0) { + throw new Error(`${backend.kind} did not return a trustworthy unused change index; refusing to build the transaction.`) + } + return discovered.changeIndex +} + /** Fetch UTXOs for a single xpub, tag each with its scriptType and source accountPath */ async function fetchUtxosForXpub( pioneer: any, network: string, xpub: string, defaultScriptType: string, @@ -606,11 +628,10 @@ export async function buildUtxoTx( let changeAddressIndex = 0 const addressToPath = new Map() // BTC self-host: no Pioneer metadata. UTXOs already carry `path` (Blockbook/Core - // provide it), so the address→path enrichment below is a no-op; we only need the - // next change index. Derive it from unspent change UTXOs (path .../1/N). - // ponytail: under-counts only if a change addr was used then fully spent → address - // reuse (privacy), never fund loss; the collision loop below still prevents reusing - // a live input path. Upgrade path: query the node's used-address set if reuse bites. + // provide it), so the address→path enrichment below is a no-op. Change discovery + // must still use full address history: deriving from current UTXOs can reuse an + // address that was previously spent. Blockbook supports it; Core scantxoutset does + // not, so a Core transaction requiring change fails closed. const btcSelfHost = btcSelfHostActive(chain.networkId) // Always query primaryXpub for change-address discovery, plus all funded xpubs for path enrichment const queryCandidates = allXpubs?.length @@ -620,16 +641,13 @@ export async function buildUtxoTx( queryCandidates.map(q => [utxoDiscoveryKey(q.xpub, q.scriptType), q]), ).values()] if (btcSelfHost) { - let maxUsed = -1 - for (const u of utxos) { - const parts = (u.path || '').split('/') - if (parts.length === 6 && parts[4] === '1') { - const idx = parseInt(parts[5], 10) - if (!isNaN(idx) && idx > maxUsed) maxUsed = idx - } - } - changeAddressIndex = maxUsed + 1 - console.log(`${TAG} Self-host change index from UTXOs: ${changeAddressIndex}`) + const backend = getBtcBackend() + changeAddressIndex = await selfHostChangeIndex( + backend, + { network: chain.networkId, xpub: primaryXpub, scriptType }, + outputs.some((output: any) => !output.address), + ) + console.log(`${TAG} Self-host change index via ${backend.kind}: ${changeAddressIndex}`) } for (const query of xpubsToQuery) { const qXpub = query.xpub diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 7353923e..432fba40 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -169,6 +169,16 @@ function App() { return () => clearTimeout(t) }, [deviceState.state, deviceState.firmwareVariant]) + // A firmware swap can occur while Vault is sitting on Explore or has an old + // WalletConnect panel open. Move to the only valid portfolio and close the + // multi-chain surface as soon as the device reports its BTC-only identity. + useEffect(() => { + if (!isBitcoinOnlyVariant(deviceState.firmwareVariant)) return + setActiveTab("vault") + setWcPanelOpen(false) + setWcUri(null) + }, [deviceState.firmwareVariant]) + // ── REST API UI-active handshake ───────────────────────────────── // The Bun process refuses to serve pubkeys/addresses on port 1646 unless // the Vault UI signals it's open + heartbeats regularly. `viewDeviceId` @@ -494,14 +504,14 @@ function App() { useEffect(() => { return onRpcMessage("wc-deep-link-pair", (data) => { const { uri } = data as { uri: string } - if (!walletConnectEnabled) { + if (!walletConnectEnabled || isBitcoinOnlyVariant(deviceState.firmwareVariant)) { setWcNotSupportedOpen(true) return } setWcUri(uri) setWcPanelOpen(true) }) - }, [walletConnectEnabled]) + }, [walletConnectEnabled, deviceState.firmwareVariant]) // Force-open the panel whenever a pair proposal arrives. The pair-approval // modal lives inside WalletConnectPanel and renders nothing while the panel @@ -558,7 +568,7 @@ function App() { useEffect(() => { rpcRequest("getPendingDeepLink").then(uri => { if (uri) { - if (walletConnectEnabled) { + if (walletConnectEnabled && !isBitcoinOnlyVariant(deviceState.firmwareVariant)) { // Set URI and open panel — panel's auto-pair effect handles pairing + errors setWcUri(uri) setWcPanelOpen(true) @@ -569,7 +579,7 @@ function App() { rpcRequest("consumePendingDeepLink").catch(() => {}) } }).catch(() => {}) - }, [walletConnectEnabled]) + }, [walletConnectEnabled, deviceState.firmwareVariant]) // ── Character request overlay (cipher recovery) ───────────────── const [charRequest, setCharRequest] = useState<{ wordPos: number; characterPos: number } | null>(null) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 8ab77372..8ba17cd3 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -37,6 +37,14 @@ import { detectSpamToken, categorizeTokens, type SpamResult } from "../../shared type AssetView = "receive" | "send" | "privacy" +type BtcAddressIndexResult = { + receiveIndex: number + changeIndex: number + discoveryAvailable: boolean + source: 'pioneer' | 'blockbook' | 'core' | 'electrum' | 'esplora' | 'device-only' + warning?: string +} + // Litecoin script types — same trio as Bitcoin, standard purpose per type. const LTC_SCRIPT_TYPES = [ { scriptType: 'p2pkh', purpose: 44, label: 'Legacy', prefix: 'L' }, @@ -211,8 +219,9 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi // BTC address index state: change (0=receive, 1=change) and address index const [btcChangeIndex, setBtcChangeIndex] = useState<0 | 1>(0) const [btcAddressIndex, setBtcAddressIndex] = useState(0) - // Cache Pioneer-reported indices so we don't re-fetch on every toggle - const [pioneerIndices, setPioneerIndices] = useState<{ receiveIndex: number; changeIndex: number } | null>(null) + // Cache backend-reported indices so we don't re-fetch on every toggle. + // Core/offline return an explicit unavailable result — never a silent Pioneer fallback. + const [btcAddressIndices, setBtcAddressIndices] = useState(null) // Derive active BTC script type config and path from selected xpub + change/index const btcSelected = useMemo(() => { @@ -317,7 +326,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi // already-open Receive tab would keep showing the previous wallet's address. }, [btcSelected?.scriptType, btcSelected?.fullPath?.[2], btcChangeIndex, btcAddressIndex, btcSelected?.xpubData?.xpub]) // eslint-disable-line react-hooks/exhaustive-deps - // Fetch next unused address indices from Pioneer API when xpub selection changes + // Fetch next unused address indices from the active BTC backend when xpub selection changes. // Cancellation guard prevents stale responses from snapping to wrong index (Finding 4) const prevScriptRef = useMemo(() => btcAccounts.selectedXpub?.scriptType, [btcAccounts.selectedXpub?.scriptType]) const prevAcctRef = useMemo(() => btcAccounts.selectedXpub?.accountIndex, [btcAccounts.selectedXpub?.accountIndex]) @@ -325,35 +334,35 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi if (!isBtc) return setBtcChangeIndex(0) setBtcAddressIndex(0) - setPioneerIndices(null) + setBtcAddressIndices(null) const xpub = btcAccounts.accounts .find(a => a.accountIndex === (btcAccounts.selectedXpub?.accountIndex ?? 0)) ?.xpubs.find(x => x.scriptType === (btcAccounts.selectedXpub?.scriptType ?? 'p2wpkh')) ?.xpub if (!xpub) return let cancelled = false - rpcRequest<{ receiveIndex: number; changeIndex: number }>('getBtcAddressIndices', { + rpcRequest('getBtcAddressIndices', { xpub, scriptType: btcAccounts.selectedXpub?.scriptType ?? 'p2wpkh', }, 30000) .then((indices) => { if (cancelled) return - setPioneerIndices(indices) + setBtcAddressIndices(indices) setBtcAddressIndex(indices.receiveIndex) }) .catch(e => console.warn('[AssetPage] getBtcAddressIndices failed:', e.message)) return () => { cancelled = true } }, [prevScriptRef, prevAcctRef]) // eslint-disable-line react-hooks/exhaustive-deps - // When toggling Receive/Change, set index to the cached Pioneer value + // When toggling Receive/Change, set index to the active backend's cached value. const handleBtcChangeIndex = useCallback((v: 0 | 1) => { setBtcChangeIndex(v) - if (pioneerIndices) { - setBtcAddressIndex(v === 0 ? pioneerIndices.receiveIndex : pioneerIndices.changeIndex) + if (btcAddressIndices) { + setBtcAddressIndex(v === 0 ? btcAddressIndices.receiveIndex : btcAddressIndices.changeIndex) } else { setBtcAddressIndex(0) } - }, [pioneerIndices]) + }, [btcAddressIndices]) // When EVM selected index changes, update address from the cached value. When // the cache empties — a device swap resets the backend managers and pushes an @@ -1261,6 +1270,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi isBtc={isBtc} btcChangeIndex={btcChangeIndex} btcAddressIndex={btcAddressIndex} + btcAddressWarning={btcAddressIndices?.discoveryAvailable === false ? btcAddressIndices.warning : undefined} onBtcChangeIndex={handleBtcChangeIndex} onBtcAddressIndex={setBtcAddressIndex} isTon={isTon} diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index 167df8b4..4b912727 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -1658,8 +1658,9 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd /> - {/* WalletConnect toggle */} - + {/* WalletConnect is an altcoin signing surface. A persisted global + setting must not re-expose it on Bitcoin-only firmware. */} + {!isBitcoinOnlyVariant(deviceState.firmwareVariant) && @@ -1680,7 +1681,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd onChange={toggleWalletConnect} disabled={togglingWalletConnect} /> - + } {/* BIP-85 Derived Seeds toggle — requires firmware >= 7.16.0 */} {(() => { diff --git a/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx b/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx index e0686c76..c88f7c9b 100644 --- a/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx @@ -23,6 +23,7 @@ interface ReceiveViewProps { isBtc?: boolean btcChangeIndex?: 0 | 1 btcAddressIndex?: number + btcAddressWarning?: string onBtcChangeIndex?: (v: 0 | 1) => void onBtcAddressIndex?: (v: number) => void // TON bounceable toggle @@ -145,7 +146,7 @@ function PillToggle({ export function ReceiveView({ chain, address, loading, error, currentPath, onDerive, scriptType, xpub, - isBtc, btcChangeIndex = 0, btcAddressIndex = 0, onBtcChangeIndex, onBtcAddressIndex, + isBtc, btcChangeIndex = 0, btcAddressIndex = 0, btcAddressWarning, onBtcChangeIndex, onBtcAddressIndex, isTon, tonBounceable = false, onTonBounceableChange, watchOnly = false, }: ReceiveViewProps) { const { t } = useTranslation("receive") @@ -321,18 +322,19 @@ export function ReceiveView({ {/* BTC: receive/change toggle + index stepper — needs the device to derive each index, so hidden in watch-only (cached address only). */} {!watchOnly && isBtc && onBtcChangeIndex && ( - - onBtcChangeIndex(v as 0 | 1)} - /> + + + onBtcChangeIndex(v as 0 | 1)} + /> - {onBtcAddressIndex && ( - + {onBtcAddressIndex && ( + {t("index")} @@ -390,7 +392,13 @@ export function ReceiveView({ ({t("remaining", { remaining })}) - + + )} + + {btcAddressWarning && ( + + {btcAddressWarning} + )} )} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index b80a8fae..ec07eb2c 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -138,7 +138,13 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { getBtcAccounts: { params: void; response: BtcAccountSet } addBtcAccount: { params: void; response: BtcAccountSet } setBtcSelectedXpub: { params: { accountIndex: number; scriptType: BtcScriptType }; response: void } - getBtcAddressIndices: { params: { xpub: string; scriptType: BtcScriptType }; response: { receiveIndex: number; changeIndex: number } } + getBtcAddressIndices: { params: { xpub: string; scriptType: BtcScriptType }; response: { + receiveIndex: number + changeIndex: number + discoveryAvailable: boolean + source: 'pioneer' | 'blockbook' | 'core' | 'electrum' | 'esplora' | 'device-only' + warning?: string + } } // ── UTXO altcoin multi-account (LTC/DOGE/DASH/…) ─────────────────── // Persist a discovered account's xpubs to the device-scoped pubkey cache