diff --git a/Makefile b/Makefile index 8610e917..61fcb870 100644 --- a/Makefile +++ b/Makefile @@ -289,9 +289,17 @@ prune-bundle: cd $(PROJECT_DIR) && bun scripts/prune-app-bundle.ts # Full signed build: electrobun build → audit → prune → extract from tar → create DMG → sign + notarize + staple -# Force-clear zcash-cli stamp so it gets re-signed with Developer ID (stamp may be stale from unsigned build) +# Force-clear stamps so the release always rebuilds from the pinned source: +# - zcash-cli: so it gets re-signed with Developer ID (stamp may be stale from an unsigned build) +# - module build stamps: the proto-tx-builder / hdwallet / device-protocol dist/ output is +# gitignored and copied into the bundle via file: refs. The stamps track src mtimes, but a +# submodule `git checkout` to a new pin does NOT reliably bump src mtimes past an existing +# stamp — so make skips the rebuild and the bundle ships a STALE dist from a previous commit. +# (This shipped the @cosmjs/stargate Freegrant/Feegrant fallback as a pre-fix build in v1.4.6/1.4.7, +# breaking every Cosmos tx with "createFeegrantAminoConverters is not a function".) +# Clearing the stamps forces modules-build from the pinned source before the vault install copies it. build-signed: sign-check - @rm -f $(ZCASH_CLI_STAMP) + @rm -f $(ZCASH_CLI_STAMP) $(PROTO_BUILD_STAMP) $(HDWALLET_BUILD_STAMP) $(DEVICE_PROTOCOL_BUILD_STAMP) $(MAKE) build-stable audit prune-bundle dmg @echo "" @echo "=== Build complete ===" diff --git a/docs/HIVE-ATTESTATION-DIGEST-SPEC.md b/docs/HIVE-ATTESTATION-DIGEST-SPEC.md new file mode 100644 index 00000000..7ed42b4f --- /dev/null +++ b/docs/HIVE-ATTESTATION-DIGEST-SPEC.md @@ -0,0 +1,182 @@ +# Hive Account-Creation Attestation Digest Spec + +For the Pioneer `create-account` endpoint (§5/§6 of `handoff-pioneer-hive-sponsor.md`). +Derived from firmware 7.15.0 `lib/firmware/hive.c` + `include/keepkey/firmware/hive.h`, +verified 2026-06-26. **Byte-exact** — Pioneer's verification must match this or it silently fails. + +--- + +## 0. TL;DR — not blocked on firmware + +In Hive, `account_create` is signed by the **creator (sponsor)**, not the new account (it +doesn't exist yet). So the device's `HiveSignAccountCreate` signature is **not** a broadcast +authority — it is an **attestation**: proof that the owner key holder authorized creating +this account with these four keys, confirmed on-device. + +The firmware already produces a well-defined digest and **returns the exact `serialized_tx` +it signed**. Pioneer verifies against those returned bytes — no reconstruction, no firmware +change. The earlier "blocked on firmware attestation-digest spec" status is **cleared**: the +digest below is the spec. A dedicated attestation message would be *nicer* (see §7) but is +not required to ship. + +--- + +## 1. The digest + +``` +digest = SHA256( chain_id[32] || serialized_tx ) +``` + +- `chain_id` (mainnet, `HIVE_CHAIN_ID`): + `beeab0de` followed by 28 zero bytes (32 bytes total). +- `serialized_tx`: the Graphene `account_create` (op 9) bytes the device built and returned + (§3). Hash the **returned** bytes verbatim; do not rebuild them. + +Reference: `hive.c:183-202` (`hive_sign_digest`). + +--- + +## 2. Signature format (65 bytes) + +``` +sig[0] = 27 + recovery_id + 4 (compact header, compressed-key flag) +sig[1..33] = r (32 bytes) +sig[33..65] = s (32 bytes, canonical low-S from trezor-crypto) +``` + +To recover the signer: +``` +recovery_id = sig[0] - 31 // == 27 + recid + 4 → recid = sig[0]-31 +pubkey33 = secp256k1_ecdsa_recover(digest, recovery_id, r, s) // 33-byte compressed +``` + +Encode `pubkey33` as a Hive public key string: `"STM" + base58check(pubkey33, ripemd160-checksum)` +(Graphene pubkey encoding — 4-byte RIPEMD160 checksum, **not** double-SHA256). Compare against +the supplied `ownerKey`. + +> Graphene's extra "is_canonical" retry loop is irrelevant here — Pioneer recovers a pubkey, +> it does not rebroadcast this signature. Reference: `hive.c:194-200`. + +--- + +## 3. `serialized_tx` byte layout (account_create, op 9) + +All multi-byte integers little-endian. `varint` = LEB128 unsigned. Reference: +`hive_serialize_account_create` (`hive.c:269-307`), `append_tx_header` (`165-173`), +`append_authority` (`152-161`), `append_asset`/`append_string`/`append_varint` (`hive.c:120-164`). + +``` +# header +ref_block_num u16 LE # = msg.ref_block_num & 0xFFFF +ref_block_prefix u32 LE +expiration u32 LE +num_ops varint = 0x01 +op_type varint = 0x09 (HIVE_OP_ACCOUNT_CREATE) + +# account_create body +fee asset: amount u64 LE, precision u8 = 3, symbol[7] = "HIVE\0\0\0" +creator string: varint len || bytes # MUST equal sponsor account name +new_account_name string: varint len || bytes +owner_authority authority(owner_pubkey33) +active_authority authority(active_pubkey33) +posting_authority authority(posting_pubkey33) +memo_key 33 raw bytes # NO authority wrapper, NO type prefix +json_metadata string = "" (varint 0x00) +num_extensions varint = 0x00 +``` + +`authority(pubkey33)`: +``` +weight_threshold u32 LE = 1 +num_account_auths varint = 0x00 +num_key_auths varint = 0x01 +pubkey 33 bytes compressed (no type prefix) +weight u16 LE = 1 +``` + +`asset` (fee): `amount(u64 LE) || precision(u8) || symbol(7 bytes, NUL-padded)`. +`string`: `varint(len) || raw bytes`. + +> Note: pubkeys inside `serialized_tx` are **raw 33-byte compressed**, not STM strings. +> The STM strings in the request are for the availability/echo path; the authoritative keys +> are these raw bytes — re-encode them to STM to compare against the request (§5 step 5). + +--- + +## 4. account_update (op 10) — same digest, Flow B + +Identical digest and signature rules. `serialized_tx` differs only in the body +(`hive_serialize_account_update`, `hive.c:351-388`): `op_type = 0x0A`, then `account` +(string) + the four new authorities/memo_key. Same verification shape; used by the +"secure existing account" flow, not create-account. + +--- + +## 5. Pioneer verification algorithm (create-account) + +Input from vault: `{ ownerKey, activeKey, postingKey, memoKey, newAccountName, creator, +refBlockNum, refBlockPrefix, expiration, feeAmount, signature(65B hex), serializedTx(hex) }`. + +``` +1. tx = hexDecode(serializedTx) + sig = hexDecode(signature) // assert length 65 +2. digest = SHA256( HIVE_CHAIN_ID(32) || tx ) +3. recid = sig[0] - 31 // assert 0 <= recid <= 3 + pub33 = ecdsaRecover(digest, recid, sig[1:33], sig[33:65]) // assert success + recoveredStm = stmEncode(pub33) +4. ASSERT recoveredStm == ownerKey // → 401 if not (proves owner-key control + on-device confirm) +5. Parse tx (§3) and ASSERT, else 400: + - op_type == 9 + - new_account_name == newAccountName + - stmEncode(owner_authority.key) == ownerKey + - stmEncode(active_authority.key) == activeKey + - stmEncode(posting_authority.key) == postingKey + - stmEncode(memo_key) == memoKey + - creator == // binds spend to us; client cannot redirect +6. Run §7 abuse + circuit-breaker gates (handoff). If ACT pool low → 503 queued. +7. Build create_claimed_account (op 23) with { creator: sponsor, new_account_name, + owner/active/posting authorities + memo_key = the four verified keys, json_metadata: "" }, + sign with the SPONSOR active key, broadcast. fee_amount from the request is ignored + (claimed-account creation has no fee — it spends one ACT). +``` + +Step 5 is load-bearing: without it a caller could submit a valid signature over +attacker-chosen bytes. The signature only matters once you've proven the bytes are exactly +what you're about to create. + +--- + +## 6. What the vault must guarantee + +- Set `creator = ` **before** calling `hiveSignAccountCreate`, so the + device-signed bytes carry our sponsor (Pioneer rejects any other creator). +- Send Pioneer the device-returned `serializedTx` and `signature` unmodified, plus the four + STM keys + `newAccountName` it used. +- `refBlockNum/Prefix/expiration/feeAmount` are echoed for transparency but Pioneer trusts + only the parsed `serializedTx` for keys/name/creator. + +--- + +## 7. Optional future: dedicated attestation digest (firmware change) + +The op-9 digest couples attestation to Graphene serialization, so Pioneer must parse §3. +That parse is ~30 lines of fixed-layout decoding — acceptable. **Ship with this; do not block.** + +If Graphene parsing later proves annoying or we want attestation decoupled from tx fields, add +a firmware message that signs a canonical, parse-free digest, e.g.: +``` +digest = SHA256( "KK-HIVE-CREATE-v1" || new_account_name + || owner33 || active33 || posting33 || memo33 ) +``` +Pioneer would then verify with no Graphene decoding. This is a **new firmware message** +(separate PR), not a change to what 7.15.0 ships — track independently. + +--- + +## 8. Test vector (capture before Pioneer codes verification) + +On a real device (fw 7.15.0), call `hiveSignAccountCreate` with fixed inputs and record: +`{ inputs, signature(hex), serializedTx(hex), expected recoveredStm == ownerKey }`. Commit +the vector so Pioneer's verifier has a known-good fixture to test §5 against without a device. +Until this vector exists, treat §2's STM-encoding and recovery-byte handling as +**unverified-by-fixture** (correct by reading firmware, but confirm against device output). diff --git a/docs/HIVE-ONBOARDING-PLAN.md b/docs/HIVE-ONBOARDING-PLAN.md new file mode 100644 index 00000000..47f42e72 --- /dev/null +++ b/docs/HIVE-ONBOARDING-PLAN.md @@ -0,0 +1,176 @@ +# Hive Onboarding — Next Steps (Self-Run Sponsor) + +Updated: 2026-06-26. Decision: build self-run account creation (Pioneer sponsor). +Supersedes the "What Exists Today" section of `HIVE-FIRMWARE-PLAN.md` — that doc's +v2 spec is still the reference for message shapes; the status below is current truth. + +--- + +## 1. Status truth (verified 2026-06-26) + +The expensive, irreversible part — firmware crypto + protocol — is **done**. +Everything between firmware and the user is stuck at v1, and v1 is **actively broken**. + +| Layer | State | Evidence | +|---|---|---| +| device-protocol | ✅ Complete | messages 1600–1609 defined, SLIP-0048, `role` field (messages-hive.proto) | +| Firmware 7.15.0 | ✅ Complete | SLIP-0048 derive (hive.h:20-27, hive.c:42-46), 4 role keys (hive.c:58-83), account_create op 9 (hive.c:270-345), account_update op 10 (hive.c:352-426), on-device confirms + FSM wired (messagemap.def:183-193) | +| hdwallet | ⚠️ Half | `hiveGetPublicKey` + `hiveSignTx` only (hdwallet-keepkey/src/hive.ts:11-14). **Missing**: `hiveGetPublicKeys`, `hiveSignAccountCreate`, `hiveSignAccountUpdate` (1604–09). No SLIP-0048 helper. | +| Vault | ❌ v1 | `chains.ts` hive `defaultPath: [0x8000002C,0x800004FB,...]` = `m/44'/1275'/0'/0/0` (WRONG). `txbuilder/hive.ts` = transfer only. No onboarding UI/RPC. | +| Pioneer | ❌ Relay only | `hive.controller.ts`: account-by-pubkey, tx-params, history, broadcast. No create-account, no sponsor, no rate-limit. | + +### The blocker bug (independent of onboarding) +Firmware's single-key handler derives exactly the path the host sends +(`fsm_msg_hive.h:22`). The vault sends `m/44'/1275'` — a path **no other Hive wallet +uses** (Ledger/Keychain/PeakD all use SLIP-0048 `m/48'/13'`). Result: the vault's STM +key is non-interoperable, and Pioneer's pubkey→account lookup can never match an account +made elsewhere. Any account funded against a v1 key is **unrecoverable in any other tool**. + +→ The default-OFF settings flag (shipped, PR #293) is the correct holding position. +Hive must not reach a user until the path is fixed. + +--- + +## 2. Phase 0 — Path fix (MANDATORY prerequisite, no onboarding yet) + +Until this lands, nothing else is safe to ship. Breaking change is free: zero accounts +exist on v1 keys (flag has been off). + +| Task | Layer | File | Size | +|---|---|---|---| +| `defaultPath` → `m/48'/13'/1'/0'/0'` (active role) | Vault | `shared/chains.ts` | S | +| Add `hiveRolePath(role, accountIndex)` helper | Vault | `shared/chains.ts` | S | +| Verify derived STM key == Hive Keychain output for same seed | — | device + Keychain | S | + +**Exit check:** same KeepKey seed produces the **same** STM active key in vault and in +Hive Keychain. This is the single most important checkpoint in the whole effort. + +--- + +## 3. Phase 1 — hdwallet plumbing (un-skippable for any onboarding) + +The 3 proto messages exist but the JS wallet can't call them. Spec is fully written in +`HIVE-FIRMWARE-PLAN.md` Layer 2. + +| Task | File | Size | +|---|---|---| +| Core interfaces: `HiveGetPublicKeys`, `HiveSignAccountCreate`, `HiveSignAccountUpdate` (+ Signed*) | hdwallet-core/src/hive.ts | S | +| `hiveSlip48Path()` + role constants | hdwallet-core/src/hive.ts | S | +| Wire shims 1604→1605, 1606→1607, 1608→1609 | hdwallet-keepkey/src/hive.ts | M | +| `keepkey.ts` methods: `hiveGetPublicKeys/hiveSignAccountCreate/hiveSignAccountUpdate` | hdwallet-keepkey/src/keepkey.ts | S | +| Submodule pin bump in vault after merge | vault | S | + +**Exit check:** vault can call `hiveGetPublicKeys()` and get 4 STM keys from a real device. + +--- + +## 4. Phase 2 — Sponsor service (the part with cost & ops) + +This is what the "self-run" decision buys. **Economics first, code second.** + +### 4a. How Hive account creation actually costs + +Two on-chain ways to create an account: +- `account_create` — pays a flat **fee (currently ~3 HIVE)** per account. Cash out the door. +- `create_claimed_account` — spends a pre-claimed **Account Creation Token (ACT)**, which + you mint with **`claim_account`**. `claim_account` costs **either** 3 HIVE **or** a large + chunk of **Resource Credits (RC)** — and RC is free/regenerating if you've staked enough + **Hive Power (HP)**. + +**The cheap model = stake HP → mint ACTs with RC on a schedule → spend ACTs for users.** +HP stake is **locked capital, not burned cash** (power-down returns it over 13 weeks). +Cash only bleeds if we fall back to the 3-HIVE fee during bursts. + +> ⚠️ RC cost of `claim_account` and the creation fee are **witness-tunable** — do NOT +> hardcode. Read live values from the chain (`get_dynamic_global_properties`, +> `rc_api.get_resource_params`, witness `account_creation_fee`) and size HP from the +> measured claim rate you want to sustain. Capacity-plan before staking. + +### 4b. Sponsor ops (Pioneer) + +| Task | Detail | Size | +|---|---|---| +| Sponsor account + key mgmt | Funded Hive account; **active key in secrets, never in repo/.env-in-git**. Sign `claim_account` / `create_claimed_account` server-side. | M | +| ACT minting cron | Periodic `claim_account` while RC allows; maintain a pool of pending ACTs | M | +| `POST /hive/create-account` | Validate 4 STM keys, build+broadcast `create_claimed_account` from sponsor, return txid | M | +| `GET /hive/username-available/:name` | Format check + on-chain availability | S | +| `GET /hive/sponsor-info` | Pool size, RC %, HP, ACT count — for UI warnings + monitoring | S | +| Monitoring/alerts | Alert on ACT pool low / RC depleted / HP power-down | M | + +### 4c. Abuse defense (a free-account faucet WILL be attacked) + +| Control | Notes | +|---|---| +| Per-IP rate limit (1/24h baseline) | Pioneer has **no** rate-limit middleware today — must add (`express-rate-limit` or equiv) | +| **Device attestation** (our advantage) | Require a device-signed challenge so only a genuine KeepKey can request creation. Strong sybil defense competitors lack — lean on it instead of CAPTCHA. | +| Username blacklist + format validation | Reserved/abusive names | +| Pool circuit-breaker | Refuse when ACT pool/RC below threshold rather than fall back to cash fee silently | + +**Exit check:** end-to-end on testnet/mainnet — device keys → username check → device +confirm → sponsor `create_claimed_account` → `@username` resolves and shows in vault. + +--- + +## 5. Phase 3 — Vault onboarding UI + +Spec in `HIVE-FIRMWARE-PLAN.md` Layer 4 (state machine + wizard steps). Net-new in vault. + +| Task | File | Size | +|---|---|---| +| `txbuilder/hive-account.ts`: `buildHiveAccountCreate` / `buildHiveAccountUpdate` | vault/bun | M | +| RPCs: `hiveGetPublicKeys`, `hiveCreateAccount`, (later) `hiveSecureAccount` | vault/bun | M | +| Asset-page state machine: `NO_ACCOUNT / PENDING / ACTIVE / UNSECURED` | vault/mainview | M | +| Onboarding wizard (Flow A): intro → derive 4 keys → username → device confirm → broadcast → done | vault/mainview | L | +| Hive card: `@username` as address, send/receive | vault/mainview | M | + +**Exit check:** a brand-new KeepKey with no Hive account completes the wizard and lands on +an ACTIVE Hive card, fully device-controlled. + +--- + +## 6. Phase 4 — Migration (Flow B, "secure existing account") — optional + +For users who already have a Hive account (most of them). Uses the already-implemented +`account_update`. Lets existing Keychain/Ledger users move custody to KeepKey. + +- Vault migration wizard (warning → account name → show new keys → user signs with current + owner WIF in-memory once → device confirm `account_update` → done). +- Pioneer broadcasts `account_update` (broadcast path already exists). +- **Security:** owner WIF used in-memory for one broadcast, never stored/logged. + +Lower priority than Phase 3 under the self-run decision, but cheap relative to the sponsor +work and high-value for adoption. Sequence after Phase 3 unless adoption data says otherwise. + +--- + +## 7. Critical path & sequencing + +``` +Phase 0 (path fix) ─┬─→ Phase 1 (hdwallet) ─┬─→ Phase 2 (sponsor) ─→ Phase 3 (wizard) ─→ ship (flag on) + MANDATORY │ MANDATORY │ decision = yes new-user onboarding + │ └─→ Phase 4 (migration, optional, after P3) + └─ blocks everything; do first, verify against Keychain +``` + +Firmware/protocol: **0 work** (done). Heaviest remaining: Phase 2 (sponsor ops + abuse) +and Phase 3 (wizard UI). Capital: HP stake (recoverable). Recurring: ops + monitoring, plus +cash only if bursts exceed the ACT mint rate. + +--- + +## 8. Decisions (resolved 2026-06-26) + +1. **Gating → KeepKey owners only.** Creation requires a device-signed challenge; only a + genuine KeepKey can request. Near-sybil-proof, caps abuse and cost. (Phase 2 endpoint + must verify a device attestation, not just rate-limit.) +2. **Empty-pool policy → queue + circuit-breaker.** Refuse below a threshold and refill via + the mint cron; never silently fall back to the cash fee. No surprise spend. +3. **Multi-account → single account, index 0 only** for v1. `m/48'/13'/role'/0'/0'`. + Multi-account deferred (additive, non-breaking later). +4. **Key scope → active key only** for v1 (transfers, power up/down, staking). Posting-key + signing deferred. Smallest signing surface first. + +### Still needs a number (not a blocker for Phase 0/1) +- **HP stake size** — driven by target accounts/day. With KeepKey-owners-only gating, volume + is bounded by device sales, so size the stake to that. Capacity-plan from live RC cost + before committing capital in Phase 2. diff --git a/docs/firmware-release-sop.md b/docs/firmware-release-sop.md new file mode 100644 index 00000000..a42307fd --- /dev/null +++ b/docs/firmware-release-sop.md @@ -0,0 +1,101 @@ +# Firmware Release SOP — Upstream-First Dependency Gating + +> Supersedes the Phase-5-last ordering in +> `docs/release-notes/firmware-7.14.0-release-plan.md`, which is **wrong for a +> release** (it pushes the proto/test dependencies upstream *after* fork-develop +> merges). The vault `docs/submodule-pinning-sop.md` covers the desktop-Vault +> submodules only and does not mention `python-keepkey`; this doc governs the +> **firmware** release. + +## The rule (read this first) + +On a **release**, the firmware is built **UPSTREAM-ONLY**. Nothing merges into +`BitHighlander/keepkey-firmware` `develop` until every proto/test dependency it +needs is **green and merged into the UPSTREAM masters first**: + +- `keepkey/device-protocol` `master` (proto / wire format) +- `keepkey/python-keepkey` `master` (integration test harness) + +These two upstream merges are **human-peer-review gated** — review latency is +**days**, and a sloppy PR turns days into weeks. They are the critical path. +Submit them first; keep them clean, concise, and clear. + +> Contrast: `alpha` (the testbed) pins submodules to the **forks**. A release +> does not. `alpha` = fork pins; `release` = upstream pins. Do not confuse them. + +## Order of operations (bottom-up) + +``` +1. UPSTREAM PROTO + TESTS (peer-reviewed, days) + ├─ PR proto changes → keepkey/device-protocol master ── merge + └─ PR test changes → keepkey/python-keepkey master ── merge + │ (only after BOTH merged + green) + ▼ +2. PIN firmware fork-develop submodules → those UPSTREAM masters + ▼ +3. MERGE individual firmware fix PRs → fork develop + (each PR pins upstream masters; each stays green; one at a time) + ▼ +4. UPSTREAM the firmware → keepkey/keepkey-firmware develop (LAST) + (only after everything is green and merged into fork develop) +``` + +Why bottom-up: a firmware PR that references a new proto message or relies on a +new integration test **cannot be green** until that proto/test exists on the +upstream master it pins. Merging firmware first (the old Phase-5 order) pins +fork branches / unreviewed SHAs and defers the slow review to the end — +maximizing the chance of a late-cycle, weeks-long stall. + +## Staging on fork develop (while upstream PRs are in review) + +Each fix is a **singular PR** into fork `develop`, cherry-picked from its +original feature branch (skip the `chore: pin submodules` commits). Order them +**green-first**: firmware-only fixes (no new proto/test) go green immediately on +develop's pins; fixes that need a not-yet-merged proto/test are pinned to the +**current** upstream master and sit **red — intentionally — until their upstream +PR merges**. Red here is the correct signal: "parked behind an upstream PR," +not "broken." When the upstream PR lands, re-pin to the updated master → green. + +To rehearse the eventual pin-swap safely, a fork pin may be used as a *practice* +pin; the production state is always the upstream master. + +### Mergability is *in order*, not in isolation + +The success criterion is **"the SOP can be followed in order and stay green at +each step,"** not "every branch builds standalone on bare develop." Staging is a +**sequential pipeline**: develop accumulates as batches merge, and each PR is +green *in its turn* — after its predecessors (and the upstream foundation) have +landed. A later-batch PR being **red on today's develop is correct** when its +base isn't there yet; it goes green once the batch it depends on merges. + +Practical consequence: stage and merge the **independent, develop-compatible +fixes first** (they're green immediately). Defer **feature stacks that ride the +alpha base** (e.g. EVM clear-signing, Hive, Zcash — which add alpha-only files or +share a base-foundation commit) until their turn in the order; cutting them onto +bare develop early produces low-signal red stubs and heavy reconciles, not +useful PRs. + +## Per-firmware-PR merge gate (before merge into fork develop) + +- [ ] `deps/device-protocol` pinned to `keepkey/device-protocol` master + (an ancestor-of or equal-to master commit; never a fork/feature SHA) +- [ ] `deps/python-keepkey` pinned to `keepkey/python-keepkey` master +- [ ] Every proto message / field the PR uses exists on the pinned device-protocol +- [ ] Every integration test the PR needs exists on the pinned python-keepkey +- [ ] CI green (build + unit + python-keepkey integration + test-report.pdf) +- [ ] On-device verification for device-facing changes + +## Reconcile-before-PR (python-keepkey) + +If the fork is behind upstream, pull `keepkey/python-keepkey` master **into** the +fork and verify green against the **alpha** emulator (which has every feature the +tests exercise) before building the upstream PR. Build the upstream PR from that +green base, not a stale one. Watch for divergence traps: the commit `alpha` +*pins* may not be fork `master` (e.g. firmware-pinned router test vectors can +live only on the pinned commit), so reconcile against what alpha actually pins. + +## Clang-format + +CI's `lint-format` job pins **clang-format 20** (`clang-format-20`). Format with +that exact version (`clang-format@20`, v20.1.x) — a newer local clang-format +(v22+) over-reformats and still fails CI. diff --git a/docs/handoff-pioneer-hive-eth-gate.md b/docs/handoff-pioneer-hive-eth-gate.md new file mode 100644 index 00000000..08fbcd4a --- /dev/null +++ b/docs/handoff-pioneer-hive-eth-gate.md @@ -0,0 +1,44 @@ +# Handoff — Pioneer: implement the Hive sponsor ETH anti-drain gate + +**Date:** 2026-07-02 +**Owner:** Pioneer (`services/pioneer-server`) — this is the server half of a gate the vault already implements. +**Confirmed via prod logs:** `pioneer-server-v3` pod threw a TSOA `ValidateError` on `POST /api/v1/hive/create-account`: +`"body.ethAddress": excess property not allowed` / `"body.ethSignature": excess property not allowed`. + +## Context +Hive sponsored account creation now works end-to-end EXCEPT the ETH gate. The vault signs an EIP-191 message and sends `ethAddress` + `ethSignature`, but deployed Pioneer's `HiveCreateAccountRequest` doesn't declare them and TSOA rejects excess fields → 400. Pioneer's own controller comment says it has *"no abuse gate yet."* + +**Vault status:** the gate is flagged OFF (`const HIVE_ETH_GATE = false` in `projects/keepkey-vault/src/bun/index.ts`), so the vault currently sends only `{username, ownerKey, activeKey, postingKey, memoKey, attestation}` and account creation succeeds without abuse protection. **When Pioneer ships the gate below, flip `HIVE_ETH_GATE = true` and rebuild.** + +## What Pioneer must implement + +1. **Accept the fields.** Add to `HiveCreateAccountRequest` (`controllers/hive.controller.ts`), OPTIONAL so both gated-off and gated-on vault builds validate: + ```ts + ethAddress?: string; // 0x… EIP-55, device ETH key m/44'/60'/0'/0/0 + ethSignature?: string; // 0x… 65-byte r||s||v over the gate message + ``` + +2. **Verify the signature.** The vault signs this EXACT message (no trailing newline — byte-pinned, do not reformat): + ``` + KeepKey Hive onboarding\nusername:${username}\nowner:${ownerKey} + ``` + Recover with `ethers.verifyMessage(message, ethSignature)` and require the recovered address to equal `ethAddress` (checksum-insensitive compare). `username`/`ownerKey` come from the same request body, binding the gate to this account. + +3. **Require the address to hold mainnet ETH.** If balance is 0 → **HTTP 403** (the vault shows a dedicated "fund your ETH address" screen and offers retry). Any non-zero balance passes (or pick a small threshold). + +4. **One sponsored account per ETH address.** Persist claimed addresses. On a repeat claim → **HTTP 409** with `eth` or `address` in the `error` string (the vault's 409 branch does `/eth|address/i.test(r.error)` to show "This device already has a sponsored Hive account"). + +## Status codes the vault already handles (`HiveAccountPanel.tsx`) +- **200** `{success|txid}` → success screen. +- **403** → fund-ETH-address screen (`getEvmAddresses` idx0) + retry. +- **409** with eth/address in error → "already has a sponsored account"; otherwise → "name just taken". +- **401** → "device confirmation didn't verify". +- **503** `{retryAfter}` → "sponsor busy, try again in Ns". +- **400** → surfaces Pioneer's `error` string verbatim. + +## Cutover +1. Ship + deploy the Pioneer gate (optional fields, so it's backward-compatible with the current flagged-off vault). +2. Vault: `HIVE_ETH_GATE = true`, rebuild. The vault re-adds the EIP-191 signing (one extra device confirm) + the two fields. +3. Verify on device: create-account with a funded ETH addr → 200; unfunded → 403; second attempt same addr → 409. + +Reference (vault side): `projects/keepkey-vault/src/bun/index.ts` `hiveCreateAccount` (the `if (HIVE_ETH_GATE)` block builds the message + signs; step 4 sends the fields). diff --git a/docs/handoff-pioneer-hive-push-refresh.md b/docs/handoff-pioneer-hive-push-refresh.md new file mode 100644 index 00000000..26fb1594 --- /dev/null +++ b/docs/handoff-pioneer-hive-push-refresh.md @@ -0,0 +1,99 @@ +# Handoff: Pioneer-side gaps found by the asset-page refresh audit (2026-07-01) + +Context: a user sent HIVE to their vault, got no push, and the asset-page +refresh appeared to do nothing. The vault-side root cause (single-chain +`getBalance` missing the `publicKey` derive fallback) is fixed in the vault +repo. A six-agent audit of the full refresh path (vault UI → bun → +pioneer-client → pioneer server → push pipeline) also verified, empirically, +that `?forceRefresh=true` reaches `POST /api/v1/portfolio` and genuinely +bypasses the balance cache — including for hive. The items below are the +**pioneer-side** findings that remain open. Repo: `projects/pioneer`, +branch `develop` (audited @ `00507f37f`). + +## 1. HIVE has zero push coverage (HIGH) + +An incoming HIVE transfer can never produce a `transaction:incoming` / +`tx:incoming` event: + +- `services/pioneer-server/src/websocket/WebSocketHandler.ts:150-163` — + `setupRedisSubscriptions` subscribes a **hardcoded 12-network list** + (BTC/LTC/DOGE/BCH/DASH/QTUM + 6 EVM). No `hive:beeab0de` (also no + cosmos/thorchain/xrp/solana). +- `services/pioneer-watchtower` has **zero hive references** — its chain + universe is `chainConfig.blockbooks` from pioneer-nodes + (`modules/pioneer/pioneer-nodes/src/seeds.ts:121-315`); hive has no + blockbook. The SSE path (`events-stream.controller.ts:73` → + `pioneer:watcher:add`) terminates in the same blockbook-only watchtower, + so subscribing hive addresses is a no-op. + +Fix: a dedicated hive watcher in watchtower (poll `account_history` for the +resolved account name, publish `pioneer:tx:hive:beeab0de`), and derive +`WATCHED_NETWORK_IDS` from watchtower-supported chains instead of the +hardcoded list. + +## 2. Hive address registration uses the STM pubkey, not the account name (MEDIUM, blocks #1) + +`modules/pioneer/pioneer-balance/src/index.ts:460` resolves the account name +(`asset.address = hiveAcct.name`) but `BalanceCache.fetchFromSource` +(`modules/pioneer/pioneer-cache/src/stores/balance-cache.ts:237-252`) drops it +— `BalanceData` has no address field — so the address-registration pipeline +registers the **STM public key** as the watchable address +(`balance.controller.ts:1951-1952`). Incoming hive transfers reference the +account name, so push matching could never hit even with a watcher. Pass +`asset.address` through into `BalanceData` and register that for +account-model chains. + +## 3. Failed forced refresh rewrites `fetchedAt` → stale data stamped fresh (MEDIUM) + +`modules/pioneer/pioneer-cache/src/core/base-cache.ts:521-525` — when a +forced refresh's upstream fetch fails, the stale cached value is served with +`fetchedAt = now` (+`_degraded`). The controller computes `isStale` +(`balance.controller.ts:966-968`) and `staleChains` (`:1598-1612`) from +`fetchedAt`, so both lie for degraded entries; only `meta.failures` tells the +truth. Keep the original fetch timestamp (add `lastAttemptAt` if needed). + +## 4. Hive balance adapter is fragile under the 5s per-chain budget (MEDIUM) + +`modules/pioneer/pioneer-balance/src/index.ts:438-456` — single hardcoded +node (`https://api.hive.blog`), two **sequential** JSON-RPC POSTs, no +per-request timeout, no failover. The force-refresh path races it against +`CHAIN_FETCH_TIMEOUT_MS = 5000` (`balance-cache.ts:375-404`); on timeout the +user silently gets last-known cache tagged `_degraded` (and per #3, stamped +fresh). Add an AbortController timeout + a fallback node (api.deathwing.me / +anyx.io), and cache the immutable pubkey→account-name resolution. + +## 5. Socket events the vault can't decode (LOW) + +`WebSocketHandler.ts:1874,1927` emit `balance:update` / +`balance:cache:update` as `JSON.stringify(payload)` **strings** while +`transaction:incoming` (`:410-424`) is an object. Emit objects consistently +(or document the string envelope). NOTE: the vault deliberately does NOT +consume these two events even after its socket-leg repair — see #7. + +## 7. `balance:update` / `balance:cache:update` are self-echoes, not worker pushes (HIGH — blocks any client using them) + +The **GetPortfolioBalances controller itself** emits these events, per +requested pubkey, to the *requesting user's own sockets*: +`balance.controller.ts:776/788/798` (`emitBalanceCacheUpdate`, subtypes +`updated`/`same`/`skipped` — `skipped` fires whenever `fetchedAt < 5 min`, +i.e. the steady state) and `:974` (`emitBalanceUpdate`, once per pubkey of +the response). These three call sites are the only emitters in the tree — +the background cache worker (`RefreshWorker` / `StaleBalanceScanner`) never +publishes balance updates. So today the events are 100% echoes of the +client's own REST queries: any client that refreshes balances on them +enters a self-sustaining request loop (we confirmed this in review and +reverted the vault's consumption of them — the vault now listens only to +`transaction:incoming`). + +For a usable "soft update" signal: emit balance events from the WORKER +refresh path (or add a `source: 'worker' | 'request'` field), gate on +`type === 'updated'` with `balance !== previousBalance`, and don't echo to +the requester's own socket for request-triggered fetches. + +## 6. Hive is in the slow soft-update lane (LOW / FYI) + +Background refresh for hive falls under the global stale scanner: 1-hour +threshold, 100 keys per 10-min tick (`cache-manager.ts:285-291`, +`stale-balance-scanner.ts`). UTXO gets a 15-min loop; EVM/hive/etc. wait up +to 1h+. With no push (#1), hive freshness is effectively poll-driven from +the vault. Consider a faster class for account-model chains once #1 lands. diff --git a/docs/handoff-pioneer-hive-sponsor.md b/docs/handoff-pioneer-hive-sponsor.md new file mode 100644 index 00000000..b4ff2e61 --- /dev/null +++ b/docs/handoff-pioneer-hive-sponsor.md @@ -0,0 +1,197 @@ +# Handoff: Pioneer Hive Sponsor Service (Phase 2) + +For a Pioneer-side dev/agent. Self-contained. Builds the account-creation backend that +KeepKey Vault's Hive onboarding wizard will call. + +- **Pioneer repo:** `/Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer` +- **Vault plan + decisions:** `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11-hive/docs/HIVE-ONBOARDING-PLAN.md` +- **Message/serialization spec:** `…/keepkey-vault-v11-hive/docs/HIVE-FIRMWARE-PLAN.md` (Layer 3) +- **Restart Pioneer with `make start`** (never paste manual pnpm/bun recipes). +- **Never read or write `.env`.** Sponsor keys go through the existing secrets mechanism; + ask for them, fail fast if missing. + +--- + +## 1. Why this exists + +Hive has **no free self-service account creation** — it's their spam defense. Creating an +account requires either a ~3 HIVE fee or an **Account Creation Token (ACT)**, paid by an +existing funded account. KeepKey is running a **self-run sponsor**: a funded Hive account +that mints ACTs from Resource Credits (RC) and spends them to create accounts for KeepKey +owners. This service is that sponsor. + +The device side is done: firmware 7.15.0 derives SLIP-0048 keys and signs `account_create` +(op 9) with on-device confirmation. The vault collects the user's 4 device-derived public +keys and a device-signed payload, then calls this service to broadcast from the sponsor. + +--- + +## 2. What Pioneer has today (do not rebuild) + +`services/pioneer-server/src/controllers/hive.controller.ts` — read-only + relay: + +| Endpoint | Purpose | +|---|---| +| `GET /api/v1/hive/account/:pubkey` | Resolve STM pubkey → account name + balances (hive/hbd/hp/rc%) | +| `GET /api/v1/hive/tx-params` | Block reference params for tx construction | +| `GET /api/v1/hive/history/:account` | Recent transfer history | +| `POST /api/v1/hive/broadcast` | Broadcast a signed transfer | + +Routes registered in `routes.ts` (~lines 2271, 2300, 2330, 2360). **No rate-limit +middleware exists** in `app.ts` — you must add it. There is **no sponsor account, no ACT +logic, no abuse defense** today. + +--- + +## 3. Locked decisions (do not re-litigate) + +1. **Gating = KeepKey owners only**, enforced via a device-signed `account_create` payload + + rate-limit + circuit-breaker. No hardware attestation exists on fw 7.15.0, so this is an + *economic* gate, not hardware-identity proof — see §6 (resolved). +2. **Empty ACT pool = queue + circuit-breaker.** Refuse below a threshold; refill via the + mint cron. **Never** silently fall back to the 3-HIVE cash fee. +3. **Single account, index 0 only** for v1. Keys arrive at `m/48'/13'/role'/0'/0'`. +4. **Active-key only** is what the vault signs day-to-day; but account creation submits all + 4 public keys (owner/active/posting/memo) — you broadcast all 4 into the new account's + authorities. + +--- + +## 4. Sponsor mechanics (the core) + +``` + ┌─ claim_account (costs RC, free-ish if HP staked) ──┐ + Sponsor acct ──┤ run on a cron while RC allows ├─→ pool of pending ACTs + (funded, HP) └────────────────────────────────────────────────────┘ + │ + Vault request ── create-account ──→ create_claimed_account (spends 1 ACT) ─→ new @username +``` + +- **`claim_account`** mints one pending ACT. Costs **either** 3 HIVE **or** a large chunk of + **RC**. With enough staked **Hive Power**, RC regenerates and ACTs are effectively free. +- **`create_claimed_account`** spends one pending ACT to create the user's account with their + 4 device public keys as authorities. This is what the create endpoint broadcasts. +- **HP stake is locked capital, not burned cash** (power-down returns it over 13 weeks). Cash + only leaves if you ever use the fee path — which decision #2 forbids. + +> ⚠️ **Do not hardcode** the RC cost of `claim_account` or the creation fee — both are +> witness-tunable. Read live values: +> - `condenser_api.get_dynamic_global_properties` +> - `rc_api` for current RC cost of `claim_account` +> - witness `account_creation_fee` +> Size the HP stake from the measured claim rate you need to sustain (see §8). + +--- + +## 5. Endpoints to build + +### `GET /api/v1/hive/username-available/:name` +- Validate format: 3–16 chars, lowercase `a-z 0-9 -`, Hive naming rules (segments, no + leading/trailing/double `-`). +- Check on-chain availability via `condenser_api.get_accounts`. +- Response: `{ "available": boolean, "reason"?: string }` + +### `POST /api/v1/hive/create-account` +Request: +```jsonc +{ + "username": "alice", + "ownerKey": "STM…", // m/48'/13'/0'/0'/0' + "activeKey": "STM…", // m/48'/13'/1'/0'/0' + "postingKey": "STM…", // m/48'/13'/4'/0'/0' + "memoKey": "STM…", // m/48'/13'/3'/0'/0' + "attestation": { … } // device proof — see §6 +} +``` +Flow: +1. **Verify attestation** (§6). Reject if absent/invalid → `401`. +2. **Rate-limit** (§7). Over limit → `429`. +3. Validate all 4 keys are well-formed STM pubkeys; re-check username availability + format. +4. **Circuit-breaker:** if ACT pool < threshold → `503 { error: "queued", retryAfter }`. + Do **not** fall back to the cash fee. +5. Build + broadcast `create_claimed_account` from the sponsor (creator = sponsor account), + spending one ACT, with the 4 keys as authorities (`weight_threshold: 1`, single key auth). +6. Response: `{ "success": true, "txid": "…", "username": "alice" }`. + +### `GET /api/v1/hive/sponsor-info` +- Response: `{ "actPool": N, "rcPercent": 0–100, "hivePower": "…", "creatable": N }`. +- Powers UI warnings (vault greys "Create" when `creatable` is 0) **and** monitoring/alerts. + +--- + +## 6. Device attestation (RESOLVED — baseline signature is the gate) + +Decision was "only a genuine KeepKey." **Firmware 7.15.0 has no per-device hardware +attestation** Pioneer can verify (confirmed against the firmware tree 2026-06-26): +- U2F attestation cert is a *shared batch* cert (same key across many units, in the firmware + image) — can't identify a unit, not exposed for arbitrary signing. +- `GetFeatures.device_id` is a stored UUID — not CA-signed, host-spoofable. +- `OTP_MFG_SIG` is a one-time factory marker, not a signing oracle. +- No manufacturer/root-CA pubkey, endorsement key, or device-cert message exists. + +**v1 gate (implement this):** the request carries the payload the device signed during the +on-device `account_create` confirm (firmware `HiveSignAccountCreate` → 65-byte recoverable +sig). Pioneer recomputes the digest over the device-returned `serialized_tx`, recovers the +signer, asserts it equals `ownerKey`, and **parses the bytes to bind the signature to the +exact account name + 4 keys + sponsor** before spending an ACT. This proves control of a full +device-derived key set + a hardware confirmation. Combine with the §7 rate-limit and §5 +circuit-breaker. + +→ **Byte-exact spec: `HIVE-ATTESTATION-DIGEST-SPEC.md`** (digest, signature recovery, +`serialized_tx` layout, full verification algorithm). Implement against that, not from memory. +Note: the device signs `account_create` (op 9) as attestation; Pioneer broadcasts +`create_claimed_account` (op 23) signed by the sponsor — different op, no fee, spends an ACT. + +**Honest caveat:** a valid secp256k1 sig does **not** cryptographically prove genuine KeepKey +hardware — any software can derive keys and sign. So "KeepKey-owners-only" is enforced +*economically* (rate-limit + bounded ACT pool), not by hardware identity. If true hardware +gating is later required, it needs a **new firmware attestation message** (manufacturer +endorsement key + signed challenge) — track as a separate firmware feature, out of scope here. + +--- + +## 7. Abuse defense (none exists today) + +- Add rate-limit middleware (`express-rate-limit` or equiv) to `app.ts`, scoped to the create + route. Baseline: **1 success / IP / 24h** + a short burst limit. +- Username blacklist (reserved/abusive/impersonation names). +- **Circuit-breaker is also a defense:** refusing below the ACT threshold caps the blast + radius of any drain attempt. +- Log every create attempt (ip, username, attestation result, outcome) for forensics. + +--- + +## 8. Capacity planning (before staking capital) + +- Pick a target **accounts/day**. With KeepKey-owners-only gating, this is bounded by device + sales — size to that, not to an open internet. +- Measure live RC cost of `claim_account`, compute HP needed to sustain the target claim rate + with RC regen headroom, add buffer. Stake once; monitor. +- Alert thresholds: ACT pool low, RC% low, HP power-down initiated, create error-rate spike. + +--- + +## 9. Definition of done + +- [ ] `username-available`, `create-account`, `sponsor-info` live and registered in `routes.ts` +- [ ] Sponsor account funded + HP staked; active key via secrets (not env-in-git) +- [ ] ACT mint cron running; pool maintained +- [ ] Signature verification implemented (recover `account_create` sig → `ownerKey`; §6) +- [ ] Rate-limit middleware added; blacklist in place +- [ ] Circuit-breaker returns 503/queued below threshold — never the cash fee +- [ ] End-to-end (mainnet): vault wizard → create-account → `@username` resolves via existing + `GET /hive/account/:pubkey` +- [ ] Monitoring/alerts wired + +--- + +## 10. Vault-side contract (what calls you) + +The vault onboarding wizard (Phase 3, not yet built) will: +1. `hiveGetPublicKeys()` on device → 4 STM keys. +2. `GET /hive/username-available/:name` as the user types. +3. Device signs `account_create` (on-device confirm) → attestation payload. +4. `POST /hive/create-account` with the 4 keys + attestation. +5. Poll `GET /hive/account/:pubkey` until `@username` resolves → Hive card goes ACTIVE. + +Keep request/response shapes in §5 stable; the vault codes against them. diff --git a/modules/device-protocol b/modules/device-protocol index f2c3c005..98ca1e2f 160000 --- a/modules/device-protocol +++ b/modules/device-protocol @@ -1 +1 @@ -Subproject commit f2c3c005ad824df11aec9592a8b62066323ba282 +Subproject commit 98ca1e2fcb12af28c3cfa6b5ade969a237d46269 diff --git a/modules/hdwallet b/modules/hdwallet index c5a4d79b..d51727e4 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit c5a4d79b16de1231926c3270ffa75a1fb092c28e +Subproject commit d51727e4a5047c1676f73cf671e6269ba6163175 diff --git a/projects/keepkey-sdk/lib/index.d.ts b/projects/keepkey-sdk/lib/index.d.ts index ca87c5e6..27835644 100644 --- a/projects/keepkey-sdk/lib/index.d.ts +++ b/projects/keepkey-sdk/lib/index.d.ts @@ -1,5 +1,5 @@ import { VaultClient } from './client'; -import type { SdkConfig, DeviceFeatures, DeviceInfo, SignedTx, AddressRequest, EthSignTxParams, EthSignTypedDataParams, EthSignMessageParams, EthVerifyMessageParams, BtcSignTxParams, CosmosAminoSignParams, XrpSignTxParams, BnbSignTxParams, SolanaSignTxParams, TronSignTxParams, TonSignTxParams, GetPublicKeyRequest, BatchPubkeysPath, ApplySettingsParams, HealthResponse, SupportedAsset, PortfolioBalancesParams, MarketInfoParams, SearchAssetsParams, ListUnspentParams, PubkeyInfoParams, TxHistoryParams, BroadcastParams, NetworkIdParams, NetworkAddressParams, TokenDecimalsParams, StakingParams, SwapQuoteParams, SweepScanParams, SweepScanStatus, SweepExecuteParams, SweepExecuteResult } from './types'; +import type { SdkConfig, DeviceFeatures, DeviceInfo, SignedTx, AddressRequest, EthSignTxParams, EthSignTypedDataParams, EthSignMessageParams, EthVerifyMessageParams, BtcSignTxParams, CosmosAminoSignParams, XrpSignTxParams, BnbSignTxParams, SolanaSignTxParams, SolanaSignOffchainMessageParams, SolanaOffchainMessageSignatureResult, TronSignTxParams, TronSignMessageParams, TronMessageSignatureResult, TronVerifyMessageParams, TronSignTypedHashParams, TronTypedDataSignatureResult, TonSignTxParams, TonSignMessageParams, TonMessageSignatureResult, TonBuildTransferParams, TonBuildTransferResult, TonFinalizeTransferParams, TonFinalizeTransferResult, GetPublicKeyRequest, BatchPubkeysPath, ApplySettingsParams, HealthResponse, SupportedAsset, PortfolioBalancesParams, MarketInfoParams, SearchAssetsParams, ListUnspentParams, PubkeyInfoParams, TxHistoryParams, BroadcastParams, NetworkIdParams, NetworkAddressParams, TokenDecimalsParams, StakingParams, SwapQuoteParams, SweepScanParams, SweepScanStatus, SweepExecuteParams, SweepExecuteResult } from './types'; export { SdkError } from './client'; export * from './types'; /** @@ -125,6 +125,35 @@ export declare class KeepKeySdk { success: boolean; }>; }; + /** On-device cipher-recovery character entry (drives a RecoveryDevice flow). */ + recovery: { + /** + * Send one ciphered character during on-device cipher recovery. + * The device shows a scrambled keyboard on the OLED; the host relays the + * character the user "typed". A finalized word that isn't in the BIP-39 + * wordlist makes the in-flight `recoverDevice()` promise reject with + * "Word not found in BIP39 wordlist". + */ + sendCharacter: (character: string) => Promise<{ + success: boolean; + }>; + /** Delete the last character entered during cipher recovery. */ + sendCharacterDelete: () => Promise<{ + success: boolean; + }>; + /** Finalize cipher-recovery word/seed entry (equivalent to pressing "next"). */ + sendCharacterDone: () => Promise<{ + success: boolean; + }>; + /** Current cipher-recovery state. `seq` advances each time the device asks + * for the next character — poll it to sync sends with the device. */ + getRecoveryState: () => Promise<{ + active: boolean; + word_pos: number | null; + character_pos: number | null; + seq: number; + }>; + }; }; /** * Derive receive addresses on the device. Every method takes a BIP32 @@ -263,16 +292,81 @@ export declare class KeepKeySdk { solana: { /** Sign a Solana transaction. `raw_tx` must be the base64-encoded serialized transaction. */ solanaSignTransaction: (params: SolanaSignTxParams) => Promise; + /** + * Sign a Solana off-chain message with domain separation. Firmware + * builds the spec envelope (`\xff` || "solana offchain" || version || + * format || length || message) and Ed25519-signs it. NO AdvancedMode + * gate is needed — the envelope's leading `\xff` byte is invalid as a + * Solana transaction prefix, providing the domain separation that + * `solanaSignMessage` lacks. Format 2 (extended UTF-8) is rejected + * device-side; only formats 0 (ASCII) and 1 (UTF-8 limited, max 1212 + * bytes) are supported. Verifier MUST reconstruct the envelope locally + * and verify against it, NOT against the bare message. + */ + solanaSignOffchainMessage: (params: SolanaSignOffchainMessageParams) => Promise; }; /** TRON (TRX) signing, including TRC-20 tokens. */ tron: { /** Sign a TRON transaction. `amount` is in sun (1 TRX = 1,000,000 sun). */ tronSignTransaction: (params: TronSignTxParams) => Promise; + /** + * Sign a message under TIP-191 (TRON's analog of EIP-191 personal_sign): + * hash = keccak256("\x19TRON Signed Message:\n" + decimal(len) + msg) + * sig = secp256k1_sign(hash) → 65 bytes (r || s || 27+v) + * + * Pass `is_text=false` to send `message` as hex bytes; default treats + * it as UTF-8. + */ + tronSignMessage: (params: TronSignMessageParams) => Promise; + /** + * Verify a TIP-191 signature against the claimed Base58Check address. + * The device recovers the secp256k1 pubkey, derives the canonical + * TRON address, and compares it against `address`. Returns + * `{ verified: boolean }`. + */ + tronVerifyMessage: (params: TronVerifyMessageParams) => Promise<{ + verified: boolean; + }>; + /** + * TIP-712 typed-data signing in hash mode. Host pre-computes the + * domainSeparator + message hashes per the TIP-712 spec; the device + * assembles + * keccak256("\x19\x01" || domain_separator_hash || message_hash) + * and signs with secp256k1. Both hashes must be exactly 32 bytes; + * omit `message_hash` for primaryType="EIP712Domain". + */ + tronSignTypedHash: (params: TronSignTypedHashParams) => Promise; }; /** TON signing (supports Jettons). */ ton: { /** Sign a TON transaction. `raw_tx` must be the base64- or hex-encoded raw transaction. */ tonSignTransaction: (params: TonSignTxParams) => Promise; + /** + * Bare Ed25519 over message bytes. NO domain separation — firmware + * fences this behind the `AdvancedMode` policy. With the policy + * disabled (default) this call returns a Failure response. Returns + * the 32-byte Ed25519 public key + 64-byte signature, both hex. + * + * For TON Connect-style auth flows, prefer the upcoming `ton_proof` + * envelope (separate endpoint, not yet implemented) which carries + * proper domain separation and doesn't need the policy gate. + */ + tonSignMessage: (params: TonSignMessageParams) => Promise; + /** + * Build an unsigned TON v4R2 transfer. Fetches seqno and wallet + * state from TonCenter, constructs the body cell, and returns the + * 32-byte body hash the device should sign — the client never + * touches BOC/Cell internals. Echo the returned `build` object back + * to `tonFinalizeTransfer` after signing. + */ + tonBuildTransfer: (params: TonBuildTransferParams) => Promise; + /** + * Finalize a signed TON transfer: assembles the external message + * BOC from the prior `build` + the device's Ed25519 signature, then + * broadcasts via TonCenter. Pass `broadcast: false` to skip the + * broadcast and inspect/retry manually. + */ + tonFinalizeTransfer: (params: TonFinalizeTransferParams) => Promise; }; /** Extended public key (xpub) derivation — single and batch. */ xpub: { diff --git a/projects/keepkey-sdk/lib/index.d.ts.map b/projects/keepkey-sdk/lib/index.d.ts.map index d2ebb744..a3b21a44 100644 --- a/projects/keepkey-sdk/lib/index.d.ts.map +++ b/projects/keepkey-sdk/lib/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/projects/keepkey-sdk/lib/index.js b/projects/keepkey-sdk/lib/index.js index 87a7c0cd..58bebc18 100644 --- a/projects/keepkey-sdk/lib/index.js +++ b/projects/keepkey-sdk/lib/index.js @@ -69,7 +69,7 @@ class KeepKeySdk { /** Ping the device. Useful for connection checks. */ ping: () => this.client.post('/system/info/ping'), /** Wipe all secrets from the device. Requires user confirmation on device. */ - wipe: () => this.client.post('/system/wipe-device'), + wipe: () => this.client.post('/system/wipe-device', undefined, this.client.signingTimeoutMs), /** Change device label, passphrase protection, or auto-lock delay. */ applySettings: (params) => this.client.post('/system/apply-settings', params), /** Apply device policy changes. */ @@ -79,14 +79,32 @@ class KeepKeySdk { /** Clear the device session (forces PIN re-entry for the next sensitive call). */ clearSession: () => this.client.post('/system/clear-session'), /** Initialize a new device with a fresh seed. Requires user confirmation. */ - resetDevice: (params) => this.client.post('/system/initialize/reset-device', params), + resetDevice: (params) => this.client.post('/system/initialize/reset-device', params, this.client.signingTimeoutMs), /** Recover an existing device from a seed phrase. Requires user input on device. */ - recoverDevice: (params) => this.client.post('/system/initialize/recover-device', params), + recoverDevice: (params) => this.client.post('/system/initialize/recover-device', params, this.client.signingTimeoutMs), /** Load a device with a specific seed (testing only). */ - loadDevice: (params) => this.client.post('/system/initialize/load-device', params), + loadDevice: (params) => this.client.post('/system/initialize/load-device', params, this.client.signingTimeoutMs), /** Send a PIN entered via matrix input during a recovery flow. */ sendPin: (pin) => this.client.post('/system/recovery/pin', { pin }), }, + /** On-device cipher-recovery character entry (drives a RecoveryDevice flow). */ + recovery: { + /** + * Send one ciphered character during on-device cipher recovery. + * The device shows a scrambled keyboard on the OLED; the host relays the + * character the user "typed". A finalized word that isn't in the BIP-39 + * wordlist makes the in-flight `recoverDevice()` promise reject with + * "Word not found in BIP39 wordlist". + */ + sendCharacter: (character) => this.client.post('/system/recovery/character', { character }), + /** Delete the last character entered during cipher recovery. */ + sendCharacterDelete: () => this.client.post('/system/recovery/character/delete', {}), + /** Finalize cipher-recovery word/seed entry (equivalent to pressing "next"). */ + sendCharacterDone: () => this.client.post('/system/recovery/character/done', {}), + /** Current cipher-recovery state. `seq` advances each time the device asks + * for the next character — poll it to sync sends with the device. */ + getRecoveryState: () => this.client.get('/system/recovery/state'), + }, }; // ═══════════════════════════════════════════════════════════════════ // address — derive addresses on the device @@ -231,6 +249,18 @@ class KeepKeySdk { this.solana = { /** Sign a Solana transaction. `raw_tx` must be the base64-encoded serialized transaction. */ solanaSignTransaction: (params) => this.client.post('/solana/sign-transaction', params), + /** + * Sign a Solana off-chain message with domain separation. Firmware + * builds the spec envelope (`\xff` || "solana offchain" || version || + * format || length || message) and Ed25519-signs it. NO AdvancedMode + * gate is needed — the envelope's leading `\xff` byte is invalid as a + * Solana transaction prefix, providing the domain separation that + * `solanaSignMessage` lacks. Format 2 (extended UTF-8) is rejected + * device-side; only formats 0 (ASCII) and 1 (UTF-8 limited, max 1212 + * bytes) are supported. Verifier MUST reconstruct the envelope locally + * and verify against it, NOT against the bare message. + */ + solanaSignOffchainMessage: (params) => this.client.post('/solana/sign-offchain-message', params), }; // ═══════════════════════════════════════════════════════════════════ // tron — TRON signing @@ -239,6 +269,31 @@ class KeepKeySdk { this.tron = { /** Sign a TRON transaction. `amount` is in sun (1 TRX = 1,000,000 sun). */ tronSignTransaction: (params) => this.client.post('/tron/sign-transaction', params), + /** + * Sign a message under TIP-191 (TRON's analog of EIP-191 personal_sign): + * hash = keccak256("\x19TRON Signed Message:\n" + decimal(len) + msg) + * sig = secp256k1_sign(hash) → 65 bytes (r || s || 27+v) + * + * Pass `is_text=false` to send `message` as hex bytes; default treats + * it as UTF-8. + */ + tronSignMessage: (params) => this.client.post('/tron/sign-message', params), + /** + * Verify a TIP-191 signature against the claimed Base58Check address. + * The device recovers the secp256k1 pubkey, derives the canonical + * TRON address, and compares it against `address`. Returns + * `{ verified: boolean }`. + */ + tronVerifyMessage: (params) => this.client.post('/tron/verify-message', params), + /** + * TIP-712 typed-data signing in hash mode. Host pre-computes the + * domainSeparator + message hashes per the TIP-712 spec; the device + * assembles + * keccak256("\x19\x01" || domain_separator_hash || message_hash) + * and signs with secp256k1. Both hashes must be exactly 32 bytes; + * omit `message_hash` for primaryType="EIP712Domain". + */ + tronSignTypedHash: (params) => this.client.post('/tron/sign-typed-hash', params), }; // ═══════════════════════════════════════════════════════════════════ // ton — TON signing @@ -247,6 +302,32 @@ class KeepKeySdk { this.ton = { /** Sign a TON transaction. `raw_tx` must be the base64- or hex-encoded raw transaction. */ tonSignTransaction: (params) => this.client.post('/ton/sign-transaction', params), + /** + * Bare Ed25519 over message bytes. NO domain separation — firmware + * fences this behind the `AdvancedMode` policy. With the policy + * disabled (default) this call returns a Failure response. Returns + * the 32-byte Ed25519 public key + 64-byte signature, both hex. + * + * For TON Connect-style auth flows, prefer the upcoming `ton_proof` + * envelope (separate endpoint, not yet implemented) which carries + * proper domain separation and doesn't need the policy gate. + */ + tonSignMessage: (params) => this.client.post('/ton/sign-message', params), + /** + * Build an unsigned TON v4R2 transfer. Fetches seqno and wallet + * state from TonCenter, constructs the body cell, and returns the + * 32-byte body hash the device should sign — the client never + * touches BOC/Cell internals. Echo the returned `build` object back + * to `tonFinalizeTransfer` after signing. + */ + tonBuildTransfer: (params) => this.client.post('/ton/build-transfer', params), + /** + * Finalize a signed TON transfer: assembles the external message + * BOC from the prior `build` + the device's Ed25519 signature, then + * broadcasts via TonCenter. Pass `broadcast: false` to skip the + * broadcast and inspect/retry manually. + */ + tonFinalizeTransfer: (params) => this.client.post('/ton/finalize-transfer', params), }; // ═══════════════════════════════════════════════════════════════════ // xpub — public key operations diff --git a/projects/keepkey-sdk/lib/index.js.map b/projects/keepkey-sdk/lib/index.js.map index 6057c5bb..a4536f3d 100644 --- a/projects/keepkey-sdk/lib/index.js.map +++ b/projects/keepkey-sdk/lib/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/projects/keepkey-sdk/package.json b/projects/keepkey-sdk/package.json index 678e0eb6..f3f8dc9e 100644 --- a/projects/keepkey-sdk/package.json +++ b/projects/keepkey-sdk/package.json @@ -54,6 +54,7 @@ "author": "KeepKey", "license": "MIT", "devDependencies": { + "ethers": "^6.16.0", "typescript": "^5.5.0" } } diff --git a/projects/keepkey-sdk/src/index.ts b/projects/keepkey-sdk/src/index.ts index 36310784..f0fe0623 100644 --- a/projects/keepkey-sdk/src/index.ts +++ b/projects/keepkey-sdk/src/index.ts @@ -191,7 +191,7 @@ export class KeepKeySdk { /** Wipe all secrets from the device. Requires user confirmation on device. */ wipe: (): Promise<{ success: boolean }> => - this.client.post('/system/wipe-device'), + this.client.post('/system/wipe-device', undefined, this.client.signingTimeoutMs), /** Change device label, passphrase protection, or auto-lock delay. */ applySettings: (params: ApplySettingsParams): Promise<{ success: boolean }> => @@ -214,23 +214,49 @@ export class KeepKeySdk { word_count?: number; label?: string pin_protection?: boolean; passphrase_protection?: boolean }): Promise<{ success: boolean }> => - this.client.post('/system/initialize/reset-device', params), + this.client.post('/system/initialize/reset-device', params, this.client.signingTimeoutMs), /** Recover an existing device from a seed phrase. Requires user input on device. */ recoverDevice: (params: { word_count?: number; label?: string pin_protection?: boolean; passphrase_protection?: boolean }): Promise<{ success: boolean }> => - this.client.post('/system/initialize/recover-device', params), + this.client.post('/system/initialize/recover-device', params, this.client.signingTimeoutMs), /** Load a device with a specific seed (testing only). */ loadDevice: (params: any): Promise<{ success: boolean }> => - this.client.post('/system/initialize/load-device', params), + this.client.post('/system/initialize/load-device', params, this.client.signingTimeoutMs), /** Send a PIN entered via matrix input during a recovery flow. */ sendPin: (pin: string): Promise<{ success: boolean }> => this.client.post('/system/recovery/pin', { pin }), }, + + /** On-device cipher-recovery character entry (drives a RecoveryDevice flow). */ + recovery: { + /** + * Send one ciphered character during on-device cipher recovery. + * The device shows a scrambled keyboard on the OLED; the host relays the + * character the user "typed". A finalized word that isn't in the BIP-39 + * wordlist makes the in-flight `recoverDevice()` promise reject with + * "Word not found in BIP39 wordlist". + */ + sendCharacter: (character: string): Promise<{ success: boolean }> => + this.client.post('/system/recovery/character', { character }), + + /** Delete the last character entered during cipher recovery. */ + sendCharacterDelete: (): Promise<{ success: boolean }> => + this.client.post('/system/recovery/character/delete', {}), + + /** Finalize cipher-recovery word/seed entry (equivalent to pressing "next"). */ + sendCharacterDone: (): Promise<{ success: boolean }> => + this.client.post('/system/recovery/character/done', {}), + + /** Current cipher-recovery state. `seq` advances each time the device asks + * for the next character — poll it to sync sends with the device. */ + getRecoveryState: (): Promise<{ active: boolean; word_pos: number | null; character_pos: number | null; seq: number }> => + this.client.get('/system/recovery/state'), + }, } // ═══════════════════════════════════════════════════════════════════ diff --git a/projects/keepkey-sdk/tests/evm-firmware/eip1559-chainid-required.js b/projects/keepkey-sdk/tests/evm-firmware/eip1559-chainid-required.js new file mode 100644 index 00000000..f13c5cdc --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-firmware/eip1559-chainid-required.js @@ -0,0 +1,16 @@ +// Firmware EVM guard (alpha PR #255): an EIP-1559 (type-2) tx with chain_id == 0 +// over-declares the RLP list header (Stage-1 counts the chain_id field, Stage-2 +// hashes nothing) -> wrong signer, so the firmware must REJECT it. +// +// NOT REACHABLE via the Vault REST path: the Vault normalizes chainId 0 -> 1 +// before signing (rest-api.ts: `if (chainId === 0) chainId = 1`), so a type-2 +// tx with chain_id 0 never reaches the device through this API. The firmware +// guard itself is covered at the device level by python-keepkey +// (test_msg_ethereum_signing_guards). Kept as a documented skip so the rest of +// the evm-firmware suite stays green and the rationale is recorded next to it. +console.log('\n=== EIP-1559 chain_id 0 guard — SKIPPED (unreachable via Vault REST) ===\n') +console.log(' The Vault normalizes chainId 0 -> 1 before signing, so a type-2') +console.log(' chain_id 0 tx never reaches the device. The firmware guard is') +console.log(' validated by python-keepkey integration tests instead.') +console.log(' No device interaction performed.\n') +process.exit(0) diff --git a/projects/keepkey-sdk/tests/evm-firmware/eip1559-recover.js b/projects/keepkey-sdk/tests/evm-firmware/eip1559-recover.js new file mode 100644 index 00000000..1b2836e2 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-firmware/eip1559-recover.js @@ -0,0 +1,44 @@ +// Firmware EVM pre-image correctness (alpha PR #255). +// +// The "contract deployments won't sign" bug was an RLP pre-image defect: Stage-1 +// list length counted raw field bytes while Stage-2 hashed leading-zero-stripped +// bytes, so any tx with a leading-zero byte in nonce/gas/value/fee recovered to a +// WRONG/random address. EIP-1559 also: priority fee must always be hashed, +// chain_id is required. The strongest end-to-end check is: sign a spread of +// small/leading-zero-prone txs via the live Vault and verify EACH recovers to the +// device's own address. (Requires on-device approval per tx.) +// +// KEEPKEY_API_KEY= node tests/evm-firmware/eip1559-recover.js +const { run, ETH_PATH, CHAINS, toHex } = require('../_helpers') +const { Transaction } = require('ethers') + +run('EVM pre-image correctness — recovered signer == device address', async (getSdk, assert) => { + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + assert('Got device ETH address', address && address.startsWith('0x')) + const dev = address.toLowerCase() + + // Field shapes that historically broke the pre-image: zero value (empty/ + // leading-zero), small nonce, small gas, and EIP-1559 with NO priority fee. + const recipient = '0x000000000000000000000000000000000000dEaD' + const cases = [ + { label: 'legacy, nonce 5, value 0', tx: { + to: recipient, value: '0x0', data: '0x', nonce: '0x5', + gasLimit: '0x5208', gasPrice: toHex(20e9), chainId: CHAINS.ETH } }, + { label: 'legacy, small value (leading-zero-prone)', tx: { + to: recipient, value: '0x5', data: '0x', nonce: '0x1', + gasLimit: '0x5208', gasPrice: '0x100', chainId: CHAINS.ETH } }, + { label: 'EIP-1559, nonce 5, value 0', tx: { + to: recipient, value: '0x0', data: '0x', nonce: '0x5', gasLimit: '0x5208', + maxFeePerGas: toHex(30e9), maxPriorityFeePerGas: toHex(1.5e9), chainId: CHAINS.ETH } }, + { label: 'EIP-1559, NO priority fee (always-hash-priority fix)', tx: { + to: recipient, value: '0x0', data: '0x', nonce: '0x2', gasLimit: '0x5208', + maxFeePerGas: toHex(30e9), chainId: CHAINS.ETH } }, + ] + + for (const c of cases) { + const out = await sdk.eth.ethSignTransaction({ addressNList: ETH_PATH, from: address, ...c.tx }) + const parsed = Transaction.from(out.serialized || out.serializedTx) + assert(`${c.label} → recovers to device addr`, parsed.from && parsed.from.toLowerCase() === dev) + } +}) diff --git a/projects/keepkey-sdk/tests/evm-firmware/selltouniswap-offset.js b/projects/keepkey-sdk/tests/evm-firmware/selltouniswap-offset.js new file mode 100644 index 00000000..53981f21 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-firmware/selltouniswap-offset.js @@ -0,0 +1,51 @@ +// Firmware sellToUniswap offset validation (alpha PR #260, HIGH). The handler +// reads tokens[] at fixed offsets that are only correct when the dynamic-array +// head pointer (word0) is canonical (0x80). A non-canonical word0 lets the EVM +// decode a different array than the firmware displays (display/execution drain), +// so it is now rejected. +// +// Case (a) needs on-device approval; case (b) rejects before confirmation. +// KEEPKEY_API_KEY= node tests/evm-firmware/selltouniswap-offset.js +const { run, ETH_PATH, CHAINS, toHex } = require('../_helpers') +const { Transaction } = require('ethers') + +const ZX_EXCHANGE_PROXY = '0xdef1c0ded9bec7f1a1670819833240f027b25eff' // ZXSWAP_ADDRESS +const word = (h) => h.replace(/^0x/, '').padStart(64, '0') + +// sellToUniswap(address[] tokens, uint256 sellAmount, uint256 minBuyAmount, bool isSushi) +function sellToUniswap(tokensOffsetHex) { + return '0xd9627aa4' + + word(tokensOffsetHex) // word0: tokens[] head pointer + + word('3fb33ddbf39e4') // sellAmount + + word('155cbf') // minBuyAmount + + word('1') // isSushi + + word('2') // numTokens + + word('eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee') // tokens[0] (ETH placeholder) + + word('a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') // tokens[1] (USDC) +} + +const baseTx = { + addressNList: ETH_PATH, to: ZX_EXCHANGE_PROXY, value: '0x0', + nonce: '0x0', gasLimit: '0x26249', gasPrice: toHex(0x24c988ac00), chainId: CHAINS.ETH, +} + +run('sellToUniswap dynamic-array offset validation', async (getSdk, assert, assertThrows) => { + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + // Verify the blind-sign gate is active (AdvancedMode off — the device default). + const adv = (await sdk.system.info.getFeatures()).policies?.find(p => p.policy_name === 'AdvancedMode') + assert('AdvancedMode is OFF (blind-sign gate active)', !adv || adv.enabled === false) + + // (a) canonical word0 (0x80) → clear-signs (needs on-device approval) + const out = await sdk.eth.ethSignTransaction({ ...baseTx, from: address, data: sellToUniswap('80') }) + assert('canonical sellToUniswap signs', !!(out.serialized || out.serializedTx)) + const parsed = Transaction.from(out.serialized || out.serializedTx) + assert('recovers to device addr', parsed.from && parsed.from.toLowerCase() === address.toLowerCase()) + + // (b) non-canonical word0 (0xa0) → display/execution mismatch → rejected + let err + try { + await sdk.eth.ethSignTransaction({ ...baseTx, from: address, data: sellToUniswap('a0') }) + } catch (e) { err = e } + assertThrows('non-canonical tokens[] offset is rejected', err) +}) diff --git a/projects/keepkey-sdk/tests/evm-firmware/thorchain-router-pin.js b/projects/keepkey-sdk/tests/evm-firmware/thorchain-router-pin.js new file mode 100644 index 00000000..234b2525 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-firmware/thorchain-router-pin.js @@ -0,0 +1,53 @@ +// Firmware THORChain pin (alpha PR #261, CRITICAL). thor_isThorchainTx now pins +// the router (bumped to current v4 d37bbe..). A deposit to the real router +// clear-signs; a deposit to ANY other contract carrying the deposit selector is +// NOT clear-signed and — with AdvancedMode off — hits the blind-sign gate and is +// rejected (previously it bypassed the gate, the drain vector). +// +// Case (a) needs on-device approval; case (b) rejects before any confirmation. +// KEEPKEY_API_KEY= node tests/evm-firmware/thorchain-router-pin.js +const { run, ETH_PATH, CHAINS, toHex } = require('../_helpers') +const { Transaction } = require('ethers') + +const THOR_ROUTER_V4 = '0xd37bbe5744d730a1d98d8dc97c42f0ca46ad7146' +const word = (h) => h.replace(/^0x/, '').padStart(64, '0') + +// deposit(vault, asset(ETH=0), amount, memo) — memo "SWAP:BTC.BTC:0x..:420" +const depositCalldata = '0x1fece7b4' + + word('345b297ec83add7ff74d2f7933651bffa037d956') // asgard vault + + word('0') // asset = ETH native + + word('65945acd2b867ef000') // amount + + word('80') // memo offset (canonical) + + word('3b') // memo length (59) + + '535741503a4254432e4254433a30783431653535363030353438323465613662' + + '30373332653635366533616436346532306539346534353a3432300000000000' + +const baseTx = { + addressNList: ETH_PATH, value: toHex('0x65945acd2b867ef000'), + data: depositCalldata, nonce: '0x0', gasLimit: '0x186a0', + gasPrice: toHex(0x5fb9aca00), chainId: CHAINS.ETH, +} + +run('THORChain router pin — real router clear-signs, attacker contract blocked', async (getSdk, assert, assertThrows) => { + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + // Verify the blind-sign gate is active (AdvancedMode off — the device default). + const adv = (await sdk.system.info.getFeatures()).policies?.find(p => p.policy_name === 'AdvancedMode') + assert('AdvancedMode is OFF (blind-sign gate active)', !adv || adv.enabled === false) + + // (a) deposit to the pinned router → clear-signs (needs on-device approval) + const out = await sdk.eth.ethSignTransaction({ ...baseTx, from: address, to: THOR_ROUTER_V4 }) + assert('deposit to v4 router signs', !!(out.serialized || out.serializedTx)) + const parsed = Transaction.from(out.serialized || out.serializedTx) + assert('recovers to device addr', parsed.from && parsed.from.toLowerCase() === address.toLowerCase()) + + // (b) same calldata to an arbitrary (attacker) contract → must NOT clear-sign; + // with AdvancedMode off it is blind-sign-blocked and rejected. + let err + try { + await sdk.eth.ethSignTransaction({ + ...baseTx, from: address, to: '0x1234567890123456789012345678901234567890', + }) + } catch (e) { err = e } + assertThrows('deposit selector to non-router is rejected (no blind-sign bypass)', err) +}) diff --git a/projects/keepkey-sdk/tests/evm-firmware/transformerc20-clearsign.js b/projects/keepkey-sdk/tests/evm-firmware/transformerc20-clearsign.js new file mode 100644 index 00000000..e6d31f0c --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-firmware/transformerc20-clearsign.js @@ -0,0 +1,49 @@ +// Firmware clear-sign (alpha PR #260): 0x transformERC20 is pinned to the +// ExchangeProxy and bounded by its displayed input/min-output amounts, so it +// clear-signs at ANY calldata size WITHOUT AdvancedMode. The over-broad gate +// previously forced these (the transformations[] tail exceeds one 1024-byte +// chunk) onto the blind-sign path. With AdvancedMode OFF, a correct firmware +// clear-signs it; a regressed one would blind-sign-block and throw. +// +// Requires on-device approval of the "Transform ERC20" screen. +// KEEPKEY_API_KEY= node tests/evm-firmware/transformerc20-clearsign.js +const { run, ETH_PATH, CHAINS, toHex } = require('../_helpers') +const { Transaction } = require('ethers') + +const ZX_EXCHANGE_PROXY = '0xdef1c0ded9bec7f1a1670819833240f027b25eff' // ZXSWAP_ADDRESS +const USDT = 'dac17f958d2ee523a2206206994597c13d831ec7' +const USDC = 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +const word = (h) => h.replace(/^0x/, '').padStart(64, '0') + +// transformERC20(inputToken, outputToken, inputAmount, minOutput, transformations[]) +// Padded so total calldata > 1024 bytes (streams to the device in chunks), +// exercising the "clear-sign at any size" path. The transformations blob is +// inert (not broadcast); the firmware only reads/display the 4 head words. +const transformERC20 = '0x415565b0' + + word(USDT) // inputToken + + word(USDC) // outputToken + + word('0c5c360b9c') // inputTokenAmount + + word('0c58cb06ec') // minOutputTokenAmount + + word('a0') // transformations offset (canonical 0xa0) + + word('1') // transformations length + + '00'.repeat(32 * 30) // pad past 1024 bytes + +run('transformERC20 clear-signs without AdvancedMode (any size)', async (getSdk, assert) => { + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + + // Verify the blind-sign gate is active (AdvancedMode off — the device default). + const adv = (await sdk.system.info.getFeatures()).policies?.find(p => p.policy_name === 'AdvancedMode') + assert('AdvancedMode is OFF (blind-sign gate active)', !adv || adv.enabled === false) + + const out = await sdk.eth.ethSignTransaction({ + addressNList: ETH_PATH, from: address, + to: ZX_EXCHANGE_PROXY, value: '0x0', data: transformERC20, + nonce: '0x1', gasLimit: '0x5140e', + maxFeePerGas: toHex(30e9), maxPriorityFeePerGas: toHex(1.5e9), chainId: CHAINS.ETH, + }) + assert('signed (clear-signed, not blind-blocked)', !!(out.serialized || out.serializedTx)) + const parsed = Transaction.from(out.serialized || out.serializedTx) + assert('recovers to device addr', parsed.from && parsed.from.toLowerCase() === address.toLowerCase()) + assert('calldata exceeded one chunk (>1024 bytes)', (transformERC20.length - 2) / 2 > 1024) +}) diff --git a/projects/keepkey-sdk/tests/evm-firmware/uniswap-liquidity-recipient.js b/projects/keepkey-sdk/tests/evm-firmware/uniswap-liquidity-recipient.js new file mode 100644 index 00000000..ccc97693 --- /dev/null +++ b/projects/keepkey-sdk/tests/evm-firmware/uniswap-liquidity-recipient.js @@ -0,0 +1,55 @@ +// Firmware liquidity recipient guard (alpha PR #260, MEDIUM). addLiquidityETH +// routes the LP tokens to its `to` recipient. A non-self recipient was only +// soft-warned; it now fails closed. Recipient == signer signs; a third-party +// recipient is rejected. +// +// Both cases prompt on-device (the handler shows its liquidity screens). For +// case (b) the device shows "Liquidity recipient is NOT this wallet" and then +// refuses to sign — approve the screens and confirm it ends in rejection. +// KEEPKEY_API_KEY= node tests/evm-firmware/uniswap-liquidity-recipient.js +const { run, ETH_PATH, CHAINS, toHex } = require('../_helpers') +const { Transaction } = require('ethers') + +const UNISWAP_V2_ROUTER = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D' // UNISWAP_ROUTER_ADDRESS +const USDC = 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +const word = (h) => h.replace(/^0x/, '').padStart(64, '0') + +// addLiquidityETH(token, amountTokenDesired, amountTokenMin, amountETHMin, to, deadline) +function addLiquidityETH(recipient) { + return '0xf305d719' + + word(USDC) // token + + word('5f5e100') // amountTokenDesired + + word('5b8d80') // amountTokenMin + + word('38d7ea4c68000') // amountETHMin + + word(recipient) // to (LP recipient) + + word('ffffffff') // deadline +} + +const baseTx = { + addressNList: ETH_PATH, to: UNISWAP_V2_ROUTER, value: toHex(0.05e18), + nonce: '0x0', gasLimit: '0x30000', gasPrice: toHex(20e9), chainId: CHAINS.ETH, +} + +run('Uniswap addLiquidityETH recipient must be the signer', async (getSdk, assert, assertThrows) => { + const sdk = await getSdk() + const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH }) + // Verify the blind-sign gate is active (AdvancedMode off — the device default). + const adv = (await sdk.system.info.getFeatures()).policies?.find(p => p.policy_name === 'AdvancedMode') + assert('AdvancedMode is OFF (blind-sign gate active)', !adv || adv.enabled === false) + + // (a) recipient == device address → signs (approve on-device) + const out = await sdk.eth.ethSignTransaction({ ...baseTx, from: address, data: addLiquidityETH(address) }) + assert('self-recipient liquidity signs', !!(out.serialized || out.serializedTx)) + const parsed = Transaction.from(out.serialized || out.serializedTx) + assert('recovers to device addr', parsed.from && parsed.from.toLowerCase() === address.toLowerCase()) + + // (b) recipient == a third party → fail closed (rejected after the recipient screen) + let err + try { + await sdk.eth.ethSignTransaction({ + ...baseTx, from: address, + data: addLiquidityETH('000000000000000000000000000000000000dEaD'), + }) + } catch (e) { err = e } + assertThrows('non-self liquidity recipient is rejected', err) +}) diff --git a/projects/keepkey-sdk/tests/recovery/load-verify.js b/projects/keepkey-sdk/tests/recovery/load-verify.js new file mode 100644 index 00000000..1c231677 --- /dev/null +++ b/projects/keepkey-sdk/tests/recovery/load-verify.js @@ -0,0 +1,92 @@ +/** + * recovery/load-verify.js — Wipe, load a RANDOM BIP-39 seed, and verify the + * device derives the SAME ETH address an independent library (ethers v6) does. + * + * Exercises WipeDevice + LoadDevice + address derivation across word counts. + * LoadDevice is production-available (fsm_msgLoadDevice is gated only by + * CHECK_NOT_INITIALIZED + an on-device confirm — NOT #if DEBUG_LINK), so this + * runs against the real flashed RC. + * + * DESTRUCTIVE: this WIPES the connected device. Use a TEST device only. + * HUMAN-IN-LOOP: each wipe/load blocks until you press Confirm on the device. + * + * Robustness: WipeDevice (and possibly LoadDevice) reboots the device, so the + * REST/USB connection drops mid-call (UND_ERR_SOCKET "other side closed"). That + * is EXPECTED — we swallow the dropped call and then poll getFeatures() until the + * device re-enumerates and reports the intended state, so correctness is asserted + * from device STATE, not from the (unreliable) call return. + * + * NOT covered: recovery_cipher.c per-word/dry-run/wipe-on-failure (#272) — that's + * the on-device CIPHER flow (recoverDevice), see the G1-G12 manual matrix. + * + * Run: node tests/run-all.js recovery (vault must serve localhost:1646) + */ +const { run } = require('../_helpers') +const { Mnemonic, HDNodeWallet, randomBytes } = require('ethers') + +const ETH_PATH = [0x80000000 + 44, 0x80000000 + 60, 0x80000000, 0, 0] +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +const randomMnemonic = (entropyBytes) => Mnemonic.fromEntropy(randomBytes(entropyBytes)).phrase +const expectedEth = (mnemonic) => HDNodeWallet.fromPhrase(mnemonic).address.toLowerCase() + +/** + * Poll getFeatures() until it returns and `.initialized === wantInitialized`, + * tolerating the connection drops a wipe/load reboot causes. Returns the + * features object on success, or null on timeout. + */ +async function waitForState(sdk, wantInitialized, timeoutMs = 45000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const f = await sdk.system.info.getFeatures() + if (f && f.initialized === wantInitialized) return f + } catch (_) { /* device re-enumerating after reboot */ } + await sleep(1500) + } + return null +} + +/** Wipe, tolerating the reboot drop; resolves once the device is back + uninitialized. */ +async function robustWipe(sdk) { + try { await sdk.system.device.wipe() } catch (_) { /* reboot drops the call */ } + return waitForState(sdk, false) +} + +/** Load a mnemonic, tolerating any reboot drop; resolves once back + initialized. */ +async function robustLoad(sdk, mnemonic, label) { + try { await sdk.system.device.loadDevice({ mnemonic, label }) } catch (_) { /* may re-enumerate */ } + return waitForState(sdk, true) +} + +run('Recovery: wipe + load random seed, verify derivation', async (getSdk, assert) => { + const sdk = await getSdk() + + // 1 — 12-word random seed: device must match independent (ethers) derivation + const m12 = randomMnemonic(16) + console.log(` 12-word seed: ${m12}`) + assert('wipe -> device uninitialized', !!(await robustWipe(sdk))) + assert('load 12-word -> device initialized', !!(await robustLoad(sdk, m12, 'rc-12'))) + const a12 = (await sdk.address.ethGetAddress({ address_n: ETH_PATH })).address.toLowerCase() + console.log(` device: ${a12} ethers: ${expectedEth(m12)}`) + assert('12-word: device ETH addr == ethers derivation', a12 === expectedEth(m12)) + + // 2 — 24-word random seed: matches, and differs from the 12-word seed + const m24 = randomMnemonic(32) + console.log(` 24-word seed: ${m24}`) + await robustWipe(sdk) + assert('load 24-word -> device initialized', !!(await robustLoad(sdk, m24, 'rc-24'))) + const a24 = (await sdk.address.ethGetAddress({ address_n: ETH_PATH })).address.toLowerCase() + console.log(` device: ${a24} ethers: ${expectedEth(m24)}`) + assert('24-word: device ETH addr == ethers derivation', a24 === expectedEth(m24)) + assert('different seeds derive different addresses', a12 !== a24) + + // 3 — Edge: invalid-checksum mnemonic must be rejected (device stays uninitialized). + // A socket error alone isn't proof of rejection (load may reboot too), so we + // assert from STATE: after the bad load the device must still be uninitialized. + const badMnemonic = Array(12).fill('abandon').join(' ') // valid words, bad BIP-39 checksum + await robustWipe(sdk) + try { await sdk.system.device.loadDevice({ mnemonic: badMnemonic, skip_checksum: false }) } catch (_) {} + const stillUninit = await waitForState(sdk, false, 15000) + assert('invalid-checksum mnemonic rejected (device stays uninitialized)', !!stillUninit) +}) diff --git a/projects/keepkey-sdk/tests/recovery/wrong-word.js b/projects/keepkey-sdk/tests/recovery/wrong-word.js new file mode 100644 index 00000000..913ffb88 --- /dev/null +++ b/projects/keepkey-sdk/tests/recovery/wrong-word.js @@ -0,0 +1,93 @@ +/** + * recovery/wrong-word.js — firmware #272 area: during cipher recovery the device + * must REJECT invalid input rather than accept garbage as a seed. + * + * We can't enter a specific seed without reading the device's scrambled cipher + * (OLED-only on production firmware). We don't need to: sending 5 identical + * ciphered characters is never a valid cipher-entered BIP-39 word, so the + * firmware rejects it. Observed on a 7.15.0 device, the rejection is: + * "Words were not entered correctly. Make sure you are using the substitution + * cipher." (the anti-non-cipher guard; the per-word "Word not found in BIP39 + * wordlist" path is the other valid rejection.) Either proves garbage is + * refused — that's the security property. + * + * The rejection can surface on the sendCharacter() that finalizes the word OR on + * the in-flight recoverDevice() promise, so we catch both. + * + * DESTRUCTIVE: needs an uninitialized device; wipes only if currently initialized + * (a wipe reboots the device and churns the USB transport — avoid when we can). + * HUMAN-IN-LOOP: approve pairing (first run) + Confirm "Recover device?". + * NOTE: on the unsigned RC, a wipe reboots into the "unofficial firmware" gate; + * prefer starting from an already-empty device, and replug if comms wedge. + * + * Requires the REST recovery endpoints (POST /system/recovery/character{,/delete, + * /done}, GET /system/recovery/state). Run: node tests/run-all.js recovery + */ +const { run } = require('../_helpers') +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function waitUninitialized(sdk, timeoutMs = 30000) { + const end = Date.now() + timeoutMs + while (Date.now() < end) { + try { const f = await sdk.system.info.getFeatures(); if (f && f.initialized === false) return true } catch (_) {} + await sleep(1500) + } + return false +} + +/** Wait until the recovery seq advances past `fromSeq` (device asked for the next + * character). Returns the new seq, or null on timeout. */ +async function waitSeqAdvance(sdk, fromSeq, timeoutMs) { + const end = Date.now() + timeoutMs + while (Date.now() < end) { + try { const s = await sdk.system.recovery.getRecoveryState(); if (s && s.seq > fromSeq) return s.seq } catch (_) {} + await sleep(500) + } + return null +} + +run('Recovery #272: invalid input rejected during cipher recovery', async (getSdk, assert) => { + const sdk = await getSdk() + + // Recovery needs an uninitialized device. Only wipe if it isn't already empty + // (the wipe reboots the device and tends to wedge the USB transport). + const f0 = await sdk.system.info.getFeatures() + if (f0.initialized) { + try { await sdk.system.device.wipe() } catch (_) {} + assert('device uninitialized (after wipe)', await waitUninitialized(sdk)) + } else { + assert('device already uninitialized', true) + } + + const baseSeq = (await sdk.system.recovery.getRecoveryState()).seq + + // Begin cipher recovery (do NOT await — resolves/rejects when entry finishes). + const recovery = sdk.system.device.recoverDevice({ + word_count: 12, pin_protection: false, passphrase_protection: false, + }) + // The rejection may land on this promise instead of a sendCharacter call. + let recPromiseErr = null + recovery.catch((e) => { recPromiseErr = e }) + + // Generous — this blocks on the human Confirm. >>> CONFIRM "Recover device?". + const seq0 = await waitSeqAdvance(sdk, baseSeq, 60000) + assert('device entered cipher recovery (CharacterRequest received)', seq0 !== null) + + // Enter a guaranteed-invalid word: 5 identical ciphered chars + a separator. + let seq = seq0 + let rejErr = null + try { + for (let i = 0; i < 5 && seq !== null; i++) { + await sdk.system.recovery.sendCharacter('a') + seq = await waitSeqAdvance(sdk, seq, 8000) + } + await sdk.system.recovery.sendCharacter(' ') // finalize -> firmware validation + await recovery + } catch (e) { rejErr = e } + + const err = rejErr || recPromiseErr + const msg = err ? String(err.message || JSON.stringify(err)) : '' + console.log(` recovery rejection -> ${msg.slice(0, 130) || 'NONE — device accepted garbage!'}`) + assert('invalid recovery input rejected on-device', + /word not found|wordlist|not entered correctly|substitution cipher/i.test(msg)) +}) diff --git a/projects/keepkey-sdk/tests/thorchain/ruji.js b/projects/keepkey-sdk/tests/thorchain/ruji.js new file mode 100644 index 00000000..1d98001e --- /dev/null +++ b/projects/keepkey-sdk/tests/thorchain/ruji.js @@ -0,0 +1,101 @@ +/** + * THORChain RUJI / secured-asset / custom-denom signing tests. + * + * Proves (against the running Vault + whatever device/emulator is attached) + * that the firmware signs THORChain `MsgSend` with ARBITRARY denoms (TCY, + * RUJIRA, secured assets like `btc-btc`) and `MsgDeposit` with arbitrary memos + * — the two operations RUJI / secured assets actually need. + * + * It also asserts the firmware's denom charset guard rejects injection denoms. + * + * Run: node tests/thorchain/ruji.js (Vault must be live on :1646) + */ +const { run } = require('../_helpers') + +// m/44'/931'/0'/0/0 +const THOR_PATH = [0x80000000 + 44, 0x80000000 + 931, 0x80000000, 0, 0] +const CHAIN_ID = 'thorchain-1' + +const sig = (r) => r && (r.signature || r.serialized || r.serializedTx) + +function transferDoc(from, denom) { + return { + fee: { gas: '500000000', amount: [{ denom: 'rune', amount: '0' }] }, + msgs: [{ + type: 'thorchain/MsgSend', + value: { + from_address: from, + to_address: from, // self-send: we only care that it signs + amount: [{ denom, amount: '1000000' }], + }, + }], + memo: '', + sequence: '0', + chain_id: CHAIN_ID, + account_number: '0', + } +} + +function depositDoc(from, asset, memo) { + return { + fee: { gas: '500000000', amount: [{ denom: 'rune', amount: '0' }] }, + msgs: [{ + type: 'thorchain/MsgDeposit', + value: { + coins: [{ asset, amount: '1000000' }], + memo, + signer: from, + }, + }], + memo, + sequence: '0', + chain_id: CHAIN_ID, + account_number: '0', + } +} + +run('thorchain RUJI / secured-asset / custom-denom signing', async (getSdk, assert, assertThrows) => { + const sdk = await getSdk() + + // ── Address sanity ────────────────────────────────────────────────── + const { address } = await sdk.address.thorchainGetAddress({ address_n: THOR_PATH, show_display: false }) + assert(`derives thor address (${address})`, !!address && address.startsWith('thor')) + + // ── MsgSend with arbitrary denoms (the RUJI / secured-asset feature) ─ + // 'rune' is the baseline; the rest exercise firmware "allow any denom". + const denoms = [ + ['rune', 'baseline RUNE'], + ['tcy', 'TCY token'], + ['rujira', 'RUJI / Rujira token'], + ['btc-btc', 'secured asset BTC-BTC'], + ['eth-usdc', 'secured asset ETH-USDC'], + ] + for (const [denom, label] of denoms) { + let res, err + try { res = await sdk.thorchain.thorchainSignAminoTransfer({ signDoc: transferDoc(address, denom), signerAddress: address }) } + catch (e) { err = e } + assert(`MsgSend signs denom "${denom}" (${label})`, !err && !!sig(res)) + if (err) console.error(` ↳ ${denom}: ${String(err.message || err).slice(0, 160)}`) + } + + // ── MsgDeposit with memo (mint/redeem/trade routing) ──────────────── + let depRes, depErr + try { + depRes = await sdk.thorchain.thorchainSignAminoDeposit({ + signDoc: depositDoc(address, 'THOR.RUNE', 'SECURE+:BTC.BTC'), + signerAddress: address, + }) + } catch (e) { depErr = e } + assert('MsgDeposit signs with secured-asset memo', !depErr && !!sig(depRes)) + if (depErr) console.error(` ↳ deposit: ${String(depErr.message || depErr).slice(0, 160)}`) + + // ── Negative: firmware denom charset guard rejects injection ──────── + let injErr + try { + await sdk.thorchain.thorchainSignAminoTransfer({ + signDoc: transferDoc(address, 'rune"},{"x'), + signerAddress: address, + }) + } catch (e) { injErr = e } + assertThrows('rejects JSON-injection denom (charset guard)', injErr) +}) diff --git a/projects/keepkey-vault/__tests__/chain-scan.test.ts b/projects/keepkey-vault/__tests__/chain-scan.test.ts index ef0e1af9..87640561 100644 --- a/projects/keepkey-vault/__tests__/chain-scan.test.ts +++ b/projects/keepkey-vault/__tests__/chain-scan.test.ts @@ -216,6 +216,7 @@ describe('chainSupportsLevelScan (per-account single-address scan)', () => { describe('utxoAccountScriptPaths — per-account xpub paths for UTXO altcoins', () => { const DOGE = { id: 'dogecoin', scriptType: 'p2pkh', defaultPath: [0x8000002C, 0x80000003, 0x80000000, 0, 0] } as ChainDef + // Real production LTC def: BIP84 native segwit (p2wpkh on 84'). const LTC = { id: 'litecoin', scriptType: 'p2wpkh', defaultPath: [0x80000054, 0x80000002, 0x80000000, 0, 0] } as ChainDef test('single script type (DOGE p2pkh/44) varies only the account element, keeps coinType 3', () => { @@ -223,10 +224,17 @@ describe('utxoAccountScriptPaths — per-account xpub paths for UTXO altcoins', expect(utxoAccountScriptPaths(DOGE, 2)).toEqual([{ scriptType: 'p2pkh', path: [0x8000002C, 0x80000003, 0x80000002] }]) }) - test('Litecoin walks all three script types (purposes 44/49/84) at coinType 2', () => { + test('Litecoin walks all three script types (44/49/84) at coinType 2, plus the historical p2wpkh-on-44 branch', () => { const r = utxoAccountScriptPaths(LTC, 1) - expect(r.map(x => x.scriptType)).toEqual(['p2pkh', 'p2sh-p2wpkh', 'p2wpkh']) - expect(r.map(x => x.path[0])).toEqual([0x8000002C, 0x80000031, 0x80000054]) // 44'/49'/84' + expect(r.map(x => x.scriptType)).toEqual(['p2pkh', 'p2sh-p2wpkh', 'p2wpkh', 'p2wpkh']) + expect(r.map(x => x.path[0])).toEqual([0x8000002C, 0x80000031, 0x80000054, 0x8000002C]) // 44'/49'/84'/44' expect(r.every(x => x.path[1] === 0x80000002 && x.path[2] === 0x80000001)).toBe(true) // coin 2, account 1 + // Historical entry must be LAST: the (device,chain,path)-keyed pubkey + // cache upsert keeps the final write when it shares m/44'/2'/N' with p2pkh. + expect(r[r.length - 1]).toEqual({ scriptType: 'p2wpkh', path: [0x8000002C, 0x80000002, 0x80000001] }) + }) + + test('a chain whose receive convention matches a standard entry gets no duplicate', () => { + expect(utxoAccountScriptPaths(DOGE, 0)).toHaveLength(1) }) }) diff --git a/projects/keepkey-vault/docs/scan-screen-permissions.md b/projects/keepkey-vault/docs/scan-screen-permissions.md new file mode 100644 index 00000000..287be602 --- /dev/null +++ b/projects/keepkey-vault/docs/scan-screen-permissions.md @@ -0,0 +1,61 @@ +# "Scan Screen" QR option — permissions, entitlements, dev vs prod + +The QR overlay has three inputs: **camera** (getUserMedia), **image file**, and +**scan screen**. Scan-screen is implemented natively (`src/bun/screen-capture.ts`): +Bun minimizes the window, screenshots every display with the OS screenshot tool, +and the webview decodes the PNGs with jsQR. + +## Why not getDisplayMedia in the webview + +WKWebView only honors `getDisplayMedia` when the embedding app implements **no** +media-capture delegate. Electrobun implements +`webView:requestMediaCapturePermissionForOrigin:` for the camera, which makes +WebKit auto-deny display capture unless a **private** delegate +(`_webView:requestDisplayCapturePermissionForOrigin:`) is also implemented +(see WebKit's macOS Sonoma change for `getDisplayMedia` in WKWebView). Patching +private WebKit API into Electrobun's native wrapper is fragile; the native +screenshot path works identically on all three platforms. + +## macOS (the pain point) + +- **Permission**: Screen Recording, System Settings → Privacy & Security → + Screen Recording. It is **pure TCC** — there is *no* codesign entitlement for + screen capture (nothing to add to `electrobun.config.ts` `entitlements`). + The sandbox is not in play (we ship Developer ID + hardened runtime, not MAS). +- **Info.plist**: `NSScreenCaptureUsageDescription` purpose string — injected by + `scripts/patch-electrobun.sh` (same mechanism as `NSCameraUsageDescription`), + so dev and prod bundles both get it. +- **Prompting**: `CGPreflightScreenCaptureAccess()` checks silently; + `CGRequestScreenCaptureAccess()` registers the app in the Screen Recording + list and shows the system prompt **once**. Both are called via `bun:ffi` + (CoreGraphics). On refusal we deep-link the exact pane: + `x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture`. +- **Relaunch**: after the user grants, macOS requires an app relaunch before the + in-process preflight reports granted. The UI copy says so. +- **Unauthorized behavior** (verified on macOS 26): `screencapture` exits 1 with + "could not create image from display" — it no longer silently captures a + wallpaper-only image, so a permission gap can't produce a bogus "no QR found". +- **Dev builds**: TCC keys the grant to the *responsible process*. `bun dev` + spawns the launcher from your terminal, so the grant lands on + **Terminal/iTerm/WebStorm**, not "KeepKey Vault". Grant Screen Recording to + your terminal once and every dev rebuild works — this also dodges the + ad-hoc-signing problem where TCC grants die on each rebuild (the code + directory hash changes). Prod builds are Developer ID signed with a stable + identity, so the grant sticks. +- **macOS 15+ re-approval**: Sequoia/Tahoe periodically re-confirm screen + recording grants ("KeepKey Vault can access your screen — continue to + allow?"). Expected; one-shot capture needs no + `com.apple.developer.persistent-content-capture` (that's Apple-approved, + always-on capture apps only). + +## Windows + +No OS permission gate. Capture uses PowerShell `Graphics.CopyFromScreen` per +monitor. (WebView2 does support `getDisplayMedia`, but the native path keeps one +code path for all platforms.) + +## Linux + +First available tool wins: `grim` (Wayland), `gnome-screenshot`, `spectacle`, +`import` (X11/ImageMagick). Wayland compositors may show their own one-time +consent dialog. No tool installed → the UI shows an install hint. diff --git a/projects/keepkey-vault/docs/zcash-shielded-device-smoke.md b/projects/keepkey-vault/docs/zcash-shielded-device-smoke.md new file mode 100644 index 00000000..be127f4d --- /dev/null +++ b/projects/keepkey-vault/docs/zcash-shielded-device-smoke.md @@ -0,0 +1,84 @@ +# Zcash shielded — real-device smoke checklist (stable-promotion gate) + +**Status for v1.4.6:** ⛔ NOT YET RUN. v1.4.6 shipped as a **prerelease** validated entirely +by local/static checks (Orchard proof verify, BatchValidator, sighash-divergence, `cargo +audit`, notarization, backend boot smoke). **Do not promote v1.4.6 from prerelease → +`--latest`/stable until this checklist passes on a physical KeepKey.** + +## Why this gate exists + +The shielded send/scan core is a Rust sidecar that builds + finalizes Orchard PCZTs. Every +guard we added (local proof verification, BatchValidator, the device-signed-vs-consensus +sighash check, the per-flow transparent digest) runs **locally** — and the NU6.2 incident +proved that a tx can pass every local check and still be rejected by every node on the +network (pre-fork proofs validated locally but were rejected on broadcast). **Only a real +broadcast that gets mined confirms correctness.** Equally, the new fail-closed validation +could *false-abort* a legitimate send (e.g. a wrong transparent-digest assumption) — which +also only shows up when a real send either completes or refuses on hardware. + +A prerelease is the correct vehicle for getting this in front of a real device. Stable is not. + +## Preconditions + +- A physical KeepKey on firmware **≥ 7.15.0** (Orchard support). +- Vault **v1.4.6** (the prerelease build, not a dev build). +- A wallet holding a small amount of **shielded ZEC** and a small amount of **transparent ZEC**. +- Mainnet; at least one lightwalletd node reachable. +- A second shielded (Orchard/unified) address and a transparent address to send to (the + device's own next address is fine). + +## Checklist + +> Each item lists the code path it exercises and the specific risk it closes. Record the +> on-chain txid for every broadcast so a reviewer can independently confirm it mined. + +- [ ] **1. Shielded balance is device-verified.** Open the Privacy tab. The shielded balance + shows and matches the explorer. + _Exercises:_ `ensureZcashDeviceMatch` + the fail-closed balance gate. + _Pass:_ balance shown; no "not verified against the connected device" error for the + connected wallet. + +- [ ] **2. z→z shielded send.** Send a small amount shielded→shielded. + _Exercises:_ `build_pczt` + `finalize_pczt` (Orchard proof verify + BatchValidator + + consensus-sighash check) + multi-node broadcast. + _Pass:_ device OLED shows recipient + amount; physical button required; tx broadcasts and + **mines** (no "could not validate orchard proof"); the validation did **not** false-abort + a legitimate send. Record txid: `__________`. + +- [ ] **3. Shield (t→z).** Move transparent ZEC into a shielded note. + _Exercises:_ `build_shield_pczt` + `finalize_shield_pczt` (hybrid + `validate_hybrid_orchard_consensus` with transparent **inputs + outputs**). + _Pass:_ device confirm; broadcasts and mines; no false-abort. Record txid: `__________`. + +- [ ] **4. Deshield (z→t).** Move shielded ZEC out to a transparent address. + _Exercises:_ `build_deshield_pczt` (per-spend Merkle-root==anchor guard) + + `finalize_deshield_pczt` (hybrid validation, transparent **outputs only**). + _Pass:_ device confirm; broadcasts and mines; no false-abort. Record txid: `__________`. + +- [ ] **5. Anti-bleed across a passphrase / hidden-wallet toggle.** With a device-verified + shielded balance showing, activate a hidden wallet (passphrase) on the **same** device. + _Exercises:_ `resetSeedManagers` flag reset (FS-1) + forced spend-path re-derive. + _Pass:_ the shielded balance does **not** keep showing the previous wallet's value — it + fail-closes / re-derives for the active wallet. A send while in the hidden wallet builds + against the **active** wallet's notes, not the previous wallet's. + +- [ ] **6. Address book in private send.** Save a shielded recipient, then reuse it from the + picker on a subsequent send. + _Pass:_ saved address round-trips and sends to the correct recipient. + +- [ ] **7. Memo + amount bounds (sanity).** Send with a normal memo; confirm the memo on the + explorer matches what was typed. Attempt an absurd amount. + _Pass:_ memo on chain == memo entered (no silent truncation); over-supply / non-positive + amounts are rejected before signing. + +## If any item fails + +Do **not** promote v1.4.6 to stable. File the failure (with the txid / device behavior), +fix on `develop`, cut a `release/1.4.x` patch, and re-run this checklist. The published +prerelease can stay up for continued testing. + +## On pass + +Promote: `gh release edit v1.4.6 --repo keepkey/keepkey-vault --latest --prerelease=false` +(keep this checklist's completed copy, with txids, attached to the release or PR for the +audit trail). diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 3aa50433..e60cade6 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.4.6", + "version": "1.4.10", "description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun", "scripts": { "dev": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && electrobun build && bun scripts/patch-bundle.ts && electrobun dev", @@ -22,10 +22,9 @@ "@keepkey/hdwallet-keepkey-nodehid": "file:../../modules/hdwallet/packages/hdwallet-keepkey-nodehid", "@keepkey/hdwallet-keepkey-nodewebusb": "file:../../modules/hdwallet/packages/hdwallet-keepkey-nodewebusb", "@keepkey/proto-tx-builder": "file:../../modules/proto-tx-builder", - "@pioneer-platform/pioneer-caip": "^9.27.10", "@pioneer-platform/pioneer-client": "^11.1.0", "@pioneer-platform/pioneer-coins": "^11.0.0", - "@pioneer-platform/pioneer-discovery": "10.0.9", + "@pioneer-platform/pioneer-discovery": "10.3.1", "bs58": "^6.0.0", "@walletconnect/core": "^2.23.9", "@walletconnect/jsonrpc-utils": "^1.0.8", diff --git a/projects/keepkey-vault/scripts/patch-electrobun.sh b/projects/keepkey-vault/scripts/patch-electrobun.sh index 4fb1c8bf..2dbb236c 100755 --- a/projects/keepkey-vault/scripts/patch-electrobun.sh +++ b/projects/keepkey-vault/scripts/patch-electrobun.sh @@ -39,6 +39,18 @@ if [ -f "$EBUN_CLI" ]; then else echo "[patch-electrobun] WARNING: Info.plist pattern not found — camera permission may not work" fi + + # Add NSScreenCaptureUsageDescription (the QR "scan screen" option). Screen + # Recording itself is pure TCC — no entitlement exists — but newer macOS + # expects a purpose string on apps that request capture access. + if grep -q 'NSScreenCaptureUsageDescription' "$EBUN_CLI"; then + echo "[patch-electrobun] NSScreenCaptureUsageDescription already patched" + elif grep -q 'NSAppTransportSecurity' "$EBUN_CLI"; then + sed_in_place 's||NSScreenCaptureUsageDescription\n\tKeepKey Vault takes a one-time screenshot to find a QR code on your screen when you choose Scan Screen.\n|' "$EBUN_CLI" + echo "[patch-electrobun] Patched NSScreenCaptureUsageDescription" + else + echo "[patch-electrobun] WARNING: Info.plist pattern not found — screen recording permission may not work" + fi else echo "[patch-electrobun] $EBUN_CLI not found, skipping (expected during CI or fresh install)" fi diff --git a/projects/keepkey-vault/src/bun/auth.ts b/projects/keepkey-vault/src/bun/auth.ts index 55c663eb..7e3e0f1c 100644 Binary files a/projects/keepkey-vault/src/bun/auth.ts and b/projects/keepkey-vault/src/bun/auth.ts differ diff --git a/projects/keepkey-vault/src/bun/calldata-decoder.ts b/projects/keepkey-vault/src/bun/calldata-decoder.ts index b24ee9f1..c5d67455 100644 --- a/projects/keepkey-vault/src/bun/calldata-decoder.ts +++ b/projects/keepkey-vault/src/bun/calldata-decoder.ts @@ -47,6 +47,26 @@ interface LocalDecoder { decode: (data: string) => CalldataDecodedField[] } +// THORChain/Maya router deposit head layout: vault (word0), asset (word1) and +// amount (word2) sit at identical offsets for both deposit(...,memo) and +// depositWithExpiry(...,memo,expiry), so one decoder serves both selectors. +// The firmware (thortx.c) clear-signs BOTH — so the Vault must recognize both, +// otherwise the plain deposit() path decodes as source:'none', the signing +// overlay flags needsBlindSigning and forces AdvancedMode ON, which turns OFF +// the device's own blind-sign gate and defeats the router-pin fix (PR #261). +function decodeThorDeposit(data: string): CalldataDecodedField[] { + const vault = formatAddress('0x' + data.slice(10, 74)) + const asset = formatAddress('0x' + data.slice(74, 138)) + const amount = formatUint256('0x' + data.slice(138, 202)) + const isNativeAsset = asset === '0x0000000000000000000000000000000000000000' + return [ + { name: 'Protocol', type: 'string', value: 'THORChain Router', format: 'raw' }, + { name: 'Vault', type: 'address', value: vault, format: 'address' }, + { name: 'Asset', type: 'string', value: isNativeAsset ? 'Native (ETH)' : asset, format: isNativeAsset ? 'raw' : 'address' }, + { name: 'Amount', type: 'uint256', value: amount, format: 'amount' }, + ] +} + const LOCAL_DECODERS: LocalDecoder[] = [ // ERC-20 transfer(address,uint256) { @@ -305,24 +325,48 @@ const LOCAL_DECODERS: LocalDecoder[] = [ ] }, }, - // ── THORChain Router ── - // depositWithExpiry(address vault, address asset, uint256 amount, string memo, uint256 expiry) + // ── 0x Exchange Proxy / Uniswap (firmware clear-signs these) ── + // The firmware (zxswap.c / zxliquidtx.c) natively clear-signs these to their + // pinned routers, so decode them — otherwise they read as source:'none' and + // the signing overlay over-gates into blind-signing (forcing AdvancedMode ON, + // which disables the device's own gate). The device stays authoritative: it + // re-checks the array offset / LP recipient and rejects spoofed variants + // regardless of what we display here. + // sellToUniswap(address[] tokens, uint256 sellAmount, uint256 minBuyAmount, bool isSushi) { - selector: '0x44bc937b', - method: 'Deposit (THORChain)', + selector: '0xd9627aa4', + method: 'Sell to Uniswap (0x)', decode: (data) => { - const vault = formatAddress('0x' + data.slice(10, 74)) - const asset = formatAddress('0x' + data.slice(74, 138)) - const amount = formatUint256('0x' + data.slice(138, 202)) - const isNativeAsset = asset === '0x0000000000000000000000000000000000000000' + const sellAmount = formatUint256('0x' + data.slice(74, 138)) + const minBuyAmount = formatUint256('0x' + data.slice(138, 202)) return [ - { name: 'Protocol', type: 'string', value: 'THORChain Router', format: 'raw' }, - { name: 'Vault', type: 'address', value: vault, format: 'address' }, - { name: 'Asset', type: 'string', value: isNativeAsset ? 'Native (ETH)' : asset, format: isNativeAsset ? 'raw' : 'address' }, - { name: 'Amount', type: 'uint256', value: amount, format: 'amount' }, + { name: 'Protocol', type: 'string', value: '0x Exchange Proxy', format: 'raw' }, + { name: 'Sell Amount', type: 'uint256', value: sellAmount, format: 'amount' }, + { name: 'Min Buy Amount', type: 'uint256', value: minBuyAmount, format: 'amount' }, + ] + }, + }, + // addLiquidityETH(address token, uint amountTokenDesired, uint amountTokenMin, uint amountETHMin, address to, uint deadline) + { + selector: '0xf305d719', + method: 'Add Liquidity (Uniswap)', + decode: (data) => { + const token = formatAddress('0x' + data.slice(10, 74)) + const recipient = formatAddress('0x' + data.slice(266, 330)) + return [ + { name: 'Protocol', type: 'string', value: 'Uniswap V2 Router', format: 'raw' }, + { name: 'Token', type: 'address', value: token, format: 'address' }, + { name: 'LP Recipient', type: 'address', value: recipient, format: 'address' }, ] }, }, + // ── THORChain Router ── + // deposit(vault, asset, amount, memo) [0x1fece7b4] and + // depositWithExpiry(vault, asset, amount, memo, expiry) [0x44bc937b]. + // Firmware clear-signs both; recognize both so the plain deposit() path is + // not over-gated into blind-signing. (keepkey-firmware thortx.h selectors.) + { selector: '0x1fece7b4', method: 'Deposit (THORChain)', decode: decodeThorDeposit }, + { selector: '0x44bc937b', method: 'Deposit (THORChain)', decode: decodeThorDeposit }, ] /** @@ -368,6 +412,21 @@ interface PioneerSignResponse { dappName?: string contractName?: string method?: string + txHash?: string // the sighash the blob is bound to (== device's sighash) +} + +/** + * The full unsigned-tx fields /descriptors/sign needs to bind the blob to the + * exact sighash the device will sign. MUST be byte-identical to the values + * later passed to ethSignTx — any drift and rc3 firmware refuses the blob. + */ +export interface EvmUnsignedTxFields { + nonce: string | number + gasLimit: string | number + value: string | number + gasPrice?: string | number + maxFeePerGas?: string | number + maxPriorityFeePerGas?: string | number } /** @@ -409,8 +468,13 @@ async function fetchPioneerDecode( async function fetchPioneerSignedBlob( chainId: number, contractAddress: string, - data: string + data: string, + tx?: EvmUnsignedTxFields ): Promise { + // The blob's tx_hash covers nonce/gas/value/fees — without the full tx the + // server 400s (and any blob it could make would fail the rc3 hash binding). + if (!tx) return null + const request = { chainId, contractAddress, data, ...tx } try { // Try SDK first (available once spec is regenerated) const pioneer = await Promise.race([ @@ -419,7 +483,7 @@ async function fetchPioneerSignedBlob( ]) if (pioneer?.SignDescriptor) { const resp = await Promise.race([ - pioneer.SignDescriptor({ chainId, contractAddress, data }), + pioneer.SignDescriptor(request), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), ]) const result = resp?.data as PioneerSignResponse | undefined @@ -431,7 +495,7 @@ async function fetchPioneerSignedBlob( const resp = await fetch(`${base}/api/v1/descriptors/sign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chainId, contractAddress, data }), + body: JSON.stringify(request), signal: AbortSignal.timeout(3000), }) if (!resp.ok) return null @@ -448,6 +512,7 @@ export async function decodeCalldata( contractAddress: string, data: string, chainId?: number, + tx?: EvmUnsignedTxFields, ): Promise { // Skip if no calldata or just a bare transfer (no data) if (!data || data === '0x' || data.length < 10) return null @@ -460,11 +525,19 @@ export async function decodeCalldata( const resolvedChainId = chainId || 1 if (resolvedChainId) { const networkId = chainIdToNetworkId(resolvedChainId) - const [pioneer, signedBlob] = await Promise.all([ + const [pioneer, signedBlobRaw] = await Promise.all([ fetchPioneerDecode(networkId, contractAddress, data), - fetchPioneerSignedBlob(resolvedChainId, contractAddress, data), + fetchPioneerSignedBlob(resolvedChainId, contractAddress, data, tx), ]) + // Only attach VERIFIED blobs. An OPAQUE/UNKNOWN blob doesn't enable + // clear-sign — rc3 fail-closes on it — and attaching it would just mask + // the honest "unverified contract call" state from the UI/device paths. + const signedBlob = signedBlobRaw?.classification === 'VERIFIED' ? signedBlobRaw : null + if (signedBlobRaw) { + console.log(`[calldata] Pioneer /sign: classification=${signedBlobRaw.classification} keyId=${signedBlobRaw.keyId} txHash=${signedBlobRaw.txHash ?? '(n/a)'} attach=${!!signedBlob}`) + } + if (pioneer) { const fields: CalldataDecodedField[] = pioneer.args.map((arg) => ({ name: arg.name, @@ -483,10 +556,11 @@ export async function decodeCalldata( source: 'pioneer', signedInsightBlob: signedBlob?.signedPayload, insightKeyId: signedBlob?.keyId, + insightClassification: signedBlobRaw?.classification, } } - // Pioneer decode failed but we got a signed blob — still useful + // Pioneer decode failed but we got a VERIFIED blob — still useful if (signedBlob) { return { dappName: signedBlob.dappName || 'Unknown', @@ -497,6 +571,7 @@ export async function decodeCalldata( source: 'pioneer', signedInsightBlob: signedBlob.signedPayload, insightKeyId: signedBlob.keyId, + insightClassification: signedBlob.classification, } } } diff --git a/projects/keepkey-vault/src/bun/chain-scan.ts b/projects/keepkey-vault/src/bun/chain-scan.ts index b557f574..349bdccc 100644 --- a/projects/keepkey-vault/src/bun/chain-scan.ts +++ b/projects/keepkey-vault/src/bun/chain-scan.ts @@ -78,10 +78,29 @@ export function utxoAccountScriptPaths(chain: ChainDef, account: number): Array< const scriptTypes = (chain.id === 'litecoin' || chain.id === 'bitcoin') ? [{ scriptType: 'p2pkh', purpose: 44 }, { scriptType: 'p2sh-p2wpkh', purpose: 49 }, { scriptType: 'p2wpkh', purpose: 84 }] : [{ scriptType: chain.scriptType || 'p2pkh', purpose: 44 }] - return scriptTypes.map(st => ({ + const out = scriptTypes.map(st => ({ scriptType: st.scriptType, path: [st.purpose + 0x80000000, chain.defaultPath[1], 0x80000000 + account], })) + // The chain's own receive convention (defaultPath purpose + chain.scriptType) + // must always be queried: blockbook derives the script type from the xpub's + // SLIP-132 version bytes (which the wallet picks from scriptType), so a + // convention outside the standard purpose-matched entries above would leave + // the vault's own receive addresses invisible to the balance pipeline. + const ownScript = chain.scriptType || 'p2pkh' + const ownPurpose = chain.defaultPath[0] + if (!out.some(e => e.scriptType === ownScript && e.path[0] === ownPurpose)) { + out.push({ scriptType: ownScript, path: [ownPurpose, chain.defaultPath[1], 0x80000000 + account] }) + } + // Litecoin historical convention: pre-1.4.10 releases handed out p2wpkh + // addresses on the 44' branch. Keep that branch queried as p2wpkh so those + // funds stay visible and spendable. LAST so the (device, chain, path)-keyed + // pubkey-cache upsert keeps this entry over p2pkh on the shared 44' path + // (vault-generated addresses are far more likely to hold funds there). + if (chain.id === 'litecoin') { + out.push({ scriptType: 'p2wpkh', path: [0x8000002C, chain.defaultPath[1], 0x80000000 + account] }) + } + return out } /** diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 47b5ba66..5d349984 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -294,6 +294,10 @@ export function initDb() { try { db.exec(`ALTER TABLE api_log ADD COLUMN ${col}`) } catch { /* already exists */ } } try { db.exec(`ALTER TABLE swap_history ADD COLUMN device_id TEXT`) } catch { /* already exists */ } + // Pairing identity (stable per-install id) + sliding-TTL recency + for (const col of ['client_id TEXT', 'last_used_on INTEGER']) { + try { db.exec(`ALTER TABLE paired_apps ADD COLUMN ${col}`) } catch { /* already exists */ } + } try { db.exec(`ALTER TABLE swap_history ADD COLUMN wallet_id TEXT`) } catch { /* already exists */ } // Underlying protocol when integration is an aggregator (e.g. Relay, 0x via ShapeShift) try { db.exec(`ALTER TABLE swap_history ADD COLUMN swapper TEXT`) } catch { /* already exists */ } @@ -654,6 +658,18 @@ export function clearBalances(deviceId?: string) { } } +/** Remove one chain's cached balance for a device. Used when a chain becomes + * underivable on the current firmware (unknown-message) so a stale row cached + * under earlier firmware can't keep showing in the dashboard. */ +export function deleteCachedChainBalance(deviceId: string, chainId: string) { + try { + if (!db) return + db.run('DELETE FROM balances WHERE device_id = ? AND chain_id = ?', [deviceId, chainId]) + } catch (e: any) { + console.warn('[db] deleteCachedChainBalance failed:', e.message) + } +} + // ── Custom Tokens ──────────────────────────────────────────────────── export function getCustomTokens(): CustomToken[] { @@ -885,28 +901,42 @@ export function getTokensByVisibility(status: TokenVisibilityStatus): TokenVisib export function getStoredPairings(): PairedAppInfo[] { try { if (!db) return [] - const rows = db.query('SELECT api_key, name, url, image_url, added_on FROM paired_apps').all() as Array<{ - api_key: string; name: string; url: string; image_url: string; added_on: number + const rows = db.query('SELECT api_key, name, url, image_url, added_on, client_id, last_used_on FROM paired_apps').all() as Array<{ + api_key: string; name: string; url: string; image_url: string; added_on: number; client_id: string | null; last_used_on: number | null }> - return rows.map(r => ({ apiKey: r.api_key, name: r.name, url: r.url, imageUrl: r.image_url, addedOn: r.added_on })) + return rows.map(r => ({ + apiKey: r.api_key, name: r.name, url: r.url, imageUrl: r.image_url, addedOn: r.added_on, + clientId: r.client_id ?? undefined, lastUsedOn: r.last_used_on ?? undefined, + })) } catch (e: any) { console.warn('[db] getStoredPairings failed:', e.message) return [] } } -export function storePairing(apiKey: string, info: { name: string; url: string; imageUrl: string; addedOn: number }) { +export function storePairing(apiKey: string, info: { name: string; url: string; imageUrl: string; addedOn: number; clientId?: string; lastUsedOn?: number }) { try { if (!db) return db.run( - 'INSERT OR REPLACE INTO paired_apps (api_key, name, url, image_url, added_on) VALUES (?, ?, ?, ?, ?)', - [apiKey, info.name, info.url || '', info.imageUrl || '', info.addedOn] + 'INSERT OR REPLACE INTO paired_apps (api_key, name, url, image_url, added_on, client_id, last_used_on) VALUES (?, ?, ?, ?, ?, ?, ?)', + [apiKey, info.name, info.url || '', info.imageUrl || '', info.addedOn, info.clientId ?? null, info.lastUsedOn ?? info.addedOn] ) } catch (e: any) { console.warn('[db] storePairing failed:', e.message) } } +/** Lightweight recency write-back — used by the sliding-TTL refresh on the auth + * hot path, so it must not rewrite the whole row. No-op if the row is gone. */ +export function touchPairing(apiKey: string, lastUsedOn: number) { + try { + if (!db) return + db.run('UPDATE paired_apps SET last_used_on = ? WHERE api_key = ?', [lastUsedOn, apiKey]) + } catch (e: any) { + console.warn('[db] touchPairing failed:', e.message) + } +} + export function removePairing(apiKey: string) { try { if (!db) return diff --git a/projects/keepkey-vault/src/bun/emulator-window.ts b/projects/keepkey-vault/src/bun/emulator-window.ts index bea707c0..a3b15b37 100644 --- a/projects/keepkey-vault/src/bun/emulator-window.ts +++ b/projects/keepkey-vault/src/bun/emulator-window.ts @@ -191,6 +191,11 @@ let emuWindow: BrowserWindow | null = null export function openEmulatorWindow(): void { if (emuWindow) return + // A fresh window's webview hasn't loaded handlePacket yet. Close the gate + // until the new page posts /_emu/ready, so a stale `true` left over from a + // prior window can't let sendToWindow inject before this webview is ready. + viewReady = false + const port = startBridge() const saved = loadWindowState() console.log(`${TAG} Opening emulator window at (${saved.x}, ${saved.y}) ${saved.width}x${saved.height}`) @@ -270,7 +275,12 @@ function sendToWindow(messageName: string, payload: any): boolean { if (!emuWindow || !viewReady) return false const packet = JSON.stringify({ type: 'message', id: messageName, payload }) try { - emuWindow.webview.executeJavascript(`window.handlePacket(${packet})`) + // Guard handlePacket: executeJavascript against a webview whose page hasn't + // finished loading — or was rebuilt without a close event resetting + // viewReady — throws inside WebKit and crashes the WKWebView process with + // EXC_BREAKPOINT (SIGTRAP / exit 133). The `if` makes a not-yet-ready + // injection a harmless no-op instead of killing the app. + emuWindow.webview.executeJavascript(`if(window.handlePacket)window.handlePacket(${packet})`) return true } catch (err: any) { console.warn(`${TAG} sendToWindow ${messageName} failed:`, err?.message) @@ -294,7 +304,7 @@ export async function displaySeedWords(mnemonic: string): Promise { // user "seed displayed" when the words were never actually shown — that // would lead to backing up a seed the device doesn't hold. if (!viewReady) { - const deadline = Date.now() + 5000 + const deadline = Date.now() + EMU_VIEW_READY_TIMEOUT_MS while (Date.now() < deadline && !viewReady && emuWindow) { await new Promise(r => setTimeout(r, 50)) } @@ -330,6 +340,11 @@ export function dismissSeedDisplay(): void { // ── Interactive confirm ───────────────────────────────────────────────── const CONFIRM_TIMEOUT_MS = 120_000 // 2 minutes — reject if emulator window is dead/unresponsive +// How long to wait for a freshly-opened emulator webview to finish loading and +// post /_emu/ready. A cold webview on `make dev` can take well over 5s; the +// wait loop exits the instant viewReady flips, so a higher ceiling only avoids +// spuriously rejecting a confirm (e.g. a swap) before the window is up. +const EMU_VIEW_READY_TIMEOUT_MS = 20_000 async function requestUserConfirm(details: EmulatorConfirmDetails & { id: string }): Promise { if (!emuWindow) { @@ -346,7 +361,7 @@ async function requestUserConfirm(details: EmulatorConfirmDetails & { id: string // immediately after open (HTML hasn't loaded yet) — sendToWindow would // silently no-op and the user would never see the prompt. if (!viewReady) { - const deadline = Date.now() + 5000 + const deadline = Date.now() + EMU_VIEW_READY_TIMEOUT_MS while (Date.now() < deadline && !viewReady && emuWindow) { await new Promise(r => setTimeout(r, 50)) } diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index cb5dbe2a..8fee50aa 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -142,6 +142,12 @@ export class EngineController extends EventEmitter { // PIN flow tracking — device sends PIN_REQUEST mid-operation private setupInProgress = false private verifyInProgress = false // dry-run verify seed (PIN type stays 'current') + // Latest cipher-recovery CharacterRequest (transport event 80), exposed for + // REST/SDK callers driving recovery without the UI. seq increments on each + // request so a caller can wait for the device to ask for the next character. + private lastCharacterRequest: { wordPos: number; characterPos: number } | null = null + private characterRequestSeq = 0 + private recoveryActive = false // true only while a recover/reset flow drives the cipher private pinRequestCount = 0 // Tracks whether promptPin() → getPublicKeys() is still awaiting resolution. // While active, sendPin/sendPassphrase must NOT call getFeatures — that would @@ -188,6 +194,7 @@ export class EngineController extends EventEmitter { this.seedEthAddress = null this.hiddenWalletActive = false this.passphraseSetThisSession = false + this.resetRecoveryState() this.keyring.removeAll().catch(() => {}) } @@ -251,7 +258,9 @@ export class EngineController extends EventEmitter { if (event.message) { const { wordPos, characterPos } = event.message console.log(`[Engine] CHARACTER_REQUEST → word=${wordPos} char=${characterPos}`) - this.emit('character-request', { wordPos: wordPos ?? 0, characterPos: characterPos ?? 0 }) + this.lastCharacterRequest = { wordPos: wordPos ?? 0, characterPos: characterPos ?? 0 } + this.characterRequestSeq++ + this.emit('character-request', this.lastCharacterRequest) } }) } @@ -1155,21 +1164,11 @@ export class EngineController extends EventEmitter { // // Recovery: flush ring buffers, reconnect transport, re-initialize, retry. if (derivedState === 'ready') { - const probeXpub = async () => { - await withTimeout( - (this.wallet as any).getPublicKeys([{ - addressNList: [0x80000000 + 44, 0x80000000 + 0, 0x80000000 + 0], - coin: 'Bitcoin', - scriptType: 'p2pkh', - showDisplay: false, - }]), - 10_000, - 'emulator smoke-test' - ) - } - + // Captures the live m/44'/0'/0' BTC xpub from whichever probe succeeds, + // so we can verify the running seed matches the saved mnemonic below. + let liveBtcXpub: string | null = null try { - await probeXpub() + liveBtcXpub = await this.probeBtcXpub() console.log('[Engine] Emulator smoke-test passed (key derivation OK)') } catch (probeErr: any) { console.warn('[Engine] Emulator smoke-test failed, flushing + reconnecting...', probeErr?.message || probeErr) @@ -1193,105 +1192,18 @@ export class EngineController extends EventEmitter { // Retry try { - await probeXpub() + liveBtcXpub = await this.probeBtcXpub() console.log('[Engine] Emulator smoke-test passed after reconnect') } catch (retryErr: any) { - // Storage key persistence is broken — the firmware can't decrypt - // its own stored seed after a restart. Auto-wipe the flash and - // reload the saved mnemonic from Keychain if available. - console.warn('[Engine] Emulator storage key stale — auto-wiping flash') - const { stopEmulator, initEmulator, getActiveFlashName } = await import('./emulator') - const { deleteFlash, loadMnemonic } = await import('./emulator-keychain') - const flashName = getActiveFlashName() - const savedMnemonic = loadMnemonic(flashName) - stopEmulator() - deleteFlash(flashName) - const status = initEmulator(flashName) - if (status.state !== 'running') { - this.lastError = `Emulator restart failed: ${status.error}` - this.updateState('error') - return - } - // Reconnect to the fresh (uninitialized) emulator - const cleanAdapter = EmulatorKeepKeyAdapter.useKeyring(this.keyring) - const cleanDevice = await cleanAdapter.getDevice() - const cleanWallet = await cleanAdapter.pairRawDevice(cleanDevice, true) - if (!cleanWallet) { - this.lastError = 'Emulator reconnect failed after wipe' - this.updateState('error') - return - } - this.wallet = cleanWallet as any - this.activeTransport = 'emulator' - this.attachTransportListeners() - this.cachedFeatures = await withTimeout( - cleanWallet.initialize(), - PAIR_TIMEOUT_MS, - 'emulator fresh-init' - ) - - // Auto-reload saved mnemonic if available - if (savedMnemonic) { - console.log('[Engine] Auto-reloading saved mnemonic from Keychain...') - await this.emuConfirmOp(() => (this.wallet as any).loadDevice({ - mnemonic: savedMnemonic, pin: false, passphrase: false, skipChecksum: false, - }), 2) - console.log('[Engine] Mnemonic auto-loaded successfully') - - // Flush + reconnect for clean state - const { flushRingBuffers } = await import('./emulator') - flushRingBuffers() - const reAdapter = EmulatorKeepKeyAdapter.useKeyring(this.keyring) - const reDevice = await reAdapter.getDevice() - const reWallet = await reAdapter.pairRawDevice(reDevice, true) - if (reWallet) { - this.wallet = reWallet as any - this.attachTransportListeners() - this.cachedFeatures = await withTimeout( - reWallet.initialize(), - PAIR_TIMEOUT_MS, - 'emulator post-reload' - ) - } - - // Verify auto-reload actually took effect. Race against a 3s - // deadline — the DebugLinkGetState read can hang on the dylib - // path (separate timing bug). The verify is just a sanity log; - // if it hangs, connectEmulator must NOT block forever or the - // wizard / dashboard never sees state → ready. - // - // The underlying readChunk has its own ~240s timeout and we - // can't cancel it from here, so the .then below may fire long - // after the race resolves. Suppress its log in that case so - // the user doesn't see a spurious VERIFY FAIL minutes later. - let verifyAbandoned = false - const verifyPromise = this.getEmulatorMnemonic() - .then(verifyMnemonic => { - if (verifyAbandoned) return - if (!verifyMnemonic) { - console.error('[Engine] AUTO-RELOAD VERIFY FAIL — firmware returned no mnemonic') - } else if (verifyMnemonic.trim() !== savedMnemonic.trim()) { - console.error('[Engine] AUTO-RELOAD VERIFY FAIL — firmware has DIFFERENT mnemonic than saved') - console.error('[Engine] saved first word: %s', savedMnemonic.trim().split(/\s+/)[0]) - console.error('[Engine] actual first word: %s', verifyMnemonic.trim().split(/\s+/)[0]) - } else { - console.log('[Engine] AUTO-RELOAD VERIFY OK — firmware mnemonic matches saved seed') - } - }) - .catch(err => { - if (verifyAbandoned) return - console.warn('[Engine] AUTO-RELOAD VERIFY error:', err?.message || err) - }) - await Promise.race([ - verifyPromise, - new Promise(resolve => setTimeout(() => { - verifyAbandoned = true - console.warn('[Engine] AUTO-RELOAD VERIFY timed out (3s) — continuing') - resolve() - }, 3000)), - ]) - - this.updateState(this.deriveState(this.cachedFeatures)) + // Storage key persistence is broken — the firmware can't decrypt its + // own stored seed after a restart. Wipe the stale flash and reload + // the saved mnemonic. reloadSavedSeed verifies the live xpub and + // sets engine state; if no mnemonic is saved, fall back to setup. + console.warn('[Engine] Emulator storage key stale —', retryErr?.message || retryErr) + const { getActiveFlashName } = await import('./emulator') + const { hasMnemonic } = await import('./emulator-keychain') + if (hasMnemonic(getActiveFlashName())) { + await this.reloadSavedSeed('Emulator storage key stale') } else { console.log('[Engine] No saved mnemonic — showing setup wizard') this.updateState(this.deriveState(this.cachedFeatures)) @@ -1299,6 +1211,35 @@ export class EngineController extends EventEmitter { return } } + + // Deterministic seed-identity guard. The persisted flash is NOT a + // trustworthy seed store — the firmware's storage key changes per + // kkemu_init (see emulator-keychain.ts:374), so a stale flash can boot + // a DIFFERENT seed than the one the user saved. The smoke-test above + // passes on any valid seed, so compare the live BTC xpub against the + // saved mnemonic and force a reload on mismatch. No flaky DebugLink + // read — the probe already gave us the live xpub. + if (liveBtcXpub) { + const { getActiveFlashName } = await import('./emulator') + const { loadMnemonic } = await import('./emulator-keychain') + const saved = loadMnemonic(getActiveFlashName()) + if (saved && this.expectedBtcXpub(saved) !== liveBtcXpub) { + await this.reloadSavedSeed('Running seed does not match saved mnemonic') + return + } + } + } else if (this.cachedFeatures && this.cachedFeatures.initialized === false) { + // Flash booted blank/uninitialized but a mnemonic is saved for this + // wallet — same flash-untrustworthy failure. Restore the saved seed + // instead of dropping the user into setup. (A genuinely new wallet + // saves its mnemonic before connect, so "uninitialized + has-mnemonic" + // only happens when the flash lost its seed.) + const { getActiveFlashName } = await import('./emulator') + const { hasMnemonic } = await import('./emulator-keychain') + if (hasMnemonic(getActiveFlashName())) { + await this.reloadSavedSeed('Flash booted uninitialized but a saved mnemonic exists') + return + } } this.updateState(derivedState) @@ -1311,6 +1252,103 @@ export class EngineController extends EventEmitter { } } + /** + * Fetch the live m/44'/0'/0' BTC legacy xpub from the device. Throws on + * timeout/transport failure (used as the connect smoke-test); returns null + * only if the device responds without an xpub. + */ + private async probeBtcXpub(timeoutMs = 10_000, label = 'emulator smoke-test'): Promise { + const r = await withTimeout( + (this.wallet as any).getPublicKeys([{ + addressNList: [0x80000000 + 44, 0x80000000 + 0, 0x80000000 + 0], + coin: 'Bitcoin', + scriptType: 'p2pkh', + showDisplay: false, + }]), + timeoutMs, + label + ) + return (r as any)?.[0]?.xpub ?? null + } + + /** + * Derive the m/44'/0'/0' BTC legacy xpub for a mnemonic — the same key the + * smoke-test probe fetches from the device. Pure/offline, used to detect when + * the running emulator seed differs from the saved one. + */ + private expectedBtcXpub(mnemonic: string): string { + const { mnemonicToSeedSync } = require('@scure/bip39') + const { HDKey } = require('@scure/bip32') + const seed = mnemonicToSeedSync(mnemonic.trim()) + return HDKey.fromMasterSeed(seed).derive("m/44'/0'/0'").publicExtendedKey + } + + /** + * Force the emulator's running seed to match the saved mnemonic for the active + * flash: wipe the (untrustworthy) flash and re-load the saved seed. No-op if + * no mnemonic is saved. Sets engine state on completion. + */ + private async reloadSavedSeed(reason: string): Promise { + const { stopEmulator, initEmulator, getActiveFlashName, flushRingBuffers } = await import('./emulator') + const { deleteFlash, loadMnemonic } = await import('./emulator-keychain') + const flashName = getActiveFlashName() + const savedMnemonic = loadMnemonic(flashName) + if (!savedMnemonic) { + console.warn(`[Engine] ${reason} — no saved mnemonic, cannot reload`) + return + } + console.warn(`[Engine] ${reason} — wiping flash + reloading saved seed for "${flashName}"`) + stopEmulator() + deleteFlash(flashName) + const status = initEmulator(flashName) + if (status.state !== 'running') { + this.lastError = `Emulator restart failed: ${status.error}` + this.updateState('error') + return + } + const adapter = EmulatorKeepKeyAdapter.useKeyring(this.keyring) + const device = await adapter.getDevice() + const wallet = await adapter.pairRawDevice(device, true) + if (!wallet) { + this.lastError = 'Emulator reconnect failed after wipe' + this.updateState('error') + return + } + this.wallet = wallet as any + this.activeTransport = 'emulator' + this.attachTransportListeners() + this.cachedFeatures = await withTimeout(wallet.initialize(), PAIR_TIMEOUT_MS, 'emulator fresh-init') + await this.emuConfirmOp(() => (this.wallet as any).loadDevice({ + mnemonic: savedMnemonic, pin: false, passphrase: false, skipChecksum: false, + }), 2) + flushRingBuffers() + const reAdapter = EmulatorKeepKeyAdapter.useKeyring(this.keyring) + const reDevice = await reAdapter.getDevice() + const reWallet = await reAdapter.pairRawDevice(reDevice, true) + if (reWallet) { + this.wallet = reWallet as any + this.attachTransportListeners() + this.cachedFeatures = await withTimeout(reWallet.initialize(), PAIR_TIMEOUT_MS, 'emulator post-reload') + } + + // Prove the reload took: the live xpub must now match the saved mnemonic. + // Without this, a failed reload could still report success (the same trap + // the connect-time guard exists to catch). + let reloadedXpub: string | null = null + try { + reloadedXpub = await this.probeBtcXpub(10_000, 'post-reload verify') + } catch (err: any) { + console.error('[Engine] Post-reload verify could not read xpub:', err?.message || err) + } + if (!reloadedXpub || reloadedXpub !== this.expectedBtcXpub(savedMnemonic)) { + this.lastError = 'Emulator seed restore failed — running seed still does not match saved mnemonic. Re-import to recover.' + this.updateState('error') + return + } + this.updateState(this.deriveState(this.cachedFeatures)) + console.log('[Engine] Saved seed reloaded — running seed verified against saved mnemonic') + } + /** * Disconnect the emulator from the engine (called when emulator stops). */ @@ -1936,6 +1974,32 @@ export class EngineController extends EventEmitter { await this.wallet.sendCharacterDone() } + /** Latest cipher-recovery character request, for REST/SDK callers driving + * recovery. `seq` advances each time the device asks for the next character. */ + getRecoveryState() { + return { + active: this.recoveryActive, + word_pos: this.lastCharacterRequest?.wordPos ?? null, + character_pos: this.lastCharacterRequest?.characterPos ?? null, + seq: this.characterRequestSeq, + } + } + + /** Marks a cipher-recovery/reset flow active. The REST recover-device handler + * wraps the call so /system/recovery/state reports active only while entry is + * in progress. (seq stays monotonic so callers can sync on the next request.) */ + setRecoveryActive(active: boolean) { + this.recoveryActive = active + } + + /** Clear recovery progress — called on disconnect so /system/recovery/state + * can't return stale word/character positions from a previous session. */ + private resetRecoveryState() { + this.lastCharacterRequest = null + this.characterRequestSeq = 0 + this.recoveryActive = false + } + resetUpdatePhase() { this.updatePhase = 'idle' this.emit('state-change', this.getDeviceState()) diff --git a/projects/keepkey-vault/src/bun/evm-addresses.ts b/projects/keepkey-vault/src/bun/evm-addresses.ts index e5e4d7d8..4954548b 100644 --- a/projects/keepkey-vault/src/bun/evm-addresses.ts +++ b/projects/keepkey-vault/src/bun/evm-addresses.ts @@ -202,19 +202,25 @@ export class EvmAddressManager extends EventEmitter { /** * Auto-discover balance-bearing addresses by scanning indices 0–maxIndex. * Derives each address, checks Pioneer for non-zero balance, and auto-adds any with funds. - * Call once after initial balance fetch to expand tracked addresses. + * Guarded to run once per session — it used to re-derive + re-scan every + * untracked index (device round-trips + forced Pioneer reads) on EVERY + * getBalances call, including soft page loads. */ + private discoveryDone = false async autoDiscover( wallet: any, pioneer: any, evmChains: Array<{ caip: string; id: string; symbol: string; networkId: string }>, maxIndex = 9, ): Promise<{ discovered: number[] }> { + if (this.discoveryDone) return { discovered: [] } const discovered: number[] = [] const existingIndices = new Set(this.addresses.map(a => a.addressIndex)) + let attempted = 0, succeeded = 0 for (let idx = 0; idx <= maxIndex; idx++) { if (existingIndices.has(idx)) continue + attempted++ // Derive address without persisting yet const path = evmAddressPath(idx) @@ -225,10 +231,13 @@ export class EvmAddressManager extends EventEmitter { if (!address) continue } catch { continue } - // Check if any EVM chain has a balance for this address + // Check if any EVM chain has a balance for this address. Soft read: a + // never-queried key is a cache miss and gets fetched fresh anyway, so + // discovery doesn't need to bypass Pioneer's cache. const pubkeys = evmChains.map(c => ({ caip: c.caip, pubkey: address })) try { - const resp = await pioneer.GetPortfolioBalances({ pubkeys }, { forceRefresh: true }) + const resp = await pioneer.GetPortfolioBalances({ pubkeys }) + succeeded++ const balances = resp?.data?.balances || resp?.data || [] const hasBalance = (Array.isArray(balances) ? balances : []).some( (b: any) => parseFloat(String(b?.balance ?? '0')) > 0 || Number(b?.valueUsd ?? 0) > 0, @@ -242,6 +251,11 @@ export class EvmAddressManager extends EventEmitter { } catch { continue } } + // Latch only when the scan actually ran: if EVERY probe errored (Pioneer + // outage at cold start, device unplug mid-scan — both swallowed above), + // leave the session's discovery shot available for the next getBalances. + if (attempted === 0 || succeeded > 0) this.discoveryDone = true + if (discovered.length > 0) { this.persistIndices() const set = this.toAddressSet() @@ -256,6 +270,7 @@ export class EvmAddressManager extends EventEmitter { this.addresses = [] this.selectedIndex = 0 this.initPromise = null + this.discoveryDone = false } get isInitialized(): boolean { diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 2a0b9198..09c5dfd0 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -107,6 +107,7 @@ process.on('unhandledRejection', (reason) => { import { EngineController, withTimeout } from "./engine-controller" import { runUsbDiagnostic as runUsbDiagnosticProbe } from "./windows-usb-probe" +import { ensureScreenPermission, captureScreens } from "./screen-capture" import { startRestApi, clearFeaturesCache, setUiActive, uiHeartbeat, type RestApiCallbacks } from "./rest-api" import { parseSolanaTx, SolanaTxParseError, solanaMessageSlice } from "./solana-tx" import { AuthStore } from "./auth" @@ -121,7 +122,7 @@ import { buildTx, broadcastTx } from "./txbuilder" import { buildCosmosStakingTx, buildCosmosNameRegTx } from "./txbuilder/cosmos" import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, sendShielded, ensureFvkLoaded, displayOrchardAddressOnDevice } from "./txbuilder/zcash-shielded" import { isSidecarReady, startSidecar, stopSidecar, wipeSidecarWalletDb, hasFvkLoaded, getCachedFvk, onScanProgress, getScanState, updateSyncedTo, beginZcashSend, endZcashSend, isZcashSendInFlight } from "./zcash-sidecar" -import { CHAINS, customChainToChainDef, isChainSupported } from "../shared/chains" +import { CHAINS, customChainToChainDef, isChainSupported, hiveRolePath } from "../shared/chains" import { versionCompare } from "../shared/firmware-versions" import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" @@ -129,12 +130,12 @@ import { EvmAddressManager, evmAddressPath } from "./evm-addresses" import { shouldResetManagersOnReady, nextReadyDeviceId } from "../shared/device-switch" import { isManagerSeedStale } from "../shared/seed-reconcile" import { WalletConnectManager } from "./walletconnect" -import { initDb, factoryResetDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, setCustomTokenIcon as dbSetCustomTokenIcon, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, updateCachedBalance, clearBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats, getSwapHistoryByTxid, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, getPioneerServers, addPioneerServerDb, removePioneerServerDb, syncOwnAddressBook, recordOutbound, getAddressBookList, updateAddressBookEntry, deleteAddressBookEntry, getAddressBookHistory, getDeviceLabelMap, getBalancesForOwnSeed, addExternalEntry, matchAddressBook } from "./db" +import { initDb, factoryResetDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, setCustomTokenIcon as dbSetCustomTokenIcon, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, updateCachedBalance, clearBalances, deleteCachedChainBalance, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats, getSwapHistoryByTxid, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, getPioneerServers, addPioneerServerDb, removePioneerServerDb, syncOwnAddressBook, recordOutbound, getAddressBookList, updateAddressBookEntry, deleteAddressBookEntry, getAddressBookHistory, getDeviceLabelMap, getBalancesForOwnSeed, addExternalEntry, matchAddressBook } from "./db" import type { OwnAddressSeed } from "./db" import { rectifyWallet, getLedgerSummary, getLedgerJournals } from "./ledger" import { generateReport, reportToPdfBuffer, reportToCsv } from "./reports" import { startAudit, startBtcScan, getAudit, getAuditBtcRaw, getAuditEntry, dismissAudit, markAuditsStale, type AuditDeps } from "./audit-engine" -import { chainSupportsDeepScan, chainSupportsLevelScan, chainLevelPath, deriveAddressParams, extractAddress, parseNativeScanResult, parseEvmScanResult, utxoAccountScriptPaths, explorerAddressUrl, pathToBip32 } from "./chain-scan" +import { chainSupportsDeepScan, chainSupportsLevelScan, chainLevelPath, deriveAddressParams, extractAddress, parseNativeScanResult, parseEvmScanResult, utxoAccountScriptPaths, explorerAddressUrl, pathToBip32, parseBip32Path } from "./chain-scan" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import * as os from "os" import * as path from "path" @@ -240,20 +241,43 @@ function unwrapPortfolioResponse(resp: any): { // chain). Replaces the audit's former GetBalanceAddressByNetwork calls, which // were EVM-only (route /evm/balance → ETH JSON-RPC) and 500'd for any non-EVM // networkId (bip122/cosmos/ripple), so BTC/DOGE/XRP/Cosmos balances read 0. +// Patient retry for audit balance lookups: a transient Pioneer blip must not +// surface as "couldn't verify" — only claim the API is down after it failed +// repeatedly over a real window. 5 attempts, 30s between failures. +const AUDIT_BALANCE_ATTEMPTS = 5 +const AUDIT_BALANCE_RETRY_MS = 30_000 +async function auditPatientFetch(label: string, fn: () => Promise): Promise { + let lastErr: any + for (let attempt = 1; attempt <= AUDIT_BALANCE_ATTEMPTS; attempt++) { + try { + return await withTimeout(fn(), PIONEER_TIMEOUT_MS, label) + } catch (e: any) { + lastErr = e + if (attempt < AUDIT_BALANCE_ATTEMPTS) { + console.warn(`[audit] ${label} attempt ${attempt}/${AUDIT_BALANCE_ATTEMPTS} failed (${e?.message}) — retrying in ${AUDIT_BALANCE_RETRY_MS / 1000}s`) + await new Promise(r => setTimeout(r, AUDIT_BALANCE_RETRY_MS)) + } + } + } + throw lastErr +} + async function auditNativeBalance( chain: { caip: string; id: string }, address: string, ): Promise<{ native: string; hasBalance: boolean; balanceError: boolean }> { try { const pioneer = await getPioneer() - const resp = await withTimeout( - pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: address }] }, { forceRefresh: true }), - PIONEER_TIMEOUT_MS, `audit balance ${chain.id}`, - ) - const { entries, meta } = unwrapPortfolioResponse(resp) - const { native, hasBalance } = parseNativeScanResult(entries, chain.caip) - // degraded + nothing found = unverified, not a confident zero (honesty rule). - if (!hasBalance && meta?.degraded) return { native: '0', hasBalance: false, balanceError: true } + // degraded + nothing found = unverified, not a confident zero (honesty + // rule) — treated as retryable so a transient upstream blip gets the same + // patience as a thrown request. + const { native, hasBalance } = await auditPatientFetch(`audit balance ${chain.id}`, async () => { + const resp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: address }] }, { forceRefresh: true }) + const { entries, meta } = unwrapPortfolioResponse(resp) + const parsed = parseNativeScanResult(entries, chain.caip) + if (!parsed.hasBalance && meta?.degraded) throw new Error('balance API degraded') + return parsed + }) return { native, hasBalance, balanceError: false } } catch (e: any) { console.warn(`[audit] balance ${chain.id} failed: ${e?.message}`) @@ -276,14 +300,15 @@ async function auditBalanceForAddress( const pioneer = await getPioneer() // GetPortfolioBalances can return 200 with DEGRADED data — for a single-caip // query meta.degraded means THIS chain's fresh fetch failed, so an empty - // result is "couldn't verify", NOT a clean $0 (honesty rule). - const resp = await withTimeout( - pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: address }] }, { forceRefresh: true }), - PIONEER_TIMEOUT_MS, `audit EVM balance ${chain.id}`, - ) - const { entries, meta } = unwrapPortfolioResponse(resp) - const parsed = parseEvmScanResult(entries) - if (!parsed.hasBalance && meta?.degraded) return { native: '0', hasBalance: false, balanceError: true } + // result is "couldn't verify", NOT a clean $0 (honesty rule). Degraded-empty + // is retryable — same patience as a thrown request. + const parsed = await auditPatientFetch(`audit EVM balance ${chain.id}`, async () => { + const resp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: address }] }, { forceRefresh: true }) + const { entries, meta } = unwrapPortfolioResponse(resp) + const p = parseEvmScanResult(entries) + if (!p.hasBalance && meta?.degraded) throw new Error('balance API degraded') + return p + }) return { native: parsed.native, hasBalance: parsed.hasBalance, balanceError: false, tokens: parsed.tokens.length ? parsed.tokens : undefined } } catch (e: any) { console.warn(`[audit] EVM balance ${chain.id} failed: ${e?.message}`) @@ -300,27 +325,185 @@ function mergeMetas(metas: PortfolioMeta[]): PortfolioMeta { } } -// ── Desktop update — open GitHub releases page ── +// ── Shared portfolio-entry parsing ────────────────────────────────────────── +// Single source of truth for the leaf logic that getBalances (bulk/dashboard), +// getBalance (single-chain/asset page) and refreshWatchOnlyBalances all need. +// These used to be hand-mirrored copies that drifted — the missing publicKey +// derive fallback broke every single-chain Hive refresh, the token regex lost +// denom|bank (TCY/RUJI), and getBalance dropped response meta entirely. Keep +// the leaves here so the paths cannot diverge again. + +// Contract id from a CAIP-19 asset path: +// ERC-20: "eip155:1/erc20:0xdac17..." → "0xdac17..." +// SPL: "solana:5eykt4.../spl:TokenMint..." → "TokenMint..." +// TRC-20: "tron:27Lqcw/trc20:T..." → "T..." +// denom: "cosmos:thorchain-mainnet-v1/denom:tcy" → "tcy" (cosmos bank denom) +const CONTRACT_CAIP_RE = /\/(erc20|spl|trc20|token|denom|bank):([^\s]+)/ + +// A Pioneer portfolio entry is a token when its CAIP asset path isn't a native +// slip44/native id, or when the server explicitly types it as one. +function isTokenEntry(entry: any): boolean { + const caipPath = ((entry.caip || '').split('/')[1]) || '' + const isTokenByCaip = !!caipPath && !caipPath.startsWith('slip44:') && !caipPath.startsWith('native:') + const isTokenByType = entry.type === 'token' || (entry.isNative === false && !!entry.contract) + return isTokenByCaip || isTokenByType +} + +function parseTokenEntry(tok: any): TokenBalance { + const tokNetworkId = (tok.networkId || '').toLowerCase() + const caipPrefix = ((tok.caip || '').split('/')[0]).toLowerCase() + const contractMatch = (tok.caip || '').match(CONTRACT_CAIP_RE) + return { + symbol: tok.symbol || '???', + name: tok.name || tok.symbol || 'Unknown Token', + balance: String(tok.balance ?? '0'), + balanceUsd: Number(tok.valueUsd ?? 0), + priceUsd: Number(tok.priceUsd ?? 0), + caip: tok.caip || '', + contractAddress: contractMatch?.[2] || tok.contract || undefined, + networkId: tokNetworkId || caipPrefix, + icon: tok.icon || undefined, + decimals: tok.decimals ?? tok.precision, + type: tok.type || 'token', + dataSource: tok.dataSource, + } +} + +// Sum duplicates by normalized CAIP. EVM hex is case-insensitive (lowercase for +// the dedup key only — canonical caip on the object stays intact); Solana/Tron +// identifiers are case-sensitive, keep them exact. Pioneer can return the same +// token for multiple address indices — sum so value isn't underreported. +function dedupeTokensByCaip(tokens: TokenBalance[]): TokenBalance[] { + const seen = new Map() + for (const tok of tokens) { + const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip + const existing = seen.get(key) + if (!existing) { + seen.set(key, { ...tok }) + } else { + existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) + existing.balanceUsd += tok.balanceUsd + } + } + return [...seen.values()] +} + +function mapServerDefiPosition(sp: ServerDefiPosition): DefiPosition { + return { + protocol: sp.protocol || null, + displayName: sp.displayName, + name: sp.displayName || sp.protocol || 'DeFi Position', + network: sp.network, + networkId: sp.networkId, + balanceUsd: Number(sp.balanceUsd) || 0, + icon: sp.icon, + tokens: Array.isArray(sp.tokens) ? sp.tokens.map(t => ({ + networkId: t.networkId, + address: String(t.address || '').toLowerCase(), + symbol: t.symbol, + balance: t.balance != null ? String(t.balance) : undefined, + balanceUsd: typeof t.balanceUsd === 'number' ? t.balanceUsd : undefined, + })).filter(t => !!t.address) : [], + } +} + +/** + * Shielded ZEC as a synthetic token to attach under native Zcash — the + * dashboard renders it like an ERC20 sub-row. The Orchard FVK lives in the + * local sidecar; the seed never left the device. Only surfaces once the + * cached FVK is PROVEN to belong to the connected device (a stale FVK from a + * previous seed would show a phantom balance the user can't spend); when not + * yet verified, kicks off the device-match check and returns null — the + * balance appears on the next refresh. Shared by getBalances and getBalance + * so a single-chain zcash refresh can't wipe the shielded sub-row. + */ +async function fetchShieldedZecToken(zecPriceUsd: number): Promise { + if (!zcashPrivacyEnabled || !hasFvkLoaded()) return null + if (!zcashDeviceVerified) { + maybeStartBackgroundWalletVerification() + return null + } + try { + const shielded = await Promise.race([ + getShieldedBalance(), + new Promise(r => setTimeout(() => r(null), 5000)), + ]) + if (!shielded || shielded.confirmed <= 0) return null + const zecAmount = shielded.confirmed / 1e8 + return { + symbol: 'zZEC', + name: 'Shielded ZEC', + balance: zecAmount.toFixed(8), + balanceUsd: zecAmount * zecPriceUsd, + priceUsd: zecPriceUsd, + caip: 'bip122:00040fe8ec8471911baa1db1266ea15d/orchard:shielded', + contractAddress: 'orchard', + networkId: 'bip122:00040fe8ec8471911baa1db1266ea15d', + decimals: 8, + type: 'shielded', + } + } catch (e: any) { + console.warn('[balances] Shielded balance fetch failed:', e?.message || e) + return null + } +} + +// ── Desktop update — open keepkey.com "update your app" page ── // In-app auto-update is unreliable on both platforms: // - macOS: zig-zstd has different CLI flags than zstd, stock macOS has no zstd // - Windows: in-app exe download + spawn had process lock issues -// Both platforms now open the GitHub releases page for manual download. +// Both platforms now open the keepkey.com update page, which serves the correct +// download for the user's OS/arch and explains how to update. const GITHUB_REPO = 'keepkey/keepkey-vault' +const UPDATE_PAGE = 'https://keepkey.com/update' // Cached version from pre-release GitHub check (Updater.updateInfo() doesn't have it) let pendingUpdateVersion: string | null = null let pioneerSocket: PioneerSocket | null = null +// Debounce Pioneer push events per CAIP-2 network — rapid-fire tx events / +// per-confirmation re-fires coalesce into one refresh. Pending payload is kept +// so a replacement can merge (an 'incoming' must survive a confirmation update +// landing in its window). Module scope so the disconnect path can clear pending +// timers (a timer firing after unplug would trigger a getBalance that throws +// 'No device connected'). +type PendingPush = { payload: { chain?: string; networkId?: string; address?: string; txid?: string; type?: 'incoming' | 'outgoing' | 'confirmed' }; timer: ReturnType } +const pioneerEventDebounce = new Map() +function clearPioneerEventDebounce(): void { + for (const p of pioneerEventDebounce.values()) clearTimeout(p.timer) + pioneerEventDebounce.clear() +} +// The same tx reaches the vault on BOTH push legs (SSE watch-list + Pioneer +// socket). Dedup by txid so one deposit doesn't toast twice and trigger two +// forced device+Pioneer refetches. Checking marks: first caller wins. +const recentPushTxids = new Map() +function txidRecentlyPushed(txid: string | undefined): boolean { + if (!txid) return false + const now = Date.now() + for (const [k, exp] of recentPushTxids) { if (exp < now) recentPushTxids.delete(k) } + if (recentPushTxids.has(txid)) return true + recentPushTxids.set(txid, now + 15_000) + return false +} // True from the moment a device becomes ready until the background bulk history // scan finishes. Exposed via getActivityScanState so the activity UI can show // "Syncing…" when it mounts mid-scan instead of a false "no activity". let activityScanRunning = false -function openReleasePage() { - const version = pendingUpdateVersion || Updater.updateInfo()?.version - const url = version - ? `https://github.com/${GITHUB_REPO}/releases/tag/v${version}` - : `https://github.com/${GITHUB_REPO}/releases` - console.log(`[Update] Opening releases page: ${url}`) - const cmd = process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['open', url] +function openUpdatePage() { + // Target version the user should upgrade to (latest available). + const target = pendingUpdateVersion || Updater.updateInfo()?.version + // os: mac | windows | linux ; arch: arm64 | x64 — keepkey.com serves the right build. + const os = process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux' + const params = new URLSearchParams({ os, arch: process.arch }) + if (target) params.set('version', target) + if (appVersionCache) params.set('current', appVersionCache) + const url = `${UPDATE_PAGE}?${params.toString()}` + console.log(`[Update] Opening update page: ${url}`) + // `cmd /c start` re-parses its command line with cmd.exe's own (non-backslash-aware) + // quoting rules, which collide with Bun.spawn's Win32 argv-escaping and mangle the + // URL (observed: quote gets corrupted into a stray leading backslash, "Windows cannot + // find '\https://...'"). rundll32 takes the URL as a normal argv element — no shell + // re-parsing, so `&` in the query string and Bun's escaping both just work. + const cmd = process.platform === 'win32' ? ['rundll32', 'url.dll,FileProtocolHandler', url] : ['open', url] Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'] }) } @@ -547,6 +730,13 @@ let restApiEnabled = false let walletConnectEnabled = false let bip85Enabled = false let zcashPrivacyEnabled = false +let hiveEnabled = false +// Hive sponsor ETH anti-drain gate. ON for release — the vault signs the EIP-191 +// gate and sends ethAddress/ethSignature. HARD DEPENDENCY: Pioneer must have the +// server-side gate deployed (accept + verify the fields) or /hive/create-account +// 400s on the excess fields. See docs/handoff-pioneer-hive-eth-gate.md. +// ponytail: in-code flag, flip + rebuild. +const HIVE_ETH_GATE = true // True after the per-session incremental scan has caught the wallet up to // chain tip. The `verified` field on `zcashShieldedStatus` reports this so // API clients (and any future UI gating) get an honest answer about whether @@ -578,6 +768,7 @@ function loadSettings() { walletConnectEnabled = getSetting('walletconnect_enabled') === '1' bip85Enabled = getSetting('bip85_enabled') === '1' zcashPrivacyEnabled = getSetting('zcash_privacy_enabled') === '1' + hiveEnabled = getSetting('hive_enabled') === '1' emulatorEnabled = getSetting('emulator_enabled') === '1' preReleaseUpdates = getSetting('pre_release_updates') === '1' alphaFirmware = getSetting('alpha_firmware') === '1' @@ -882,6 +1073,7 @@ function getAppSettings() { walletConnectEnabled, bip85Enabled, zcashPrivacyEnabled, + hiveEnabled, emulatorEnabled, preReleaseUpdates, alphaFirmware, @@ -1192,6 +1384,7 @@ async function applyRestApiState() { // ── Swap quote cache (last 10 quotes for tracker data) ─────────────── import type { SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult, SwapSubStage } from '../shared/types' +import { isThorchainBankToken, thorchainBankTokenFirmwareOK, THORCHAIN_BANK_TOKEN_MIN_FW } from '../shared/swap-support-matrix' const swapQuoteCache = new Map() // ── Emulator confirm helper ────────────────────────────────────────── @@ -1443,6 +1636,17 @@ function maybeStartBackgroundWalletVerification(): void { async function headlessSwapQuote(params: SwapQuoteParams): Promise { const { getSwapQuote } = await import('./swap') + // Firmware gate (see buildTx): selling a THORChain/Maya bank token (TCY, + // RUJI) requires firmware 7.15+. Refuse the quote on older firmware — the + // single chokepoint for both in-app and REST swaps — so the flow can't + // reach signing. Buying these (toCaip) is fine: the user only receives. + if (params.fromCaip && isThorchainBankToken(params.fromCaip)) { + const fw = engine.getDeviceState().firmwareVersion + if (!thorchainBankTokenFirmwareOK(params.fromCaip, fw)) { + throw new Error(`TCY / RUJI swaps require KeepKey firmware ${THORCHAIN_BANK_TOKEN_MIN_FW}+ (device has ${fw || 'unknown'}). Update your firmware.`) + } + } + // Resolve xpub addresses to real receive addresses for UTXO chains. // ChainBalance.address can be an xpub when Pioneer doesn't return // an address field — THORChain rejects xpubs as destination addresses. @@ -1525,6 +1729,26 @@ async function headlessSwapQuote(params: SwapQuoteParams): Promise { }]) estXpub = results?.[0]?.xpub estAccountPath = fromChain.defaultPath.slice(0, 3) + // Merge device-cached account-1+ xpubs (audit "track") so the sendMax + // fee estimator aggregates the SAME funds getBalances/buildTx do. Without + // this, a funded account 1 with an empty account 0 yields no UTXOs here → + // estimateUtxoFee returns null → the net re-quote below is skipped → the + // NEAR Intents sendMax deposit under-delivers. Mirrors the buildTx merge. + const utxoDevId = engine.getDeviceState().deviceId + if (estXpub && utxoDevId && !engine.isPassphraseWallet) { + const merged: Array<{ xpub: string; scriptType: string; accountPath: number[] }> = [ + { xpub: estXpub, scriptType: fromChain.scriptType || 'p2pkh', accountPath: estAccountPath }, + ] + const seen = new Set(merged.map(x => x.xpub)) + for (const pk of getCachedPubkeys(utxoDevId)) { + if (pk.chainId !== fromChain.id || !pk.xpub || seen.has(pk.xpub)) continue + const acctPath = parseBip32Path(pk.path) + if (!acctPath || acctPath.length !== 3) continue + merged.push({ xpub: pk.xpub, scriptType: pk.scriptType || fromChain.scriptType || 'p2pkh', accountPath: acctPath }) + seen.add(pk.xpub) + } + if (merged.length > 1) estXpubs = merged + } } const hasXpub = (estXpubs && estXpubs.length > 0) || estXpub if (fromChain && hasXpub) { @@ -1562,6 +1786,18 @@ async function headlessSwapQuote(params: SwapQuoteParams): Promise { async function headlessExecuteSwap(params: ExecuteSwapParams, pushSubStage: (stage: SwapSubStage) => void): Promise { if (!engine.wallet) throw new Error('No device connected') + + // Firmware gate ENFORCED at execute time, not just quote time: /api/v2/swap/ + // execute (rest-swap.ts) calls this directly, so a stale or crafted execute + // payload must not bypass the quote-time check and reach signing on firmware + // that would sign the wrong asset. Mirrors headlessSwapQuote + buildTx. + if (params.fromCaip && isThorchainBankToken(params.fromCaip)) { + const fw = engine.getDeviceState().firmwareVersion + if (!thorchainBankTokenFirmwareOK(params.fromCaip, fw)) { + throw new Error(`TCY / RUJI swaps require KeepKey firmware ${THORCHAIN_BANK_TOKEN_MIN_FW}+ (device has ${fw || 'unknown'}). Update your firmware.`) + } + } + const { executeSwap } = await import('./swap') const { trackSwap, isTrackerInitialized, initSwapTracker } = await import('./swap-tracker') // Ensure tracker is initialized before tracking (guards against race/init failure) @@ -1656,6 +1892,67 @@ async function headlessExecuteSwap(params: ExecuteSwapParams, pushSubStage: (sta return result } +// ── Firmware-capability cache for device address derivation ────────── +// A chain can be added to the vault ahead of the firmware that implements its +// address message (Hive is the current case). Deriving it then sends a message +// the device doesn't recognize; the firmware replies FAILURE "Unknown message", +// and on some builds that unrecognized round-trip knocks the device off the USB +// bus — bouncing the user from the dashboard back to the wallet picker on every +// balance refresh. Probe once per device+firmware, remember the miss, and skip +// that chain until the firmware version changes. +const unsupportedDeviceMessages = new Set() +const capabilityKey = (chainId: string) => { + const s = engine.getDeviceState() + return `${s.deviceId || 'unknown'}:${s.firmwareVersion || '?'}:${chainId}` +} +/** True when an address-derivation error is the firmware rejecting an + * unrecognized message type (vs. a transient/device-busy failure we should + * retry). readResponse turns the device Failure into `{ code, message }` via + * toObject(); match the firmware's exact FAILURE_UnexpectedMessage text. */ +const isUnsupportedMessageError = (e: any): boolean => { + const failure = e?.message + const text = typeof failure === 'string' ? failure : (failure?.message ?? '') + return /unknown message/i.test(String(text)) +} + +/** + * Derive the display/native address for a non-EVM, non-UTXO chain — the ONE + * place that owns the address-message contract: param shape (scriptType, TON + * bounceable), the ripple method quirk, the `{ address }` vs `{ publicKey }` + * result shapes (hive is pubkey-identified), and unknown-message capability + * recording (re-sending a rejected message can knock the device off USB). + * Shared by getBalances and getBalance so the derive paths cannot drift — + * that drift is exactly what broke every single-chain hive refresh. + * + * Returns null when this firmware doesn't implement the chain's message (the + * miss is recorded + the stale cache row dropped). Throws on other failures. + */ +async function deriveChainAddress(wallet: any, chain: ChainDef): Promise { + const capDevId = engine.getDeviceState().deviceId || 'unknown' + const capKey = capabilityKey(chain.id) + if (unsupportedDeviceMessages.has(capKey)) return null + const addrParams: any = { addressNList: chain.defaultPath, showDisplay: false, coin: chain.coin } + if (chain.scriptType) addrParams.scriptType = chain.scriptType + // TON: always non-bounceable (UQ) — bounceable (EQ) bounces if wallet uninitialized + if (chain.chainFamily === 'ton') addrParams.bounceable = false + const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod + try { + const result = await (wallet as any)[method](addrParams) + return typeof result === 'string' ? result : result?.address || result?.publicKey || null + } catch (e: any) { + if (isUnsupportedMessageError(e)) { + unsupportedDeviceMessages.add(capKey) + // Drop any stale cached balance for this chain (e.g. cached on a prior + // firmware that did implement it) so a now-underivable chain can't + // linger in the dashboard. + if (capDevId !== 'unknown') deleteCachedChainBalance(capDevId, chain.id) + console.warn(`[derive] ${chain.id}: firmware does not implement ${chain.rpcMethod} ("Unknown message") — skipping until firmware updates`) + return null + } + throw e + } +} + // ── RPC Bridge (Electrobun UI ↔ Bun) ───────────────────────────────── const rpc = BrowserView.defineRPC({ maxRequestTime: 1_800_000, // 30 minutes — generous for device-interactive ops, but not infinite @@ -1885,7 +2182,18 @@ const rpc = BrowserView.defineRPC({ }, applyPolicy: async (params) => { if (!engine.wallet) throw new Error('No device connected') - await engine.wallet.applyPolicy({ policyName: params.policyName, enabled: params.enabled }) + // applyPolicy raises an "ENABLE/DISABLE POLICY" confirm on the device + // and blocks on a button press. On the emulator that needs the + // interactive Accept/Reject affordance or it hangs until timeout. + const apply = () => engine.wallet!.applyPolicy({ policyName: params.policyName, enabled: params.enabled }) + if (engine.isEmulator) { + await emuSigningOp(apply, { + operation: 'applyPolicy', + opLabel: `${params.enabled ? 'Enable' : 'Disable'} ${params.policyName} policy`, + }) + } else { + await apply() + } clearFeaturesCache() try { await engine.refreshFeaturesSnapshot() @@ -1907,7 +2215,10 @@ const rpc = BrowserView.defineRPC({ if (!/^https?:\/\//i.test(url)) { throw new Error("openExternal: only http(s) URLs are allowed") } - const cmd = process.platform === "win32" ? ["cmd", "/c", "start", "", url] + // `cmd /c start` re-parses its command line with cmd.exe's own quoting rules + // (not Bun.spawn's Win32 argv-escaping), so URLs with `&` get split into + // separate commands. rundll32 takes the URL as a normal argv element instead. + const cmd = process.platform === "win32" ? ["rundll32", "url.dll,FileProtocolHandler", url] : process.platform === "darwin" ? ["open", url] : ["xdg-open", url] try { @@ -1917,6 +2228,21 @@ const rpc = BrowserView.defineRPC({ } return { ok: true as const } }, + captureScreens: async () => { + // Permission check BEFORE minimizing — a denied request opens + // System Settings, and bouncing the window first looks broken. + const denied = ensureScreenPermission() + if (denied) return denied + // Minimize so the vault window doesn't cover the QR being scanned, + // wait out the minimize animation, capture, restore. + try { _mainWindow?.minimize() } catch { /* headless / window gone */ } + await new Promise((r) => setTimeout(r, 600)) + try { + return await captureScreens() + } finally { + try { _mainWindow?.unminimize() } catch { /* ignore */ } + } + }, cancelDeviceSigning: async () => { // User backed out of an in-flight confirm/PIN/passphrase prompt. // Sends a Cancel message to the device, which dismisses the on- @@ -1952,11 +2278,16 @@ const rpc = BrowserView.defineRPC({ // ── Address derivation ──────────────────────────────────── btcGetAddress: async (params) => { if (!engine.wallet) throw new Error('No device connected') + // btcGetAddress is shared by every UTXO chain (Litecoin, Dash, Dogecoin, …), + // so resolve the cache key from params.coin instead of hardcoding bitcoin — + // otherwise an altcoin address overwrites bitcoin's cache and never persists + // under its own chain. + const chainId = CHAINS.find(c => c.coin === params.coin)?.id || 'bitcoin' const result = (engine.isEmulator && params.showDisplay) - ? await emuSigningOp(() => engine.wallet!.btcGetAddress(params), { operation: 'btcGetAddress', chain: 'Bitcoin' }) + ? await emuSigningOp(() => engine.wallet!.btcGetAddress(params), { operation: 'btcGetAddress', chain: params.coin || 'Bitcoin' }) : await engine.wallet.btcGetAddress(params) const addr = typeof result === 'string' ? result : result?.address - if (addr) cacheAddress('bitcoin', JSON.stringify(params.addressNList || []), addr) + if (addr) cacheAddress(chainId, JSON.stringify(params.addressNList || []), addr) return result }, ethGetAddress: async (params) => { @@ -2337,9 +2668,141 @@ const rpc = BrowserView.defineRPC({ if (!result) throw new Error('hiveGetPublicKey returned no result') return result }, + // Derive all four SLIP-0048 role keys (owner/active/posting/memo) for an + // account index, for the Hive onboarding panel (account creation needs all 4). + hiveGetRoleKeys: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const accountIndex = params?.accountIndex ?? 0 + const roles = ['owner', 'active', 'posting', 'memo'] as const + const out: Record = {} + for (const role of roles) { + const r = await (engine.wallet as any).hiveGetPublicKey({ + addressNList: hiveRolePath(role, accountIndex), + showDisplay: false, + }) + if (!r?.publicKey) throw new Error(`hiveGetPublicKey(${role}) returned no key`) + out[role] = r.publicKey + } + return out as { owner: string; active: string; posting: string; memo: string } + }, + // Resolve a Hive account from a device public key via Pioneer. Returns + // { noAccount: true } when the key controls no account yet (onboarding + // case), or { account: {...} } with balances/RC when it does. + hiveGetAccount: async (params) => { + const pubkey = params?.pubkey + if (!pubkey) throw new Error('hiveGetAccount requires a pubkey') + const base = getPioneerApiBase() + const resp = await fetch(`${base}/api/v1/hive/account/${encodeURIComponent(pubkey)}`) + if (!resp.ok) throw new Error(`Pioneer hive/account ${resp.status}`) + return await resp.json() + }, + // Live username availability (format + on-chain), for the onboarding wizard. + hiveUsernameAvailable: async (params) => { + const name = params?.name + if (!name) return { success: false, available: false, reason: 'empty' } + const base = getPioneerApiBase() + const resp = await fetch(`${base}/api/v1/hive/username-available/${encodeURIComponent(name)}`) + return await resp.json() + }, + // Full sponsor-backed account creation: derive the 4 device keys, get a + // device attestation (owner-signed account_create, op 9), POST to Pioneer's + // sponsor endpoint. The sponsor (@keepkey) pays; no user key leaves the device. + hiveCreateAccount: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const username = params?.username + if (!username) throw new Error('hiveCreateAccount requires a username') + const wallet = engine.wallet as any + const accountIndex = params?.accountIndex ?? 0 + + // 1. Derive the four SLIP-0048 role keys. + const roles = ['owner', 'active', 'posting', 'memo'] as const + const keys: Record = {} + for (const role of roles) { + const r = await wallet.hiveGetPublicKey({ addressNList: hiveRolePath(role, accountIndex), showDisplay: false }) + if (!r?.publicKey) throw new Error(`hiveGetPublicKey(${role}) returned no key`) + keys[role] = r.publicKey + } + + // 2. Device attestation — owner-signed account_create (op 9). The device + // re-derives the keys itself; ref_block/expiration are unchecked by the + // server (attestation only, not broadcast). On the emulator, route + // through emuSigningOp so the interactive Confirm/Reject gate appears; + // a real device uses its physical button (calling emuSigningOp there + // would pop the emulator window). + const signAccountCreate = () => wallet.hiveSignAccountCreate({ + addressNList: hiveRolePath('owner', accountIndex), + refBlockNum: 0, refBlockPrefix: 0, expiration: 0, + creator: 'keepkey', + newAccountName: username, + ownerKey: keys.owner, activeKey: keys.active, + postingKey: keys.posting, memoKey: keys.memo, + feeAmount: 3000, + }) + const signed = engine.isEmulator + ? await emuSigningOp(signAccountCreate, { operation: 'hiveSignAccountCreate', chain: 'Hive' }) + : await signAccountCreate() + if (!signed?.signature || !signed?.serializedTx) { + throw new Error('Device did not return an attestation') + } + const toHex = (v: any) => v instanceof Uint8Array ? Buffer.from(v).toString('hex') : String(v) + + // 3. ETH gate (anti-sponsor-drain). Sign a fixed EIP-191 message bound to + // username+ownerKey with the device ETH key (m/44'/60'/0'/0/0). The + // server recovers the address, requires it to hold mainnet ETH, and + // allows one sponsored account per address. Message bytes are pinned by + // a server unit test — do NOT reformat (no trailing newline). + // GATED: deployed Pioneer has no server side yet (rejects these fields), + // so skip the signing entirely (avoids a pointless device confirm). + let ethSigned: { address: string; signature: string } | null = null + if (HIVE_ETH_GATE) { + const gateMessage = `KeepKey Hive onboarding\nusername:${username}\nowner:${keys.owner}` + const gateMessageHex = '0x' + Buffer.from(gateMessage, 'utf8').toString('hex') + const signEthGate = () => wallet.ethSignMessage({ addressNList: evmAddressPath(0), message: gateMessageHex }) + // hdwallet returns { address: eip55 0x…, signature: 0x…+65-byte r||s||v } — + // exactly the form ethers.verifyMessage expects. + ethSigned = engine.isEmulator + ? await emuSigningOp(signEthGate, { operation: 'ethSignMessage', chain: 'Ethereum', memo: gateMessage.slice(0, 64) }) + : await signEthGate() + if (!ethSigned?.address || !ethSigned?.signature) { + throw new Error('Device did not return an ETH gate signature') + } + } + + // 4. POST to the sponsor endpoint. + const base = getPioneerApiBase() + const resp = await fetch(`${base}/api/v1/hive/create-account`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username, + ownerKey: keys.owner, activeKey: keys.active, + postingKey: keys.posting, memoKey: keys.memo, + attestation: { serializedTx: toHex(signed.serializedTx), signature: toHex(signed.signature) }, + ...(ethSigned ? { ethAddress: ethSigned.address, ethSignature: ethSigned.signature } : {}), + }), + }) + const body = await resp.json().catch(() => ({})) + if (resp.status !== 200) { + // Surface WHY Pioneer rejected — the 400 body carries field-level + // validation detail the frontend otherwise throws away. + console.error('[hiveCreateAccount] Pioneer', resp.status, 'rejected:', JSON.stringify(body)) + console.error('[hiveCreateAccount] sent:', JSON.stringify({ + username, + ownerKey: keys.owner, activeKey: keys.active, postingKey: keys.posting, memoKey: keys.memo, + attestation: { serializedTx: toHex(signed.serializedTx), signature: toHex(signed.signature) }, + ...(ethSigned ? { ethAddress: ethSigned.address, ethSignature: ethSigned.signature } : {}), + })) + } + return { status: resp.status, ...body } + }, hiveSignTx: async (params) => { if (!engine.wallet) throw new Error('No device connected') - const result = await (engine.wallet as any).hiveSignTx(params) + // Route through emuSigningOp on the emulator so the interactive + // Confirm/Reject gate appears (a real device uses its button). + const signTx = () => (engine.wallet as any).hiveSignTx(params) + const result = engine.isEmulator + ? await emuSigningOp(signTx, { operation: 'hiveSignTx', chain: 'Hive', to: params?.to, value: params?.amount != null ? String(params.amount) : undefined }) + : await signTx() if (!result) throw new Error('hiveSignTx returned no result') return { signature: result.signature instanceof Uint8Array @@ -2348,6 +2811,8 @@ const rpc = BrowserView.defineRPC({ serializedTx: result.serializedTx instanceof Uint8Array ? Buffer.from(result.serializedTx).toString('hex') : result.serializedTx, + // Pass hiveBuildResult through for broadcastHiveTx + hiveBuildResult: (params as any).hiveBuildResult, } }, @@ -2393,6 +2858,7 @@ const rpc = BrowserView.defineRPC({ const allChains = getAllChains().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 return true }) const utxoChains = allChains.filter(c => c.chainFamily === 'utxo' && c.id !== 'bitcoin') @@ -2430,6 +2896,24 @@ const rpc = BrowserView.defineRPC({ if (xpub) pubkeys.push({ caip: c.caip, pubkey: xpub, chainId: c.id, symbol: c.symbol, networkId: c.networkId }) } + // Merge device-cached UTXO-altcoin xpubs beyond account 0 — persisted + // by the audit "track" action (addUtxoAccount). Device-scoped and never + // written for passphrase wallets, so reading them here is hidden-safe. + // Dedups by xpub against the freshly-derived account-0 entries above. + { + const utxoDevId = engine.getDeviceState().deviceId + if (utxoDevId && !engine.isPassphraseWallet) { + const utxoById = new Map(utxoChains.map(c => [c.id, c])) + const seen = new Set(pubkeys.map(p => p.pubkey)) + for (const pk of getCachedPubkeys(utxoDevId)) { + const c = utxoById.get(pk.chainId) + if (!c || !pk.xpub || seen.has(pk.xpub)) continue + pubkeys.push({ caip: c.caip, pubkey: pk.xpub, chainId: c.id, symbol: c.symbol, networkId: c.networkId }) + seen.add(pk.xpub) + } + } + } + // Initialize EVM multi-address manager const evmChains = nonUtxoChains.filter(c => c.chainFamily === 'evm') const nonEvmChains = nonUtxoChains.filter(c => c.chainFamily !== 'evm') @@ -2461,19 +2945,13 @@ const rpc = BrowserView.defineRPC({ if (chain.hidden) continue const t0 = Date.now() try { - const addrParams: any = { addressNList: chain.defaultPath, showDisplay: false, coin: chain.coin } - if (chain.scriptType) addrParams.scriptType = chain.scriptType - // TON: always non-bounceable (UQ) — bounceable (EQ) bounces if wallet uninitialized - if (chain.chainFamily === 'ton') addrParams.bounceable = false - const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod - const result = await wallet[method](addrParams) - const address = typeof result === 'string' ? result : result?.address || result?.publicKey || '' + // Shared derive (capability recording + { publicKey } fallback live + // inside deriveChainAddress). null = firmware miss or empty result. + const address = await deriveChainAddress(wallet, chain) const ms = Date.now() - t0 if (address) { console.log(`[getBalances] ${chain.id} address derived in ${ms}ms: ${address.substring(0, 20)}... caip=${chain.caip}`) pubkeys.push({ caip: chain.caip, pubkey: address, chainId: chain.id, symbol: chain.symbol, networkId: chain.networkId }) - } else { - console.warn(`[getBalances] ${chain.id} address empty after ${ms}ms! method=${method} result=${JSON.stringify(result)}`) } } catch (e: any) { console.warn(`[getBalances] ${chain.id} address THREW (${Date.now() - t0}ms): ${e.message}`) @@ -2686,22 +3164,7 @@ const rpc = BrowserView.defineRPC({ const chainId = networkId ? (networkToChain.get(networkId) || null) : null if (!chainId) { droppedDefi++; continue } const ownerAddr = String(sp.pubkey || '').toLowerCase() - const dp: DefiPosition = { - protocol: sp.protocol || null, - displayName: sp.displayName, - name: sp.displayName || sp.protocol || 'DeFi Position', - network: sp.network, - networkId: sp.networkId, - balanceUsd: Number(sp.balanceUsd) || 0, - icon: sp.icon, - tokens: Array.isArray(sp.tokens) ? sp.tokens.map(t => ({ - networkId: t.networkId, - address: String(t.address || '').toLowerCase(), - symbol: t.symbol, - balance: t.balance != null ? String(t.balance) : undefined, - balanceUsd: typeof t.balanceUsd === 'number' ? t.balanceUsd : undefined, - })).filter(t => !!t.address) : [], - } + const dp = mapServerDefiPosition(sp) // Chain-level (dashboard chain row) const chainList = defiByChain.get(chainId) || [] chainList.push(dp) @@ -2735,19 +3198,12 @@ const rpc = BrowserView.defineRPC({ console.log(` BTC: caip=${b.caip}, pubkey=${String(b.pubkey).substring(0, 24)}..., balance=${b.balance}, valueUsd=${b.valueUsd}, address=${b.address}`) } - // Classify entries into natives vs tokens + // Classify entries into natives vs tokens (shared isTokenEntry) const pureNatives: any[] = [] const tokenEntries: any[] = [] for (const entry of allEntries) { - const caip = entry.caip || '' - const caipPath = caip.split('/')[1] || '' - const isTokenByCaip = caipPath && !caipPath.startsWith('slip44:') && !caipPath.startsWith('native:') - const isTokenByType = entry.type === 'token' || (entry.isNative === false && entry.contract) - if (isTokenByCaip || isTokenByType) { - tokenEntries.push(entry) - } else { - pureNatives.push(entry) - } + if (isTokenEntry(entry)) tokenEntries.push(entry) + else pureNatives.push(entry) } console.log(`[getBalances] After classification: ${pureNatives.length} natives, ${tokenEntries.length} tokens`) @@ -2786,32 +3242,7 @@ const rpc = BrowserView.defineRPC({ continue } - // Extract contract address from CAIP: - // ERC-20: "eip155:1/erc20:0xdac17..." → "0xdac17..." - // SPL: "solana:5eykt4.../spl:TokenMint..." → "TokenMint..." - // TRC-20: "tron:27Lqcw/trc20:T..." → "T..." - const contractMatch = (tok.caip || '').match(/\/(erc20|spl|trc20|token):([^\s]+)/) - const contractAddress = contractMatch?.[2] || tok.contract || undefined - - const rawValueUsd = tok.valueUsd - const rawPriceUsd = tok.priceUsd - const parsedBalanceUsd = Number(rawValueUsd ?? 0) - const parsedPriceUsd = Number(rawPriceUsd ?? 0) - - const token: TokenBalance = { - symbol: tok.symbol || '???', - name: tok.name || tok.symbol || 'Unknown Token', - balance: String(tok.balance ?? '0'), - balanceUsd: parsedBalanceUsd, - priceUsd: parsedPriceUsd, - caip: tok.caip || '', - contractAddress, - networkId: tokNetworkId || caipPrefix, - icon: tok.icon || undefined, - decimals: tok.decimals ?? tok.precision, - type: tok.type || 'token', - dataSource: tok.dataSource, - } + const token = parseTokenEntry(tok) const existing = tokensByChainId.get(parentChainId) || [] existing.push(token) @@ -2829,47 +3260,22 @@ const rpc = BrowserView.defineRPC({ console.debug(`[getBalances] Token grouping: ${tokensGrouped} grouped, ${tokensSkippedZero} skipped (zero bal), ${tokensSkippedNoChain} DROPPED (no parent chain)`) - // Deduplicate tokens within each chain by normalized CAIP. - // EVM addresses are case-insensitive hex — normalize for dedup only (caip on - // the object stays canonical). Non-EVM identifiers (Solana base58 mint, - // Tron base58check) are case-sensitive — keep them exact. - // When Pioneer returns the same ERC-20 for multiple EVM address indices, - // sum their balances so portfolio value is not underreported. + // Deduplicate tokens within each chain (shared dedupeTokensByCaip). for (const [chainId, chainTokens] of tokensByChainId) { - const seen = new Map() - for (const tok of chainTokens) { - const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip - const existing = seen.get(key) - if (!existing) { - seen.set(key, { ...tok }) - } else { - existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) - existing.balanceUsd += tok.balanceUsd - } - } - if (seen.size < chainTokens.length) { - console.debug(`[getBalances] Deduped ${chainId}: ${chainTokens.length} → ${seen.size} tokens`) - tokensByChainId.set(chainId, [...seen.values()]) + const deduped = dedupeTokensByCaip(chainTokens) + if (deduped.length < chainTokens.length) { + console.debug(`[getBalances] Deduped ${chainId}: ${chainTokens.length} → ${deduped.length} tokens`) + tokensByChainId.set(chainId, deduped) } } // EVM AssetPage shows per-address tokens from evmTokensByOwner, not tokensByChainId. // Pioneer returns the same token multiple times per address — dedup that map too. for (const [ownerKey, ownerTokens] of evmTokensByOwner) { - const seen = new Map() - for (const tok of ownerTokens) { - const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip - const existing = seen.get(key) - if (!existing) { - seen.set(key, { ...tok }) - } else { - existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) - existing.balanceUsd += tok.balanceUsd - } - } - if (seen.size < ownerTokens.length) { - console.debug(`[getBalances] Deduped owner ${ownerKey}: ${ownerTokens.length} → ${seen.size} tokens`) - evmTokensByOwner.set(ownerKey, [...seen.values()]) + const deduped = dedupeTokensByCaip(ownerTokens) + if (deduped.length < ownerTokens.length) { + console.debug(`[getBalances] Deduped owner ${ownerKey}: ${ownerTokens.length} → ${deduped.length} tokens`) + evmTokensByOwner.set(ownerKey, deduped) } } @@ -2888,6 +3294,17 @@ const rpc = BrowserView.defineRPC({ // Aggregate EVM entries per-chain (sum across address indices) const evmChainAgg = new Map() + // Non-BTC UTXO chains carry multiple xpubs (script types + tracked accounts) + // sharing one caip. Pioneer returns one row PER pubkey, so a caip-level find() + // grabs the chain's first row (often an empty legacy xpub) and reports 0 while + // funds sit under another script type. Sum per-pubkey, mirroring BTC. + const utxoChainEntryCount = new Map() + for (const p of pubkeys) { + if (p.chainId === 'bitcoin' || evmAddressSet.has(p.pubkey.toLowerCase())) continue + utxoChainEntryCount.set(p.chainId, (utxoChainEntryCount.get(p.chainId) || 0) + 1) + } + const utxoChainAgg = new Map() + const selectedXpubStr = btcAccounts.getSelectedXpub()?.xpub for (const entry of pubkeys) { const isFailedEntry = failedPubkeySetForDb.has(`${entry.caip}:${entry.pubkey}`) @@ -2956,6 +3373,26 @@ const rpc = BrowserView.defineRPC({ continue } + // Multi-xpub UTXO chain (LTC/DOGE/…): match strictly by THIS entry's pubkey + // and sum across the chain's xpubs — caip matching cannot distinguish them. + if ((utxoChainEntryCount.get(entry.chainId) || 0) > 1) { + const match = pureNatives.find((d: any) => d.pubkey === entry.pubkey) + || pureNatives.find((d: any) => d.address === entry.pubkey) + const bal = parseFloat(String(match?.balance ?? '0')) || 0 + const usd = Number(match?.valueUsd ?? 0) + const agg = utxoChainAgg.get(entry.chainId) + if (agg) { + agg.balance += bal + agg.usd += usd + if (!agg.address && match?.address) agg.address = match.address + agg.matched = agg.matched || !!match + agg.allOk = agg.allOk && !isFailedEntry + } else { + utxoChainAgg.set(entry.chainId, { balance: bal, usd, address: match?.address || '', symbol: entry.symbol, matched: !!match, allOk: !isFailedEntry }) + } + continue + } + // Match by CAIP, then by networkId prefix (handles slip44 vs native CAIP variants), // then pubkey, then address field (Pioneer may use either) const entryNetwork = entry.caip.split('/')[0] // e.g. "tron:0x2b6653dc" @@ -2977,6 +3414,20 @@ const rpc = BrowserView.defineRPC({ }) } + // Push aggregated multi-xpub UTXO chain entries (one row per chain) + for (const [chainId, agg] of utxoChainAgg) { + const chainTokens = tokensByChainId.get(chainId) + const tokenUsdTotal = chainTokens?.reduce((sum, t) => sum + t.balanceUsd, 0) || 0 + results.push({ + chainId, symbol: agg.symbol, + balance: agg.balance > 0 ? agg.balance.toFixed(8).replace(/0+$/, '').replace(/\.$/, '') : '0', + balanceUsd: agg.usd + tokenUsdTotal, + nativeBalanceUsd: agg.usd, + address: agg.address || pubkeys.find(p => p.chainId === chainId)?.pubkey || '', + tokens: chainTokens && chainTokens.length > 0 ? chainTokens : undefined, + }) + } + // Push aggregated EVM chain entries for (const [chainId, agg] of evmChainAgg) { const chainTokens = tokensByChainId.get(chainId) @@ -3012,50 +3463,18 @@ const rpc = BrowserView.defineRPC({ }) } - // Attach shielded ZEC as a synthetic token under native Zcash so the - // dashboard renders it like an ERC20 sub-row. The Orchard FVK lives - // in the local sidecar; the seed never left the device. - // Only surface a shielded balance once the cached FVK has been PROVEN - // to belong to the connected device. Otherwise a stale FVK from a - // previous device/seed would show a phantom balance the user can't - // actually spend. When not yet verified, kick off the device-match - // check (which purges stale state on mismatch) and skip this round — - // the balance appears on the next refresh once verified. - if (zcashPrivacyEnabled && hasFvkLoaded() && !zcashDeviceVerified) { - maybeStartBackgroundWalletVerification() - } - if (zcashPrivacyEnabled && hasFvkLoaded() && zcashDeviceVerified) { - try { - const shielded = await Promise.race([ - getShieldedBalance(), - new Promise(r => setTimeout(() => r(null), 5000)), - ]) - if (shielded && shielded.confirmed > 0) { - const zcashEntry = results.find(r => r.chainId === 'zcash') - if (zcashEntry) { - const zcashCaip = 'bip122:00040fe8ec8471911baa1db1266ea15d/slip44:133' - const zcashNative = pureNatives.find((d: any) => d.caip === zcashCaip) - const zecPrice = parseFloat(zcashNative?.priceUsd ?? '0') - const zecAmount = shielded.confirmed / 1e8 - const shieldedUsd = zecAmount * zecPrice - zcashEntry.tokens = zcashEntry.tokens || [] - zcashEntry.tokens.push({ - symbol: 'zZEC', - name: 'Shielded ZEC', - balance: zecAmount.toFixed(8), - balanceUsd: shieldedUsd, - priceUsd: zecPrice, - caip: 'bip122:00040fe8ec8471911baa1db1266ea15d/orchard:shielded', - contractAddress: 'orchard', - networkId: 'bip122:00040fe8ec8471911baa1db1266ea15d', - decimals: 8, - type: 'shielded', - }) - zcashEntry.balanceUsd = (zcashEntry.balanceUsd || 0) + shieldedUsd - } + // Attach shielded ZEC as a synthetic token under native Zcash + // (verification + FVK rules live in fetchShieldedZecToken). + { + const zcashEntry = results.find(r => r.chainId === 'zcash') + if (zcashEntry) { + const zcashCaip = 'bip122:00040fe8ec8471911baa1db1266ea15d/slip44:133' + const zcashNative = pureNatives.find((d: any) => d.caip === zcashCaip) + const shieldedTok = await fetchShieldedZecToken(parseFloat(zcashNative?.priceUsd ?? '0')) + if (shieldedTok) { + zcashEntry.tokens = [...(zcashEntry.tokens || []), shieldedTok] + zcashEntry.balanceUsd = (zcashEntry.balanceUsd || 0) + shieldedTok.balanceUsd } - } catch (e: any) { - console.warn('[getBalances] Shielded balance fetch failed:', e?.message || e) } } @@ -3071,6 +3490,13 @@ const rpc = BrowserView.defineRPC({ const btcConfirmed = btcPubkeyEntries.every(e => !failedPubkeySetForDb.has(`${e.caip}:${e.pubkey}`)) if (btcConfirmed) confirmedChainIds.add('bitcoin') else confirmedChainIds.delete('bitcoin') + // Never persist an aggregate we didn't fully validate: like BTC above, a + // multi-xpub chain is confirmed only if EVERY pubkey's chunk succeeded AND + // at least one row came back — else a partial/zero sum would force-write + // over a good cached balance. Unconfirmed → guarded upsert keeps non-zero. + for (const [chainId, agg] of utxoChainAgg) { + if (!agg.matched || !agg.allOk) confirmedChainIds.delete(chainId) + } // Mark that Pioneer has responded — prevents getBtcAccounts from re-loading stale DB rows if (btcPubkeyEntries.length > 0) btcAccounts.markPioneerFetched() @@ -3195,6 +3621,8 @@ const rpc = BrowserView.defineRPC({ // event.data.type is the real direction ('incoming' | 'outgoing'). // Forward it verbatim — frontend resyncs either way (both change the // balance) but only shows the "Incoming payment" toast for 'incoming'. + // Mark the txid so the Pioneer-socket leg doesn't re-forward it. + if (txidRecentlyPushed(event.data.txid)) return console.log(`[event-stream] ${event.data.type} tx ${event.data.txid} → ${event.data.address} (${event.data.networkId})`) try { rpc.send['tx-push-received']({ chain: event.data.caip, @@ -3206,7 +3634,16 @@ const rpc = BrowserView.defineRPC({ } if (event.type === 'tx:confirmed') { console.log(`[event-stream] Confirmed tx ${event.data.txid} (${event.data.confirmations} confs)`) - try { rpc.send['tx-push-received']({ networkId: event.data.networkId, txid: event.data.txid, type: 'confirmed' }) } catch { /* webview not ready */ } + // Debounce per network: the stream re-fires tx:confirmed on every + // confirmation update, and each forward costs a forced getBalance. + const confKey = `confirmed:${event.data.networkId}` + const pending = pioneerEventDebounce.get(confKey) + if (pending) clearTimeout(pending.timer) + const payload = { networkId: event.data.networkId, txid: event.data.txid, type: 'confirmed' as const } + pioneerEventDebounce.set(confKey, { payload, timer: setTimeout(() => { + pioneerEventDebounce.delete(confKey) + try { rpc.send['tx-push-received'](payload) } catch { /* webview not ready */ } + }, 2000) }) } }, (status) => { @@ -3222,6 +3659,29 @@ 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}`) + // 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 + // cache is known-fresh. + const forceRefresh = params.forceRefresh !== false + // Chain gating (mirrors getBalances): firmware minimum + feature flags. + // Returns the same empty-balance shape as the capability gate below — + // throwing here would bounce flows that merely render the page (#310). + const fwVersion = engine.getDeviceState().firmwareVersion + if (!isChainSupported(chain, fwVersion) + || ((chain.id === 'zcash' || chain.id === 'zcash-shielded') && !zcashPrivacyEnabled) + || (chain.id === 'hive' && !hiveEnabled)) { + console.warn(`[getBalance] ${chain.id}: unsupported on firmware ${fwVersion || '?'} or feature-flagged off — returning empty balance`) + return { chainId: chain.id, symbol: chain.symbol, balance: '0', balanceUsd: 0, address: '' } + } + // Firmware-capability gate (mirrors getBalances): if this chain's address + // message was already rejected as unknown, return an empty balance instead + // of re-sending it — the failed round-trip can drop the device off USB and + // bounce the user to the wallet picker (e.g. drilling into Hive to Receive). + if (unsupportedDeviceMessages.has(capabilityKey(chain.id))) { + 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 wallet = engine.wallet as any @@ -3267,20 +3727,31 @@ const rpc = BrowserView.defineRPC({ for (const entry of btcPubkeyEntries) pubkeys.push({ caip: entry.caip, pubkey: entry.pubkey }) // displayAddress left empty — UTXO: frontend auto-derives from device } else if (chain.chainFamily === 'utxo') { - // Non-BTC UTXO: derive all script-type xpubs (LTC has 3, others have 1) - const scriptTypes = chain.id === 'litecoin' - ? [{ scriptType: 'p2pkh', purpose: 44 }, { scriptType: 'p2sh-p2wpkh', purpose: 49 }, { scriptType: 'p2wpkh', purpose: 84 }] - : [{ scriptType: chain.scriptType || 'p2pkh', purpose: 44 }] - const paths = scriptTypes.map(st => ({ - addressNList: [st.purpose + 0x80000000, chain.defaultPath[1], 0x80000000], - coin: chain.coin, scriptType: st.scriptType, curve: 'secp256k1', + // Non-BTC UTXO: derive account-0 xpubs via the shared helper (standard + // script-type set + the chain's own receive convention — see + // utxoAccountScriptPaths for why the latter is load-bearing on LTC). + const sps = utxoAccountScriptPaths(chain, 0) + const paths = sps.map(sp => ({ + addressNList: sp.path, + coin: chain.coin, scriptType: sp.scriptType, curve: 'secp256k1', })) const results = await wallet.getPublicKeys(paths) let anyXpub = false - for (let i = 0; i < scriptTypes.length; i++) { + for (let i = 0; i < sps.length; i++) { const xpub = results?.[i]?.xpub if (xpub) { pubkeys.push({ caip: chain.caip, pubkey: xpub }); anyXpub = true } } + // Merge device-cached account-1+ xpubs (audit "track") so funds beyond + // account 0 are spendable. Device-scoped, never written for passphrase + // wallets. Mirrors the portfolio merge in getBalances. + const utxoDevId = engine.getDeviceState().deviceId + if (utxoDevId && !engine.isPassphraseWallet) { + const seen = new Set(pubkeys.map(p => p.pubkey)) + for (const pk of getCachedPubkeys(utxoDevId)) { + if (pk.chainId !== chain.id || !pk.xpub || seen.has(pk.xpub)) continue + pubkeys.push({ caip: chain.caip, pubkey: pk.xpub }); seen.add(pk.xpub); anyXpub = true + } + } if (!anyXpub) throw new Error(`Could not derive xpub for ${chain.coin}`) } else if (chain.chainFamily === 'evm') { // EVM multi-address: send all tracked addresses (matches getBalances behavior) @@ -3303,12 +3774,9 @@ const rpc = BrowserView.defineRPC({ displayAddress = addr } } else { - const addrParams: any = { addressNList: chain.defaultPath, showDisplay: false, coin: chain.coin } - if (chain.scriptType) addrParams.scriptType = chain.scriptType - if (chain.chainFamily === 'ton') addrParams.bounceable = false - const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod - const result = await wallet[method](addrParams) - const addr = typeof result === 'string' ? result : result?.address || '' + // Shared derive — unknown-message capability recording and the + // { publicKey } result fallback (hive) live inside deriveChainAddress. + const addr = await deriveChainAddress(wallet, chain) if (!addr) throw new Error(`Could not derive address for ${chain.coin}`) pubkeys.push({ caip: chain.caip, pubkey: addr }) displayAddress = addr @@ -3326,6 +3794,9 @@ const rpc = BrowserView.defineRPC({ let balance = '0', balanceUsd = 0, address = displayAddress let tokens: TokenBalance[] | undefined let chainDefiPositions: DefiPosition[] | undefined + // True once Pioneer answered with NON-degraded data — gates the force + // cache overwrite below (degraded responses must not clobber the cache). + let pioneerConfirmed = false // Snapshot pre-refresh address from cache so we can preserve it on Pioneer failure (Finding 3) let cachedAddress = '' try { @@ -3358,7 +3829,7 @@ const rpc = BrowserView.defineRPC({ let resp: any try { resp = await withTimeout( - pioneer.GetPortfolioBalances(portfolioBody, { forceRefresh: true }), + pioneer.GetPortfolioBalances(portfolioBody, { forceRefresh }), PIONEER_TIMEOUT_MS, 'GetPortfolioBalances' ) @@ -3368,15 +3839,25 @@ const rpc = BrowserView.defineRPC({ resp = await withTimeout( pioneer.GetPortfolioBalances( { pubkeys: portfolioBody.pubkeys, ...(isEvm ? { includeDefi: true } : {}) }, - { forceRefresh: true } + { forceRefresh } ), PIONEER_TIMEOUT_MS, 'GetPortfolioBalances' ) } - const rawData = resp?.data?.data || resp?.data || {} - const allEntries: any[] = rawData.balances || (Array.isArray(rawData) ? rawData : []) - const rawDefiPositions: ServerDefiPosition[] = Array.isArray(rawData.defiPositions) ? rawData.defiPositions : [] + const { entries: allEntries, meta: portfolioMeta, defiPositions: respDefiPositions } = unwrapPortfolioResponse(resp) + const rawDefiPositions: ServerDefiPosition[] = respDefiPositions || [] + // Degraded = Pioneer's fresh fetch failed and it served last-known + // cache. Surface it (same banner channel as getBalances) and treat + // the response as UNCONFIRMED — its values must not force-overwrite + // our cache (a degraded zero would clobber a good cached balance). + pioneerConfirmed = !portfolioMeta?.degraded + if (portfolioMeta?.degraded) { + console.warn(`[getBalance] ${chain.coin}: Pioneer served degraded data`) + try { + rpc.send['pioneer-error']({ message: '', url: getPioneerApiBase(), severity: 'warning', degradedChains: [chain.symbol], staleChains: [], staleMinutes: 0, degradedChainIds: [chain.id], staleChainIds: [], unresolvedFaultCount: 0 }) + } catch { /* webview not ready */ } + } console.log(`[getBalance] ${chain.coin}: ${allEntries.length} entries from Pioneer (${pubkeys.length} pubkeys)`) @@ -3399,12 +3880,7 @@ const rpc = BrowserView.defineRPC({ || entryCaipPrefix === chainCaipPrefix if (!belongsToChain) { skippedCrossChain++; continue } - const caip = entry.caip || '' - const caipPath = caip.split('/')[1] || '' - const isTokenByCaip = caipPath && !caipPath.startsWith('slip44:') && !caipPath.startsWith('native:') - const isTokenByType = entry.type === 'token' || (entry.isNative === false && entry.contract) - - if (isTokenByCaip || isTokenByType) { + if (isTokenEntry(entry)) { // Gate 2 (tokens): owner address must be one we requested const ownerAddr = (entry.address || entry.pubkey || '').toLowerCase() if (ownerAddr && !pubkeySet.has(ownerAddr)) continue @@ -3453,7 +3929,7 @@ const rpc = BrowserView.defineRPC({ // PRIVACY: Skip DB write for hidden passphrase wallets. try { const devId = engine.getDeviceState().deviceId - if (devId && !engine.isPassphraseWallet) saveCachedPubkey(devId, 'bitcoin', pk.pubkey, pk.pubkey, match?.address || '', '', xpubBal, usd) + if (devId && !engine.isPassphraseWallet) saveCachedPubkey(devId, 'bitcoin', pk.pubkey, pk.pubkey, match?.address || '', '', xpubBal, usd, pioneerConfirmed) } catch { /* non-fatal */ } } } @@ -3481,22 +3957,7 @@ const rpc = BrowserView.defineRPC({ const caipNorm = (tok.caip || '').startsWith('eip155:') ? (tok.caip || '').toLowerCase() : (tok.caip || '') if (seenByOwnerCaip.has(`${caipNorm}|${ownerAddr}`)) continue seenByOwnerCaip.add(`${caipNorm}|${ownerAddr}`) - const contractMatch = (tok.caip || '').match(/\/(erc20|spl|trc20|token):([^\s]+)/) - const contractAddress = contractMatch?.[2] || tok.contract || undefined - const token: TokenBalance = { - symbol: tok.symbol || '???', - name: tok.name || tok.symbol || 'Unknown Token', - balance: String(tok.balance ?? '0'), - balanceUsd: Number(tok.valueUsd ?? 0), - priceUsd: Number(tok.priceUsd ?? 0), - caip: tok.caip || '', - contractAddress, - networkId: (tok.networkId || '').toLowerCase(), - icon: tok.icon || undefined, - decimals: tok.decimals ?? tok.precision, - type: tok.type || 'token', - dataSource: tok.dataSource, - } + const token = parseTokenEntry(tok) parsedTokens.push(token) if (isEvm) { const ownerAddress = String(tok.address || tok.pubkey || '').toLowerCase() @@ -3509,43 +3970,19 @@ const rpc = BrowserView.defineRPC({ } if (parsedTokens.length > 0) { - // Deduplicate by normalized CAIP — same EVM-only rule as getBalances. - // EVM: lowercase for dedup; canonical caip on the object stays intact. - // Non-EVM: exact match (Solana/Tron identifiers are case-sensitive). - const seen = new Map() - for (const tok of parsedTokens) { - const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip - const existing = seen.get(key) - if (!existing) { - seen.set(key, { ...tok }) - } else { - existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) - existing.balanceUsd += tok.balanceUsd - } - } - tokens = [...seen.values()] + tokens = dedupeTokensByCaip(parsedTokens) const tokenUsdTotal = tokens.reduce((sum, t) => sum + t.balanceUsd, 0) balanceUsd += tokenUsdTotal } console.log(`[getBalance] ${chain.coin}: ${tokens?.length ?? 0} tokens, $${balanceUsd.toFixed(2)} total`) } - // Dedup per-address EVM token lists before writing to evmAddresses. - // tokensByChainId is already deduped above; evmTokensByOwner feeds AssetPage directly. + // Dedup per-address EVM token lists before writing to evmAddresses + // (evmTokensByOwner feeds AssetPage directly). if (isEvm) { for (const [addr, addrTokens] of evmTokensByOwner) { - const seen = new Map() - for (const tok of addrTokens) { - const key = tok.caip.startsWith('eip155:') ? tok.caip.toLowerCase() : tok.caip - const existing = seen.get(key) - if (!existing) { - seen.set(key, { ...tok }) - } else { - existing.balance = String(parseFloat(existing.balance) + parseFloat(tok.balance || '0')) - existing.balanceUsd += tok.balanceUsd - } - } - if (seen.size < addrTokens.length) evmTokensByOwner.set(addr, [...seen.values()]) + const deduped = dedupeTokensByCaip(addrTokens) + if (deduped.length < addrTokens.length) evmTokensByOwner.set(addr, deduped) } } @@ -3559,24 +3996,10 @@ const rpc = BrowserView.defineRPC({ const ownerDefiPositions = new Map() const allChainDefi: DefiPosition[] = [] for (const sp of rawDefiPositions) { + // Single-chain context: keep only positions on THIS chain's network. const spNetworkId = (sp.networkId || '').toLowerCase() - if (!spNetworkId || networkToChain.get(spNetworkId) !== chain.id) continue - const dp: DefiPosition = { - protocol: sp.protocol || null, - displayName: sp.displayName, - name: sp.displayName || sp.protocol || 'DeFi Position', - network: sp.network, - networkId: sp.networkId, - balanceUsd: Number(sp.balanceUsd) || 0, - icon: sp.icon, - tokens: Array.isArray(sp.tokens) ? sp.tokens.map(t => ({ - networkId: t.networkId, - address: String(t.address || '').toLowerCase(), - symbol: t.symbol, - balance: t.balance != null ? String(t.balance) : undefined, - balanceUsd: typeof t.balanceUsd === 'number' ? t.balanceUsd : undefined, - })).filter(t => !!t.address) : [], - } + if (!spNetworkId || spNetworkId !== chainNetworkId) continue + const dp = mapServerDefiPosition(sp) allChainDefi.push(dp) const ownerLower = String(sp.pubkey || '').toLowerCase() if (ownerLower) { @@ -3613,6 +4036,18 @@ const rpc = BrowserView.defineRPC({ balanceUsd += allChainDefi.reduce((s, p) => s + (p.balanceUsd || 0), 0) chainDefiPositions = allChainDefi } + + // Zcash: attach the shielded zZEC synthetic token, same as the bulk + // path — without this a single-chain zcash refresh wiped the cached + // shielded sub-row (its cache write replaces tokens_json wholesale). + if (chain.id === 'zcash') { + const zcashNative = pureNatives.find((d: any) => d.caip === chain.caip) + const shieldedTok = await fetchShieldedZecToken(parseFloat(zcashNative?.priceUsd ?? '0')) + if (shieldedTok) { + tokens = [...(tokens || []), shieldedTok] + balanceUsd += shieldedTok.balanceUsd + } + } } catch (e: any) { const message = getPioneerPortfolioErrorMessage(e) console.warn(`[getBalance] ${chain.coin} portfolio failed:`, message) @@ -3641,7 +4076,10 @@ const rpc = BrowserView.defineRPC({ try { const deviceId = engine.getDeviceState().deviceId || 'unknown' if (!engine.isPassphraseWallet) { - updateCachedBalance(deviceId, result) + // force only when Pioneer confirmed (non-degraded) the value — then a + // genuine zero / unpriced balance must overwrite the stale cache row + // (same rule as getBalances' confirmedChainIds). + updateCachedBalance(deviceId, result, pioneerConfirmed) rectifyWallet(deviceId, [result]) } } catch { /* never block on cache failure */ } @@ -3664,7 +4102,7 @@ const rpc = BrowserView.defineRPC({ balanceUsd: btcSet.totalBalanceUsd, nativeBalanceUsd: btcSet.totalBalanceUsd, address: result.address || btcAccounts.getSelectedXpub()?.xpub || '', - }) + }, pioneerConfirmed) } } catch { /* non-fatal */ } } @@ -3677,6 +4115,19 @@ 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}`) + + // Firmware gate (fund safety): a THORChain/Maya bank-token send is a + // MsgSend whose `denom` field is only honored from 7.15.0. On older + // firmware the field is ignored and the tx signs as RUNE — refuse to + // build it rather than sign the wrong asset. Covers TCY/RUJI sends and + // swaps (both pass the bank-token caip). Native RUNE/ATOM are unaffected. + if (params.caip && isThorchainBankToken(params.caip)) { + const fw = engine.getDeviceState().firmwareVersion + if (!thorchainBankTokenFirmwareOK(params.caip, fw)) { + throw new Error(`This asset requires KeepKey firmware ${THORCHAIN_BANK_TOKEN_MIN_FW}+ (device has ${fw || 'unknown'}). Update your firmware to send or swap TCY / RUJI.`) + } + } + const pioneer = await getPioneer() // Seed-staleness boundary on the SIGNING path. params (xpubOverride, @@ -3755,13 +4206,12 @@ const rpc = BrowserView.defineRPC({ } else { // Non-BTC UTXO: derive all applicable script-type xpubs so buildUtxoTx // can aggregate UTXOs from any address type (mirrors BTC multi-xpub logic). - const scriptTypes = chain.id === 'litecoin' - ? [{ scriptType: 'p2pkh', purpose: 44 }, { scriptType: 'p2sh-p2wpkh', purpose: 49 }, { scriptType: 'p2wpkh', purpose: 84 }] - : [{ scriptType: chain.scriptType || 'p2pkh', purpose: 44 }] + // Shared helper — includes the chain's own receive convention (LTC hands + // out p2wpkh on the 44' branch), which the standard trio misses. + const scriptTypes = utxoAccountScriptPaths(chain, 0) - const coinType = chain.defaultPath[1] // already hardened (0x80000000 + slip44) const pubKeyPaths = scriptTypes.map(st => ({ - addressNList: [st.purpose + 0x80000000, coinType, 0x80000000], + addressNList: st.path, coin: chain.coin, scriptType: st.scriptType, curve: 'secp256k1', @@ -3779,9 +4229,32 @@ const rpc = BrowserView.defineRPC({ }) } } + // Aggregate device-cached account-1+ xpubs (persisted by the audit + // "track" action, addUtxoAccount) so those tracked funds are actually + // SPENDABLE, not just displayed. Each row carries its own account-level + // path (parsed from the path column) so per-input signing derives the + // correct key for account > 0. Device-scoped, never written for + // passphrase wallets; dedup by xpub against the account-0 set above. + const utxoDevId = engine.getDeviceState().deviceId + if (utxoDevId && !engine.isPassphraseWallet) { + const seen = new Set(derivedXpubs.map(x => x.xpub)) + for (const pk of getCachedPubkeys(utxoDevId)) { + if (pk.chainId !== chain.id || !pk.xpub || seen.has(pk.xpub)) continue + const acctPath = parseBip32Path(pk.path) + if (!acctPath || acctPath.length !== 3) continue + derivedXpubs.push({ xpub: pk.xpub, scriptType: pk.scriptType || chain.scriptType || 'p2pkh', accountPath: acctPath }) + seen.add(pk.xpub) + } + } if (derivedXpubs.length > 0) { xpub = derivedXpubs[0].xpub - if (derivedXpubs.length > 1) { + // Use multi-xpub aggregation for 2+ xpubs OR whenever any xpub is + // account > 0. The single-xpub path ignores per-xpub accountPath, so a + // lone account-N xpub (e.g. account-0 derivation returned nothing but a + // tracked account-1 row exists) would otherwise sign with blockbook's + // account-0 path — a wrong key. Aggregation tags each input with its + // source account path, so account-N always signs with the account-N key. + if (derivedXpubs.length > 1 || derivedXpubs.some(x => x.accountPath[2] !== 0x80000000)) { allXpubs = derivedXpubs } } @@ -4141,6 +4614,55 @@ const rpc = BrowserView.defineRPC({ if (!engine.wallet) throw new Error('No device connected') return await btcAccounts.addAccount(engine.wallet as any) }, + // Persist a non-BTC UTXO account's xpubs to the device-scoped pubkey + // cache so the audit "track" action makes those funds show + spend from + // now on. Mirrors the BTC xpub-cache pattern (saveCachedPubkey gated on + // !isPassphraseWallet) — Bitcoin itself stays on the in-memory manager. + addUtxoAccount: async (params) => { + 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 (chain.chainFamily !== 'utxo' || chain.id === 'bitcoin') throw new Error(`${params.chainId} is not a non-BTC UTXO chain`) + const devId = engine.getDeviceState().deviceId + if (!devId) throw new Error('No device id') + // PRIVACY: the device-scoped cache must never hold hidden-wallet xpubs. + if (engine.isPassphraseWallet) throw new Error('Cannot persist accounts for a passphrase wallet') + const account = Math.max(params.level ?? 0, 0) + const sps = utxoAccountScriptPaths(chain, account) + const pks = await (engine.wallet as any).getPublicKeys( + sps.map(sp => ({ addressNList: sp.path, coin: chain.coin, scriptType: sp.scriptType, curve: 'secp256k1' })), + ) + let saved = 0 + for (let i = 0; i < sps.length; i++) { + const xpub = (pks?.[i] as any)?.xpub + if (!xpub) continue + // Key the row by the account-level BIP32 path (not the xpub) so the + // account INDEX survives: the spend path (buildTx) needs it to rebuild + // per-input signing paths for account > 0. The (device,chain,path) + // upsert dedups per (scriptType, account); the xpub lives in its own + // column for the display merges. + saveCachedPubkey(devId, chain.id, pathToBip32(sps[i].path), xpub, '', sps[i].scriptType) + saved++ + } + if (!saved) throw new Error(`Could not derive xpubs for ${chain.coin} account ${account}`) + return { saved, account } + }, + // Tracked account indices for a non-BTC UTXO chain: account 0 always, + // plus any accounts persisted by addUtxoAccount. Hidden wallets never + // have cached rows (and must not read the standard wallet's), so they + // only ever see account 0. + getUtxoAccounts: async (params) => { + const devId = engine.getDeviceState().deviceId + const accounts = new Set([0]) + if (devId && !engine.isPassphraseWallet) { + for (const pk of getCachedPubkeys(devId)) { + if (pk.chainId !== params.chainId) continue + const path = parseBip32Path(pk.path) + if (path && path.length >= 3) accounts.add(path[2] & 0x7fffffff) + } + } + return { accounts: [...accounts].sort((a, b) => a - b) } + }, setBtcSelectedXpub: async (params) => { btcAccounts.setSelectedXpub(params.accountIndex, params.scriptType) }, @@ -4454,7 +4976,13 @@ const rpc = BrowserView.defineRPC({ // path — prove the cached FVK belongs to the connected device first, or a // stale/other-wallet FVK would surface a phantom balance via a scan request // (reviewer#2). ensureFvkLoaded only loads-if-absent; it does NOT verify. - await ensureZcashDeviceMatch(0) + // force=true: the user pressing "Sync" expects a real device round-trip + // every time, ignoring the sticky session flag — on mismatch this purges + // the stale FVK + notes and re-derives, repairing a stale wallet from the + // UI without a manual DB wipe. Reuses the coalesced-purge path instead of + // mutating the module-global zcashDeviceVerified (which would open a + // transient window where a concurrent balance read throws "not verified"). + await ensureZcashDeviceMatch(0, true) const result = await scanOrchardNotes(params?.startHeight, params?.fullRescan) if (result?.synced_to != null) updateSyncedTo(result.synced_to) // A successful scan validates the wallet against the chain — even an @@ -4672,6 +5200,7 @@ const rpc = BrowserView.defineRPC({ 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 + if (c.id === 'hive') return hiveEnabled if (c.hidden) return false return true }) @@ -4910,6 +5439,18 @@ const rpc = BrowserView.defineRPC({ console.log('[settings] BIP-85 enabled:', params.enabled) return getAppSettings() }, + setHiveEnabled: async (params) => { + // Hive requires firmware >= 7.15.0 (matches minFirmware in shared/chains.ts). + const fwVer = engine.getDeviceState().firmwareVersion + if (params.enabled && (!fwVer || versionCompare(fwVer, '7.15.0') < 0)) { + console.warn(`[settings] Hive blocked — firmware ${fwVer || 'unknown'} < 7.15.0`) + return getAppSettings() + } + hiveEnabled = params.enabled + setSetting('hive_enabled', params.enabled ? '1' : '0') + console.log('[settings] Hive enabled:', params.enabled) + return getAppSettings() + }, setEmulatorEnabled: async (params) => { // Refuse to enable on platforms with no emulator support. The // emulator runs on macOS (Keychain) and Windows (DPAPI); Linux @@ -5674,7 +6215,7 @@ const rpc = BrowserView.defineRPC({ const result = await rebuildActivityHistory({ wallet: engine.wallet, scope, - chains: getAllChains(), + chains: getAllChains().filter(c => c.id !== 'hive' || hiveEnabled), firmwareVersion: engine.getDeviceState().firmwareVersion, options: { chainId: params.chainId, dryRun: true, collectRows: true }, }) @@ -5690,7 +6231,7 @@ const rpc = BrowserView.defineRPC({ const result = await rebuildActivityHistory({ wallet: engine.wallet, scope, - chains: getAllChains(), + chains: getAllChains().filter(c => c.id !== 'hive' || hiveEnabled), firmwareVersion: engine.getDeviceState().firmwareVersion, options: { chainId: params.chainId }, }) @@ -5731,6 +6272,7 @@ const rpc = BrowserView.defineRPC({ if (!isChainSupported(c, fwVersion)) return false if (c.id === 'zcash-shielded') return false if (c.id === 'zcash') return zcashPrivacyEnabled + if (c.id === 'hive') return hiveEnabled return !c.hidden }) const cachedChainIds = new Set(result.balances.map(b => b.chainId)) @@ -5764,7 +6306,9 @@ const rpc = BrowserView.defineRPC({ // letting the user select them and then hitting a device signing error. const filteredBalances = result.balances.filter(b => { const chain = getAllChains().find(c => c.id === b.chainId) - return chain ? isChainSupported(chain, fwVersion) : true // keep unknowns (tokens) + if (!chain) return true // keep unknowns (tokens) + if (chain.id === 'hive' && !hiveEnabled) return false // honor feature flag — drop stale Hive rows + return isChainSupported(chain, fwVersion) }) return { balances: filteredBalances, updatedAt: result.updatedAt, staleReasons: staleReasons.length > 0 ? staleReasons : undefined } }, @@ -5784,6 +6328,182 @@ const rpc = BrowserView.defineRPC({ const result = getCachedBalances(snap.deviceId) return result?.balances ?? null }, + // Re-fetch watch-only balances from Pioneer using addresses reconstructed + // from cache — NO device required. Self-contained: deliberately does NOT + // touch the device getBalances managers. + // ponytail: display-only simplified parse — no per-owner EVM token map and + // 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) => { + const { getDeviceSnapshotById } = await import('./db') + const snap = params?.deviceId + ? getDeviceSnapshotById(params.deviceId) + : getLatestDeviceSnapshot() + if (!snap) return null + const deviceId = snap.deviceId + + // 1. Reconstruct the pubkey list from cache (no device available) + const allChains = getAllChains() + const chainById = new Map(allChains.map(c => [c.id, c])) + const pubkeys: Array<{ caip: string; pubkey: string; chainId: string; symbol: string; networkId: string }> = [] + + const cached = getCachedBalances(deviceId) + for (const b of cached?.balances ?? []) { + if (b.chainId === 'bitcoin') continue // cached BTC address isn't the xpub — handled below + if (!b.address) continue + const chain = chainById.get(b.chainId) + if (!chain || !chain.caip) continue + pubkeys.push({ caip: chain.caip, pubkey: b.address, chainId: chain.id, symbol: chain.symbol, networkId: chain.networkId }) + } + // BTC: use the cached xpubs (one entry per script-type/account) + const btcChain = chainById.get('bitcoin') + if (btcChain) { + for (const p of getCachedPubkeys(deviceId).filter(p => p.chainId === 'bitcoin' && p.xpub)) { + pubkeys.push({ caip: btcChain.caip, pubkey: p.xpub, chainId: 'bitcoin', symbol: 'BTC', networkId: btcChain.networkId }) + } + } + + if (pubkeys.length === 0) return cached?.balances ?? null + + // 2. Pioneer client — let init failure throw so the UI surfaces it + const pioneer = await getPioneer() + + // 3. networkId → chainId lookup (non-hidden takes priority) + const networkToChain = new Map() + for (const chain of allChains) { + if (!chain.networkId) continue + if (chain.hidden && networkToChain.has(chain.networkId.toLowerCase())) continue + networkToChain.set(chain.networkId.toLowerCase(), chain.id) + } + + // 4. Chunked GetPortfolioBalances + const pubkeyChunks = chunkArray(pubkeys, PIONEER_PORTFOLIO_CHUNK_SIZE) + const chunkResults = await withTimeout( + mapWithConcurrency(pubkeyChunks, PIONEER_PORTFOLIO_MAX_CONCURRENCY, async (chunk, i) => { + try { + const resp = await withTimeout( + pioneer.GetPortfolioBalances({ pubkeys: chunk.map(p => ({ caip: p.caip, pubkey: p.pubkey })), includeDefi: true }, { forceRefresh: true }), + PIONEER_PORTFOLIO_CHUNK_TIMEOUT_MS, + `watch-only chunk ${i + 1}/${pubkeyChunks.length}` + ) + return { ...unwrapPortfolioResponse(resp), failed: false } + } catch (err: any) { + console.warn(`[refreshWatchOnlyBalances] chunk ${i + 1}/${pubkeyChunks.length} failed:`, err?.message || err) + return { entries: [] as any[], meta: null, defiPositions: null as ServerDefiPosition[] | null, failed: true } + } + }), + PIONEER_PORTFOLIO_TOTAL_TIMEOUT_MS, + 'watch-only GetPortfolioBalances chunks' + ) + const allEntries = chunkResults.flatMap(r => r.entries) + + // Chains whose chunk failed must NOT be confirmed below — otherwise a + // transient Pioneer error would write a 0 over good cached balances. + const failedChainIds = new Set() + for (let i = 0; i < chunkResults.length; i++) { + if (chunkResults[i].failed) for (const p of pubkeyChunks[i]) failedChainIds.add(p.chainId) + } + + // 5. Classify natives vs tokens (shared isTokenEntry) + const pureNatives: any[] = [] + const tokenEntries: any[] = [] + for (const entry of allEntries) { + if (isTokenEntry(entry)) tokenEntries.push(entry) + else pureNatives.push(entry) + } + + // 6. Group tokens by parent chain + const tokensByChainId = new Map() + const seenByOwnerCaip = new Set() + for (const tok of tokenEntries) { + const bal = parseFloat(String(tok.balance ?? '0')) + if (bal <= 0) continue + const ownerAddr = String(tok.address || tok.pubkey || '').toLowerCase() + const caipNorm = (tok.caip || '').startsWith('eip155:') ? (tok.caip || '').toLowerCase() : (tok.caip || '') + const ownerCaipKey = `${caipNorm}|${ownerAddr}` + if (seenByOwnerCaip.has(ownerCaipKey)) continue + seenByOwnerCaip.add(ownerCaipKey) + + const tokNetworkId = (tok.networkId || '').toLowerCase() + const caipPrefix = ((tok.caip || '').split('/')[0]).toLowerCase() + const parentChainId = networkToChain.get(tokNetworkId) || networkToChain.get(caipPrefix) || null + if (!parentChainId) continue + + const token = parseTokenEntry(tok) + const existing = tokensByChainId.get(parentChainId) || [] + existing.push(token) + tokensByChainId.set(parentChainId, existing) + } + + // 7. Group DeFi positions by chain (dedup by pubkey|protocol|networkId) + const rawDefi: ServerDefiPosition[] = chunkResults.flatMap(r => r.defiPositions || []) + const defiByChain = new Map() + const seenDefiKey = new Set() + for (const sp of rawDefi) { + const key = `${String(sp.pubkey || '').toLowerCase()}|${sp.protocol || ''}|${(sp.networkId || '').toLowerCase()}` + if (seenDefiKey.has(key)) continue + seenDefiKey.add(key) + const chainId = sp.networkId ? networkToChain.get(sp.networkId.toLowerCase()) : null + if (!chainId) continue + const dp = mapServerDefiPosition(sp) + const list = defiByChain.get(chainId) || [] + list.push(dp) + defiByChain.set(chainId, list) + } + + // 8. Build ChainBalance[] — sum BTC xpubs into one entry; others 1:1 + const results: ChainBalance[] = [] + let btcBalance = 0, btcUsd = 0, btcAddress = '' + for (const entry of pubkeys) { + if (entry.chainId === 'bitcoin') { + const match = pureNatives.find((d: any) => d.pubkey === entry.pubkey) + || pureNatives.find((d: any) => d.caip === entry.caip && d.address === entry.pubkey) + btcBalance += parseFloat(String(match?.balance ?? '0')) + btcUsd += Number(match?.valueUsd ?? 0) + if (match?.address && !btcAddress) btcAddress = match.address + continue + } + const entryNetwork = entry.caip.split('/')[0] + const match = pureNatives.find((d: any) => d.caip === entry.caip) + || pureNatives.find((d: any) => d.caip && d.caip.split('/')[0] === entryNetwork) + || pureNatives.find((d: any) => d.pubkey === entry.pubkey) + || pureNatives.find((d: any) => d.address === entry.pubkey) + const chainTokens = tokensByChainId.get(entry.chainId) + const tokenUsdTotal = chainTokens?.reduce((s, t) => s + t.balanceUsd, 0) || 0 + const chainDefi = defiByChain.get(entry.chainId) + const defiUsdTotal = chainDefi?.reduce((s, p) => s + (p.balanceUsd || 0), 0) || 0 + const nativeUsd = Number(match?.valueUsd ?? 0) + results.push({ + chainId: entry.chainId, + symbol: entry.symbol, + balance: String(match?.balance ?? '0'), + balanceUsd: nativeUsd + tokenUsdTotal + defiUsdTotal, + nativeBalanceUsd: nativeUsd, + address: match?.address || entry.pubkey, + tokens: chainTokens && chainTokens.length > 0 ? chainTokens : undefined, + defiPositions: chainDefi && chainDefi.length > 0 ? chainDefi : undefined, + }) + } + if (btcChain && pubkeys.some(p => p.chainId === 'bitcoin')) { + const chainTokens = tokensByChainId.get('bitcoin') + const tokenUsdTotal = chainTokens?.reduce((s, t) => s + t.balanceUsd, 0) || 0 + results.push({ + chainId: 'bitcoin', + symbol: 'BTC', + balance: String(btcBalance), + balanceUsd: btcUsd + tokenUsdTotal, + nativeBalanceUsd: btcUsd, + address: btcAddress, + tokens: chainTokens && chainTokens.length > 0 ? chainTokens : undefined, + }) + } + + // 9. Persist and return. Only chains whose chunk succeeded are "confirmed" + // (genuine zeros overwrite stale); failed chains keep their cached value. + const confirmed = new Set(results.map(r => r.chainId).filter(id => !failedChainIds.has(id))) + setCachedBalances(deviceId, results, confirmed) + return results + }, getWatchOnlyPubkeys: async (params) => { const { getDeviceSnapshotById } = await import('./db') const snap = params?.deviceId @@ -5918,6 +6638,7 @@ const rpc = BrowserView.defineRPC({ const enabledChains = getAllChains().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 return true }) const coverageChains = enabledChains.map(c => ({ chainId: c.id, symbol: c.symbol, chainFamily: c.chainFamily })) @@ -6076,16 +6797,18 @@ const rpc = BrowserView.defineRPC({ let native = '0', hasBalance = false, balanceError = false try { const pioneer = await getPioneer() - const resp = await withTimeout( - pioneer.GetPortfolioBalances({ pubkeys: xpubs.map(x => ({ caip: chain.caip, pubkey: x })) }, { forceRefresh: true }), - PIONEER_TIMEOUT_MS, `audit utxo-acct ${chain.id}`, - ) - const { entries, meta } = unwrapPortfolioResponse(resp) - const natives = (Array.isArray(entries) ? entries : []).filter((e: any) => String(e?.caip || '').split('/')[0] === prefix) - const total = natives.reduce((acc: number, e: any) => acc + (parseFloat(String(e?.balance ?? '0')) || 0), 0) + // Patient: degraded-empty (unverified, not a confident zero) and + // thrown requests both retry before surfacing "couldn't verify". + const total = await auditPatientFetch(`audit utxo-acct ${chain.id}`, async () => { + const resp = await pioneer.GetPortfolioBalances({ pubkeys: xpubs.map(x => ({ caip: chain.caip, pubkey: x })) }, { forceRefresh: true }) + const { entries, meta } = unwrapPortfolioResponse(resp) + const natives = (Array.isArray(entries) ? entries : []).filter((e: any) => String(e?.caip || '').split('/')[0] === prefix) + const sum = natives.reduce((acc: number, e: any) => acc + (parseFloat(String(e?.balance ?? '0')) || 0), 0) + if (sum <= 0 && meta?.degraded) throw new Error('balance API degraded') + return sum + }) native = String(total) hasBalance = total > 0 - if (!hasBalance && meta?.degraded) balanceError = true // unverified, not a confident zero } catch (e: any) { console.warn(`[audit] utxo-acct ${chain.id} #${account} balance failed: ${e?.message}`) balanceError = true @@ -6110,7 +6833,7 @@ const rpc = BrowserView.defineRPC({ const address = extractAddress(await (engine.wallet as any)[method](dp)) if (!address) throw new Error('Device returned no address for that path') const { native, hasBalance, balanceError, tokens } = await auditBalanceForAddress(chain, address) - return { pathStr: pathToBip32(path), address, native, symbol: chain.symbol, hasBalance, balanceError, tokens, explorerUrl: explorerAddressUrl(chain, address) } + return { pathStr: pathToBip32(path), address, native, symbol: chain.symbol, hasBalance, balanceError, tokens, explorerUrl: explorerAddressUrl(chain, address), addressNList: path, scriptType: dp.scriptType } }, // Batch derive + balance-check a list of explicit paths (EVM known-paths // grid). Read-only, gen-guarded. @@ -6132,10 +6855,66 @@ const rpc = BrowserView.defineRPC({ try { address = extractAddress(await (engine.wallet as any)[method](dp)) } catch { continue } if (!address) continue const { native, hasBalance, balanceError, tokens } = await auditBalanceForAddress(chain, address) - results.push({ pathStr: pathToBip32(path), address, native, symbol: chain.symbol, hasBalance, balanceError, tokens, explorerUrl: explorerAddressUrl(chain, address) }) + results.push({ pathStr: pathToBip32(path), address, native, symbol: chain.symbol, hasBalance, balanceError, tokens, explorerUrl: explorerAddressUrl(chain, address), addressNList: path, scriptType: dp.scriptType }) } return { results } }, + // Sweep ONE funded address-level audit find to the chain's standard + // receive address. For funds on path+scriptType combos the portfolio + // can't spend from the Send page (uncommon branches, custom paths). + // Signs with the exact found path — no xpub/account machinery, nothing + // persisted (hidden-wallet safe). Two-step: dryRun quotes, then signs. + auditSweepPath: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + if (engine.getDeviceState().state !== 'ready') throw new Error('Device not ready') + const chain = getAllChains().find(c => c.id === params.chainId) + if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) + if (chain.chainFamily !== 'utxo') throw new Error(`${chain.symbol} can't be swept this way`) + const path = params.addressNList + if (!Array.isArray(path) || path.length < 2 || path.length > 10 || path.some(n => !Number.isInteger(n) || n < 0)) { + throw new Error('Invalid derivation path') + } + const captured = engine.wallet + // Device-swap guard: the find may be from a scan on a DIFFERENT physical + // device/seed. Re-derive on the connected device and require an exact + // match before touching funds — never sign with the wrong wallet. + const { method, params: dp } = deriveAddressParams(chain, path) + if (params.scriptType) dp.scriptType = params.scriptType + const derived = extractAddress(await (engine.wallet as any)[method](dp)) + if (!derived || derived !== params.expectedAddress) { + throw new Error('The connected device does not derive this address — reconnect the wallet the funds were found with and re-run the scan') + } + // Destination: the chain's standard receive address (account 0). + let destination = params.destinationAddress + if (!destination) { + const { method: m2, params: dp2 } = deriveAddressParams(chain, chain.defaultPath) + destination = extractAddress(await (engine.wallet as any)[m2](dp2)) + } + if (!destination) throw new Error(`Could not derive standard ${chain.symbol} receive address`) + if (destination === derived) throw new Error('These funds are already on the standard receive address') + + const { fetchUtxos, buildSweepTx } = await import('./sweep-engine') + const utxos = await fetchUtxos(derived, chain.networkId) + if (!utxos.length) throw new Error('No spendable funds found on this address') + const balanceSats = utxos.reduce((s, u) => s + u.value, 0) + const scriptType = dp.scriptType || chain.scriptType || 'p2pkh' + const sweep = await buildSweepTx( + { results: [{ path, pathStr: pathToBip32(path), scriptType, address: derived, category: 'mismatch', balanceSats, utxos }] } as any, + destination, + { coin: chain.coin, networkId: chain.networkId }, + ) + const summary = { + fromAddress: derived, destination, inputCount: sweep.inputCount, + totalSats: sweep.totalInputSats, fee: sweep.fee, outputSats: sweep.totalInputSats - sweep.fee, + symbol: chain.symbol, + } + if (params.dryRun) return { ...summary, dryRun: true } + if (engine.wallet !== captured) throw new Error('Device changed — re-run the scan') + const signed = await engine.wallet.btcSignTx(sweep.unsignedTx) + const pioneer = await getPioneer() + const { txid } = await broadcastTx(pioneer, chain, signed) + return { ...summary, txid, explorerTxUrl: chain.explorerTxUrl ? chain.explorerTxUrl.replace('{{txid}}', txid) : null } + }, // Raw-path inspector: derive an address + its pubkey/xpub + balance for a // power user verifying derivations. Read-only. auditInspectPath: async (params) => { @@ -6567,76 +7346,6 @@ const rpc = BrowserView.defineRPC({ if (!wcManager) return wcManager.rejectPair(params.id) }, - wcScanScreen: async () => { - if (process.platform !== 'darwin') { - throw new Error('Screen QR scan is only supported on macOS') - } - - const openScreenCaptureSettings = () => { - try { - Bun.spawn(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture']) - } catch { /* best effort */ } - } - - // CGPreflight is only a hint here. In packaged Electrobun builds this - // code runs from the embedded Bun helper, while TCC may show the outer - // app bundle in System Settings. Blocking solely on preflight creates - // false "missing permission" errors after the user has toggled Vault on. - let preflightGranted = false - let promptShown = false - let requestResult: number | null = null - try { - const { dlopen, FFIType } = require('bun:ffi') - const cgPath = '/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics' - const cg = dlopen(cgPath, { - CGPreflightScreenCaptureAccess: { args: [], returns: FFIType.u8 }, - CGRequestScreenCaptureAccess: { args: [], returns: FFIType.u8 }, - }) - const pre = cg.symbols.CGPreflightScreenCaptureAccess() as unknown as number - preflightGranted = pre !== 0 - console.log('[wcScanScreen] CGPreflight =', pre, 'granted:', preflightGranted) - if (!preflightGranted) { - const req = cg.symbols.CGRequestScreenCaptureAccess() as unknown as number - requestResult = req - console.log('[wcScanScreen] CGRequest returned', req, '— TCC prompt should be visible now') - promptShown = true - } - } catch (e: any) { - console.warn('[wcScanScreen] CG FFI failed:', e.message, '— falling through to screencapture') - } - - const path = `/tmp/kk-wc-scan-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.png` - // -i interactive selection, -x silent, -t png format. - const proc = Bun.spawn(['screencapture', '-i', '-x', '-t', 'png', path], { - stdout: 'ignore', - stderr: 'pipe', - }) - const stderrText = (await new Response(proc.stderr).text()).trim() - const exitCode = await proc.exited - const file = Bun.file(path) - const exists = await file.exists() - - const permissionFailure = exitCode !== 0 && ( - /could not create image|not authori[sz]ed|screen recording|permission|denied|privacy/i.test(stderrText) || - (!exists && requestResult === 0) - ) - if (permissionFailure) { - console.log('[wcScanScreen] screencapture failed; permission likely missing or stale', { - exitCode, - stderrText, - preflightGranted, - promptShown, - requestResult, - }) - openScreenCaptureSettings() - throw new Error(promptShown ? 'SCREEN_RECORDING_PERMISSION_PROMPTED' : 'SCREEN_RECORDING_PERMISSION_REQUIRED') - } - if (!exists) return null // user canceled (Esc / clicked away) - const bytes = await file.arrayBuffer() - try { fs.unlinkSync(path) } catch { /* best effort */ } - if (bytes.byteLength === 0) return null - return { pngBase64: Buffer.from(bytes).toString('base64') } - }, // ── Utility ────────────────────────────────────────────── openUrl: async (params) => { @@ -6644,9 +7353,11 @@ const rpc = BrowserView.defineRPC({ const parsed = new URL(params.url) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error() if (process.platform === 'win32') { - // 'start' is a cmd.exe built-in, not an executable — must invoke via cmd /c - // Empty title "" required because start treats URLs with & as title strings - Bun.spawn(['cmd', '/c', 'start', '', parsed.href]) + // `cmd /c start` re-parses its command line with cmd.exe's own quoting + // rules (not Bun.spawn's Win32 argv-escaping), so URLs with `&` get split + // into separate commands and quoted URLs get corrupted. rundll32 takes the + // URL as a normal argv element — no shell re-parsing involved. + Bun.spawn(['rundll32', 'url.dll,FileProtocolHandler', parsed.href]) } else { const cmd = process.platform === 'linux' ? 'xdg-open' : 'open' Bun.spawn([cmd, parsed.href]) @@ -6783,14 +7494,14 @@ udevadm trigger --subsystem-match=usb --attr-match=idVendor=2b24 || udevadm trig }, downloadUpdate: async () => { if (process.platform === 'win32' || process.platform === 'darwin') { - openReleasePage() + openUpdatePage() return } await Updater.downloadUpdate() }, applyUpdate: async () => { if (process.platform === 'win32' || process.platform === 'darwin') { - openReleasePage() + openUpdatePage() return } await Updater.applyUpdate() @@ -6905,43 +7616,64 @@ engine.on('state-change', (state) => { } } if (state.state === 'ready' && !pioneerSocket) { - // Debounce Pioneer push events per chain — rapid-fire cache pings coalesce into one refresh. - const pioneerEventDebounce = new Map>() pioneerSocket = new PioneerSocket({ queryKey: getPioneerQueryKey(), onEvent: (event, data) => { - const REFRESH_EVENTS = new Set(['transaction:incoming', 'balance:update', 'balance:cache:update']) - if (!REFRESH_EVENTS.has(event)) return - const d = data as any - const address = d?.address ?? undefined + // 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 - // Normalize whatever Pioneer sends into a canonical CAIP-19 string. - // Pioneer may send: CAIP-19 (has "/"), CAIP-2 ("eip155:1"), internal id - // ("ethereum"), or raw symbol ("ETH"). Symbol is ambiguous (ETH = Ethereum, - // Arbitrum, Optimism, Base) so we never fall back to it. + // 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 - const raw: string | undefined = d?.chain - if (raw) { - if (raw.includes('/')) { - chain = raw // already CAIP-19 - } else { - // CAIP-2 (networkId like "eip155:1") or internal id like "ethereum" - const def = allChains.find(c => c.networkId === raw || c.id === raw) - chain = def?.caip - } + 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 multiple token pushes on the same - // EVM network collapse into a single refresh rather than bypassing the debounce. + // 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 existing = pioneerEventDebounce.get(networkId) - if (existing) clearTimeout(existing) - pioneerEventDebounce.set(networkId, setTimeout(() => { + 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} → forwarding`) - try { rpc.send['tx-push-received']({ chain, address, txid }) } catch { /* webview not ready */ } - }, 2000)) + 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'), @@ -6959,7 +7691,7 @@ engine.on('state-change', (state) => { rebuildActivityHistory({ wallet: engine.wallet, scope, - chains: getAllChains(), + chains: getAllChains().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`) @@ -6982,6 +7714,7 @@ engine.on('state-change', (state) => { console.log('[Vault] Device disconnected: keeping in-memory account managers for watch-only') pioneerSocket?.stop() pioneerSocket = null + clearPioneerEventDebounce() // pending timers would getBalance a gone device stopEventStream() } if (state.state === 'disconnected' || state.state === 'needs_passphrase') { diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 21c91180..fad27ce5 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -3,8 +3,10 @@ import type { AuthStore } from './auth' import { HttpError } from './auth' import type { SigningRequestInfo, ApiLogEntry, EIP712DecodedInfo } from '../shared/types' import { decodeEIP712 } from './eip712-decoder' -import { decodeCalldata } from './calldata-decoder' +import { decodeCalldata, type EvmUnsignedTxFields } from './calldata-decoder' import { CHAINS, isChainSupported } from '../shared/chains' +import { EVM_INSIGHT } from '../shared/flags' +import { versionCompare } from '../shared/firmware-versions' import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, buildShieldedTx, finalizeShieldedTx, broadcastShieldedTx, @@ -1061,6 +1063,10 @@ const ROUTE_TO_CHAIN: Record = { } export function startRestApi(engine: EngineController, auth: AuthStore, port = 1646, callbacks?: RestApiCallbacks) { + // EVM clear-signing (insight): OFF by default, and only on firmware >= 7.15.0. + // getDeviceState() is an in-memory read (cachedFeatures); `?? '0.0.0'` fails closed. + const evmInsightEnabled = (): boolean => + EVM_INSIGHT && versionCompare(engine.getDeviceState().firmwareVersion ?? '0.0.0', '7.15.0') >= 0 const getWalletDbScope = (): { deviceId: string; walletId: string } | null => { const deviceId = engine.getDeviceState().deviceId if (!deviceId) return null @@ -1336,7 +1342,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 status: 'healthy', syncing: engine.isSyncing, apiVersion: 2, - supportedChains: CHAINS.filter(c => isChainSupported(c, ds.firmwareVersion)).map(c => c.networkId), + supportedChains: CHAINS.filter(c => isChainSupported(c, ds.firmwareVersion) && (c.id !== 'hive' || getSetting('hive_enabled') === '1')).map(c => c.networkId), device_connected: engine.wallet !== null, version: callbacks?.getVersion?.() || 'unknown', connected: engine.wallet !== null, @@ -1433,15 +1439,24 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (callbacks?.onPairRequest) { callbacks.onPairRequest({ name: body.name, url: body.url || '', imageUrl: body.imageUrl || '' }) } - // requestPair requires user approval via UI — NOT auto-granted + // requestPair requires user approval via UI — NOT auto-granted. + // Idempotent: an already-paired identity reuses its key (reused:true) + // after the user re-approves, instead of minting a duplicate. try { - const apiKey = await auth.requestPair(body) - return json({ apiKey }) + const { apiKey, reused } = await auth.requestPair(body) + return json({ apiKey, reused }) } finally { // Dismiss UI overlay + restore window level on approve, reject, or timeout callbacks?.onPairDismissed?.() } } + if (method === 'DELETE') { + // Revoke the caller's own key (clean reset / explicit unpair). + const token = auth.extractBearerToken(req) + if (!token) return json({ revoked: false, message: 'No bearer token provided' }, 401) + const revoked = auth.revoke(token) + return json({ revoked }) + } } // ═══════════════════════════════════════════════════════════════ @@ -1702,7 +1717,34 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const chainIdNum = typeof signingInfo.chainId === 'string' ? (signingInfo.chainId.startsWith('0x') ? parseInt(signingInfo.chainId, 16) : parseInt(signingInfo.chainId, 10)) : signingInfo.chainId - signingInfo.calldataDecoded = await decodeCalldata(preview.to, preview.data, chainIdNum) ?? undefined + // Full unsigned tx for the pioneer signed blob. MUST mirror the + // msg construction in the /eth/sign-transaction handler exactly + // (same defaults, same fee-field selection) — the blob's tx_hash + // is bound to the sighash over these fields, and rc3 firmware + // refuses the blob if it doesn't match what the device signs. + // Gated: only fetch a Pioneer signed blob when insight is enabled + + // fw >= 7.15. Off → txFields stays undefined → fetchPioneerSignedBlob + // short-circuits to null (no /descriptors/sign call). Local decode + + // needsBlindSigning below stay live on all firmware. + let txFields: EvmUnsignedTxFields | undefined + if (evmInsightEnabled() && path === '/eth/sign-transaction') { + txFields = { + nonce: preview.nonce || '0x0', + gasLimit: preview.gas || preview.gasLimit || '0x5208', + value: preview.value || '0x0', + } + if (preview.maxFeePerGas || preview.max_fee_per_gas) { + txFields.maxFeePerGas = preview.maxFeePerGas || preview.max_fee_per_gas + // Handler sends '0x' (canonical empty) to the device for a + // zero priority fee; send pioneer '0x0' — ethers RLP-encodes + // zero as empty too, so both sides hash identically. + const prio = preview.maxPriorityFeePerGas || preview.max_priority_fee_per_gas + txFields.maxPriorityFeePerGas = (!prio || /^0x0*$/.test(prio)) ? '0x0' : prio + } else { + txFields.gasPrice = preview.gasPrice || preview.gas_price || '0x0' + } + } + signingInfo.calldataDecoded = await decodeCalldata(preview.to, preview.data, chainIdNum, txFields) ?? undefined console.log(`[REST] Calldata decoded:`, JSON.stringify(signingInfo.calldataDecoded, null, 2)) } catch (e) { console.warn('[REST] Calldata decode failed:', e) } @@ -1978,6 +2020,8 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 if (path === '/addresses/hive' && method === 'POST') { auth.requireAuth(req) + // Gate behind the Hive feature flag (matches RPC handlers in index.ts) + if (getSetting('hive_enabled') !== '1') return json({ error: 'Hive is disabled' }, 403) const fwBlock = requireChainSupport('hive') if (fwBlock) return fwBlock const wallet = requireWallet(engine) @@ -2040,35 +2084,55 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // EIP-1559 fields if (body.maxFeePerGas || body.max_fee_per_gas) { msg.maxFeePerGas = body.maxFeePerGas || body.max_fee_per_gas - msg.maxPriorityFeePerGas = body.maxPriorityFeePerGas || body.max_priority_fee_per_gas || '0x0' + // Canonical RLP requires a zero priority fee to be the EMPTY string, not + // a 0x00 byte. hdwallet does not strip the EIP-1559 fee fields, so a + // literal '0x0' reaches the device as [0x00], which the firmware hashes + // non-canonically → the tx recovers to the wrong signer and is + // unbroadcastable (see keepkey-firmware eip1559-zero-priority fix). Send + // empty for a zero/absent priority fee. + const prio = body.maxPriorityFeePerGas || body.max_priority_fee_per_gas + msg.maxPriorityFeePerGas = (!prio || /^0x0*$/.test(prio)) ? '0x' : prio } else { msg.gasPrice = body.gasPrice || body.gas_price || '0x0' } // ── EVM Clear-Signing: attach signed metadata blob for device OLED ── + // Gated OFF by default + fw >= 7.15. This is the device-facing chokepoint: + // the caller-provided branch reads the blob straight from the request body, + // independent of the fetch gate above, so it MUST be gated here on its own — + // otherwise a caller could inject a blob and force clear-sign while disabled. // Priority: 1) caller provides txMetadata in request body (test fixtures) // 2) Pioneer signedInsightBlob from calldata decoder // 3) none — device falls back to raw hex - if (body.txMetadata && body.txMetadata.signedPayload) { - msg.txMetadata = { - signedPayload: body.txMetadata.signedPayload, - keyId: body.txMetadata.keyId ?? 0, - } - console.log(`[REST] EVM clear-sign: using caller-provided blob (${String(body.txMetadata.signedPayload).length} chars, keyId=${msg.txMetadata.keyId})`) - } else { - const decoded = activeSigningInfo?.calldataDecoded - if (decoded?.signedInsightBlob) { + if (evmInsightEnabled()) { + if (body.txMetadata && body.txMetadata.signedPayload) { msg.txMetadata = { - signedPayload: decoded.signedInsightBlob, - keyId: decoded.insightKeyId, + signedPayload: body.txMetadata.signedPayload, + keyId: body.txMetadata.keyId ?? 0, } - console.log(`[REST] EVM clear-sign: using Pioneer blob (keyId=${decoded.insightKeyId})`) + console.log(`[REST] EVM clear-sign: using caller-provided blob (${String(body.txMetadata.signedPayload).length} chars, keyId=${msg.txMetadata.keyId})`) } else { - console.log('[REST] EVM clear-sign: no metadata blob — device will show raw hex') + const decoded = activeSigningInfo?.calldataDecoded + if (decoded?.signedInsightBlob) { + // Pioneer emits the blob as base64, but hdwallet's ethSignTx + // arrayify()s a STRING signedPayload as hex ("0x"+s) → a base64 + // string throws "invalid hexadecimal string" before the device + // sees anything. Hand it the raw bytes (Uint8Array branch) so the + // encoding is unambiguous. + msg.txMetadata = { + signedPayload: new Uint8Array(Buffer.from(decoded.signedInsightBlob, 'base64')), + keyId: decoded.insightKeyId, + } + console.log(`[REST] EVM clear-sign: using Pioneer blob (keyId=${decoded.insightKeyId}, ${msg.txMetadata.signedPayload.length} bytes)`) + } else { + console.log('[REST] EVM clear-sign: no metadata blob — device will show raw hex') + } } } - console.log('[REST] ethSignTx hdwallet payload:', JSON.stringify(msg, null, 2)) + // Replacer: txMetadata.signedPayload is a Uint8Array — default stringify + // emits one line per byte, flooding the log with the whole blob. + console.log('[REST] ethSignTx hdwallet payload:', JSON.stringify(msg, (_k, v) => v instanceof Uint8Array ? `Uint8Array(${v.length})` : v, 2)) try { // Honest confirm dialog: decode msg.data so token/contract calls // don't show the contract as recipient or 0x0/hex-wei as amount. @@ -3586,13 +3650,18 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.RecoverDeviceRequest) - await wallet.recover({ - entropy: body.word_count ? ({ 12: 128, 18: 192, 24: 256 } as Record)[body.word_count] || 128 : 128, - label: body.label || 'KeepKey', - pin: body.pin_protection ?? true, - passphrase: body.passphrase_protection ?? false, - autoLockDelayMs: 600000, - }) + engine.setRecoveryActive(true) + try { + await wallet.recover({ + entropy: body.word_count ? ({ 12: 128, 18: 192, 24: 256 } as Record)[body.word_count] || 128 : 128, + label: body.label || 'KeepKey', + pin: body.pin_protection ?? true, + passphrase: body.passphrase_protection ?? false, + autoLockDelayMs: 600000, + }) + } finally { + engine.setRecoveryActive(false) + } featuresCache = null return json({ success: true }) } @@ -3614,6 +3683,39 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return json({ success: true }) } + // Cipher-recovery character entry. The device shows a scrambled keyboard + // on the OLED and the host relays the ciphered characters (CharacterAck). + // Mirrors /system/recovery/pin. The recover-device call rejects with + // "Word not found in BIP39 wordlist" when a finalized word is invalid. + if (path === '/system/recovery/character' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + const body = await parseRequest(req, S.SendCharacterRequest) + await wallet.sendCharacter(body.character) + return json({ success: true }) + } + + if (path === '/system/recovery/character/delete' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + await wallet.sendCharacterDelete() + return json({ success: true }) + } + + if (path === '/system/recovery/character/done' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + await wallet.sendCharacterDone() + return json({ success: true }) + } + + // Current cipher-recovery state. `seq` advances each time the device asks + // for the next character, so a caller can sync sends with the device. + if (path === '/system/recovery/state' && method === 'GET') { + auth.requireAuth(req) + return json(engine.getRecoveryState()) + } + // ── Zcash Shielded (Orchard) ──────────────────────────────── // Gate ALL zcash endpoints behind the feature flag (matches RPC handlers in index.ts) diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index ad4c20d2..8852cf75 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -40,6 +40,8 @@ export const PairRequest = z.object({ name: z.string().min(1), url: z.string().optional(), imageUrl: z.string().optional(), + /** Stable per-install id — preferred identity for dedup/idempotent pairing. */ + clientId: z.string().optional(), }).passthrough() /** POST /eth/sign-transaction */ @@ -127,6 +129,11 @@ export const XrpSignRequest = z.object({ destination: z.string().min(1), destinationTag: z.union([z.string(), z.number()]).optional(), }).passthrough(), + // tx (StdTx wrapper) + lastLedgerSequence are read by hdwallet's rippleSignTx + // (tx.value.fee/msg, lastLedgerSequence). Without them in the shape, .strip() + // deletes them and signing throws "undefined is not an object (msg.tx.value)". + tx: z.any(), + lastLedgerSequence: z.union([z.string(), z.number()]), sequence: z.union([z.string(), z.number()]), fee: z.union([z.string(), z.number()]).optional(), flags: z.number().optional(), @@ -327,6 +334,11 @@ export const SendPinRequest = z.object({ pin: z.string().min(1), }).passthrough() +/** POST /system/recovery/character — one ciphered character during cipher recovery */ +export const SendCharacterRequest = z.object({ + character: z.string().min(1).max(1), +}).passthrough() + // ── Zcash Shielded (Orchard) ───────────────────────────────────────── /** POST /api/zcash/shielded/init */ diff --git a/projects/keepkey-vault/src/bun/screen-capture.ts b/projects/keepkey-vault/src/bun/screen-capture.ts new file mode 100644 index 00000000..f522a0c0 --- /dev/null +++ b/projects/keepkey-vault/src/bun/screen-capture.ts @@ -0,0 +1,133 @@ +/** + * Native one-shot screen capture for QR scanning ("scan screen" in the QR overlay). + * + * Why native and not getDisplayMedia in the webview: WKWebView only honors + * getDisplayMedia when the app implements NO media-capture delegate — Electrobun + * implements one for getUserMedia (camera), so display-capture requests are + * auto-denied unless a private WebKit delegate is added. Capturing with the OS + * screenshot tool from the Bun side works on every platform and keeps the + * decode path (jsQR in the webview) identical to the camera/file flows. + * + * macOS permission model (Screen Recording, pure TCC — no entitlement exists): + * - CGPreflightScreenCaptureAccess() checks; CGRequestScreenCaptureAccess() + * registers the app in System Settings → Privacy & Security → Screen Recording + * and shows the system prompt (first call only). + * - The grant is keyed to the RESPONSIBLE process: the .app bundle in prod, + * but the terminal/IDE that launched `bun dev` in dev builds — in dev you + * grant Screen Recording to Terminal/iTerm/WebStorm, not KeepKey Vault. + * - After granting, macOS requires the app to relaunch before the in-process + * preflight reports true. Unauthorized `screencapture` fails loudly + * ("could not create image from display", exit 1) — verified on macOS 26 — + * so a permission gap can never masquerade as "no QR found". + */ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import type { ScreenCaptureResult } from "../shared/types" + +// ── macOS TCC preflight/request via CoreGraphics ────────────────────────── +let cgSymbols: { preflight: () => boolean; request: () => boolean } | null = null +function loadCoreGraphics() { + if (cgSymbols) return cgSymbols + const { dlopen, FFIType } = require("bun:ffi") + const lib = dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", { + CGPreflightScreenCaptureAccess: { args: [], returns: FFIType.bool }, + CGRequestScreenCaptureAccess: { args: [], returns: FFIType.bool }, + }) + cgSymbols = { + preflight: () => lib.symbols.CGPreflightScreenCaptureAccess(), + request: () => lib.symbols.CGRequestScreenCaptureAccess(), + } + return cgSymbols +} + +/** + * Check (and on first refusal, request) screen-capture permission. + * Returns null when capture may proceed, or a permission error result. + * Only macOS gates screen capture; Windows/Linux have no equivalent TCC. + */ +export function ensureScreenPermission(): ScreenCaptureResult | null { + if (process.platform !== "darwin") return null + try { + const cg = loadCoreGraphics() + if (cg.preflight()) return null + // Registers the app in the Screen Recording list + shows the system + // prompt the first time. Returns true if the user already granted + // (e.g. mid-session grant picked up without the prompt). + if (cg.request()) return null + // Open the exact Settings pane — the system prompt only appears once. + try { + Bun.spawn(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"], { stdio: ["ignore", "ignore", "ignore"] }) + } catch {} + return { + ok: false, + reason: "permission", + message: + "Screen Recording permission is required. In System Settings → Privacy & Security → Screen Recording, enable KeepKey Vault, then quit and reopen the app.", + } + } catch (e: any) { + return { ok: false, reason: "failed", message: `Permission check failed: ${e?.message || e}` } + } +} + +function readAsBase64AndClean(dir: string, files: string[]): string[] { + const images: string[] = [] + for (const f of files) { + if (existsSync(f)) images.push(readFileSync(f).toString("base64")) + } + rmSync(dir, { recursive: true, force: true }) + return images +} + +/** Capture every display to PNG. Caller is responsible for the permission check. */ +export async function captureScreens(): Promise { + const dir = mkdtempSync(join(tmpdir(), "kk-scan-")) + // ponytail: 4 displays max — screencapture writes one file per attached display + const files = [0, 1, 2, 3].map((i) => join(dir, `screen-${i}.png`)) + try { + if (process.platform === "darwin") { + const proc = Bun.spawn(["/usr/sbin/screencapture", "-x", "-t", "png", ...files], { stdio: ["ignore", "ignore", "ignore"] }) + await proc.exited + } else if (process.platform === "win32") { + // No OS permission gate on Windows. ponytail: CopyFromScreen is not + // per-monitor-DPI aware — a scaled capture still decodes fine for QR. + const ps = [ + "Add-Type -AssemblyName System.Windows.Forms;", + "Add-Type -AssemblyName System.Drawing;", + "$i=0;", + "foreach($s in [System.Windows.Forms.Screen]::AllScreens){", + "$b=$s.Bounds;", + "$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;", + "$g=[System.Drawing.Graphics]::FromImage($bmp);", + "$g.CopyFromScreen($b.X,$b.Y,0,0,$b.Size);", + `$bmp.Save((Join-Path '${dir.replace(/'/g, "''")}' (\"screen-$i.png\")),[System.Drawing.Imaging.ImageFormat]::Png);`, + "$g.Dispose(); $bmp.Dispose(); $i++ }", + ].join(" ") + const proc = Bun.spawn(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { stdio: ["ignore", "ignore", "ignore"] }) + await proc.exited + } else { + // Linux: first available tool wins (grim = Wayland, import = X11). + // Wayland compositors may show their own one-time consent dialog. + const out = files[0] + const candidates: string[][] = [ + ["grim", out], + ["gnome-screenshot", "-f", out], + ["spectacle", "-b", "-n", "-o", out], + ["import", "-window", "root", out], + ] + const tool = candidates.find((c) => Bun.which(c[0])) + if (!tool) { + rmSync(dir, { recursive: true, force: true }) + return { ok: false, reason: "unsupported", message: "No screenshot tool found (install grim, gnome-screenshot, spectacle, or imagemagick)." } + } + const proc = Bun.spawn(tool, { stdio: ["ignore", "ignore", "ignore"] }) + await proc.exited + } + const images = readAsBase64AndClean(dir, files) + if (images.length === 0) return { ok: false, reason: "failed", message: "Screen capture produced no image." } + return { ok: true, images } + } catch (e: any) { + rmSync(dir, { recursive: true, force: true }) + return { ok: false, reason: "failed", message: `Screen capture failed: ${e?.message || e}` } + } +} diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 87310557..874ab546 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -12,6 +12,18 @@ const TAG = '[swap]' // ── Asset mapping helpers ─────────────────────────────────────────── +/** True for assets that swap via a THORChain/Maya MsgDeposit (no inbound vault + * address): native RUNE/CACAO, plus on-chain bank tokens (TCY, RUJI, secured + * assets) which carry a `/denom:` caip. Single source of truth — both the + * quote parser and the tx-build path must agree, or one throws while the other + * would have built fine. */ +export function isNativeDepositCaip(fromCaip: string): boolean { + if (fromCaip === 'cosmos:thorchain-mainnet-v1/slip44:931') return true + if (fromCaip === 'cosmos:mayachain-mainnet-v1/slip44:931') return true + return (fromCaip.startsWith('cosmos:thorchain-') || fromCaip.startsWith('cosmos:mayachain-')) + && fromCaip.includes('/denom:') +} + /** Parse a THORChain asset string (e.g. "ETH.USDT-0xDAC...") into parts */ export function parseThorAsset(asset: string): { chain: string; symbol: string; contractAddress?: string } { const [chain, rest] = asset.split('.') @@ -264,10 +276,9 @@ function parseSingleQuote( // Expiry for depositWithExpiry const expiry = raw.expiry || quote.expiry || 0 - // Native THORChain/Maya swaps (RUNE, CACAO) use MsgDeposit — no inbound vault needed - const isNativeDeposit = - params.fromCaip === 'cosmos:thorchain-mainnet-v1/slip44:931' || - params.fromCaip === 'cosmos:mayachain-mainnet-v1/slip44:931' + // Native THORChain/Maya swaps (RUNE, CACAO, and bank tokens TCY/RUJI/secured + // assets) use MsgDeposit — no inbound vault needed. + const isNativeDeposit = isNativeDepositCaip(params.fromCaip) if (!inboundAddress && !isNativeDeposit && !hasPrebuiltTx) { // Dump full response structure to help diagnose missing field diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index da71e07a..e7ba029e 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -22,7 +22,7 @@ import { normalizeBchAddress } from './txbuilder' // history rows without CAIP). The swap quote/execute path no longer uses it — // vault is CAIP-native end-to-end. export { parseAssetsResponse, parseQuoteResponse, assetToCaip } from './swap-parsing' -import { parseQuoteResponse, parseAssetsResponse } from './swap-parsing' +import { parseQuoteResponse, parseAssetsResponse, isNativeDepositCaip } from './swap-parsing' const TAG = '[swap]' @@ -34,19 +34,9 @@ const TAG = '[swap]' const BTC_NETWORK_ID = 'bip122:000000000019d6689c085ae165831e93' const isBitcoin = (c: ChainDef) => c.networkId === BTC_NETWORK_ID -// CAIP-19 of native THORChain (RUNE) and Mayachain (CACAO) — the only assets -// that route via MsgDeposit instead of a vault inbound address. CAIP is the -// canonical identifier; symbols and THOR-style asset strings are derived -// display data, never load-bearing. -const RUNE_CAIP = 'cosmos:thorchain-mainnet-v1/slip44:931' -const CACAO_CAIP = 'cosmos:mayachain-mainnet-v1/slip44:931' - -/** True for native THORChain/Maya deposits (CAIP-driven; replaces the - * fragile `fromAsset === 'THOR.RUNE'` check that depended on canonical - * THORChain prefix). */ -function isNativeDepositCaip(fromCaip: string): boolean { - return fromCaip === RUNE_CAIP || fromCaip === CACAO_CAIP -} +// isNativeDepositCaip lives in swap-parsing.ts (single source of truth — the +// quote parser and this build path MUST agree on what's a MsgDeposit, or one +// throws "missing inbound address" while the other would have built fine). // CAIP namespace parser is shared with the picker so a future namespace // addition (Solana SPL, etc.) only needs editing in one place. @@ -242,6 +232,33 @@ export async function getSwapAssets(): Promise { } } + // THORChain bank tokens TCY and RUJI have live THORChain pools (THOR.TCY, + // THOR.RUJI verified against thornode) but aren't always in Pioneer's + // available-assets list. Same defensive shim as RUNE/TRON above — keyed by + // their `/denom:` caip so the quote path routes them via THORChain. + // + // contractAddress = the bank denom: SwapDialog keys token-vs-native balance on + // contractAddress (matched against the portfolio token's contractAddress, + // which getBalances sets to the denom). WITHOUT it these fall through to the + // native RUNE balance, so max/validation/price would quote against RUNE. + const thorBankDef = CHAINS.find(c => c.id === 'thorchain') + if (thorBankDef) { + if (!assets.find(a => a.asset === 'THOR.TCY')) { + assets.push({ + asset: 'THOR.TCY', chainId: 'thorchain', symbol: 'TCY', name: 'TCY', + chainFamily: 'cosmos', decimals: 8, contractAddress: 'tcy', + caip: 'cosmos:thorchain-mainnet-v1/denom:tcy', + }) + } + if (!assets.find(a => a.asset === 'THOR.RUJI')) { + assets.push({ + asset: 'THOR.RUJI', chainId: 'thorchain', symbol: 'RUJI', name: 'Rujira', + chainFamily: 'cosmos', decimals: 8, contractAddress: 'x/ruji', + caip: 'cosmos:thorchain-mainnet-v1/denom:x/ruji', + }) + } + } + swapLog(`${TAG} Loaded ${assets.length} swap assets from Pioneer`) assetCache = assets assetCacheTime = Date.now() @@ -801,6 +818,10 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): fromAddress, caip: sourceCaip, tokenDecimals, + // MsgDeposit coins asset: THOR.RUNE for native, THOR.TCY/THOR.RUJI for + // bank tokens. Without this the builder defaults to THOR.RUNE and the + // network would try to take RUNE for a TCY/RUJI swap. + depositAsset: fromAssetMeta?.asset, }) unsignedTx = buildResult.unsignedTx } @@ -1038,6 +1059,9 @@ export async function previewSwapBuild( // decimals: synthesized token sources (e.g. SPL USDT, absent from // Pioneer's available-assets) carry them in params.tokenDecimals. caip: fromAssetMeta?.caip ?? params.fromCaip, tokenDecimals: fromAssetMeta?.decimals ?? params.tokenDecimals, + // Match the execute path: THOR.TCY/THOR.RUJI deposit asset, not the + // hardcoded THOR.RUNE default, so the preview shows the right coin. + depositAsset: fromAssetMeta?.asset, }) return { unsignedTx: buildResult.unsignedTx } } diff --git a/projects/keepkey-vault/src/bun/sweep-engine.ts b/projects/keepkey-vault/src/bun/sweep-engine.ts index 2945bcbe..cfb9148e 100644 --- a/projects/keepkey-vault/src/bun/sweep-engine.ts +++ b/projects/keepkey-vault/src/bun/sweep-engine.ts @@ -218,7 +218,7 @@ export async function checkAddressBalance(address: string): Promise { return utxos.reduce((sum, u) => sum + u.value, 0) } -export async function fetchUtxos(address: string): Promise { +export async function fetchUtxos(address: string, networkId: string = BTC_NETWORK_ID): Promise { try { // Pioneer's ListUnspent endpoint accepts both xpubs AND single addresses // (verified 2026-05-07). Path: /api/v1/utxo/unspent/{network}/{xpub-or-address}. @@ -226,7 +226,7 @@ export async function fetchUtxos(address: string): Promise { // doesn't exist on pioneer-server (404), so the sweep tool was silently // returning [] for every funded address found. const pioneer = await getPioneer() - const resp = await pioneer.ListUnspent({ network: BTC_NETWORK_ID, xpub: address }) + const resp = await pioneer.ListUnspent({ network: networkId, xpub: address }) const data = Array.isArray(resp) ? resp : Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp.data.data @@ -244,12 +244,12 @@ export async function fetchUtxos(address: string): Promise { } } -async function fetchTxHex(txid: string): Promise { +async function fetchTxHex(txid: string, networkId: string = BTC_NETWORK_ID): Promise { try { // Pioneer's tx lookup: /api/v1/utxo/lookup/{networkId}/{txid}. // Older code hit /api/v2/tx-specific/{txid} on Pioneer's base URL — 404. const pioneer = await getPioneer() - const resp = await pioneer.UtxoLookup({ networkId: BTC_NETWORK_ID, txid }) + const resp = await pioneer.UtxoLookup({ networkId, txid }) const data = resp?.data || resp return data?.hex || data?.tx?.hex || undefined } catch { @@ -358,7 +358,12 @@ export interface SweepTxResult { export async function buildSweepTx( scan: SweepScan, destinationAddress: string, + // Chain override for non-BTC UTXO sweeps (the audit uncommon-path sweep). + // Defaults keep every existing BTC caller byte-identical. + opts: { coin?: string; networkId?: string } = {}, ): Promise { + const coin = opts.coin || 'Bitcoin' + const networkId = opts.networkId || BTC_NETWORK_ID // Only sweep mismatch/account-key entries — higher-account funds are recovered by adding the account const funded = scan.results.filter(r => r.utxos.length > 0 && r.category !== 'higher-account') if (funded.length === 0) throw new Error('No UTXOs found to sweep') @@ -367,7 +372,11 @@ export async function buildSweepTx( const pioneer = await getPioneer() let feeRate = 5 // sat/byte default try { - const feeResp = await pioneer.GetFeeRateByNetwork({ networkId: BTC_NETWORK_ID }) + // This pioneer SDK build may not have GetFeeRateByNetwork — fall back to + // GetFeeRate like txbuilder/utxo.ts does. + const feeResp = typeof pioneer.GetFeeRateByNetwork === 'function' + ? await pioneer.GetFeeRateByNetwork({ networkId }) + : await pioneer.GetFeeRate({ networkId }) const feeData = feeResp?.data || feeResp const fast = feeData?.fast || feeData?.average || 5 // Auto-detect sat/kB vs sat/byte @@ -400,7 +409,7 @@ export async function buildSweepTx( // Fetch raw tx hex for inputs that need it (non-segwit: p2pkh) for (const u of allUtxos) { if (u.entry.scriptType === 'p2pkh' && !u.utxo.hex) { - u.utxo.hex = await fetchTxHex(u.utxo.txid) || '' + u.utxo.hex = await fetchTxHex(u.utxo.txid, networkId) || '' } } @@ -423,7 +432,7 @@ export async function buildSweepTx( }] const unsignedTx = { - coin: 'Bitcoin', + coin, inputs, outputs, version: 1, diff --git a/projects/keepkey-vault/src/bun/txbuilder/cosmos.test.ts b/projects/keepkey-vault/src/bun/txbuilder/cosmos.test.ts new file mode 100644 index 00000000..af6960c3 --- /dev/null +++ b/projects/keepkey-vault/src/bun/txbuilder/cosmos.test.ts @@ -0,0 +1,65 @@ +/** + * Unit test for THORChain bank-token (TCY/RUJI) MsgSend denom threading. + * + * Verifies buildCosmosTx emits the token denom (not the chain native) when the + * caip carries a `/bank:` segment, while RUNE sends stay native. No + * device/Pioneer needed — Pioneer is stubbed. Run: bun src/bun/txbuilder/cosmos.test.ts + */ +import { buildCosmosTx } from './cosmos' + +const THOR = { + id: 'thorchain', coin: 'THORChain', symbol: 'RUNE', + caip: 'cosmos:thorchain-mainnet-v1/slip44:931', + decimals: 8, denom: 'rune', chainId: 'thorchain-1', + defaultPath: [0x8000002c, 0x800003a3, 0x80000000, 0, 0], +} as any + +const pioneer = { + GetAccountInfo: async () => ({ data: { account: { account_number: '17', sequence: '2' } } }), + GetPortfolioBalances: async () => ({ data: { balances: [{ balance: '100' }] } }), +} +const FROM = 'thor1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfhgnzx' + +let pass = 0, fail = 0 +function eq(label: string, got: unknown, want: unknown) { + if (got === want) { console.log(` ✅ ${label}`); pass++ } + else { console.error(` ❌ ${label}: got ${JSON.stringify(got)} want ${JSON.stringify(want)}`); fail++ } +} +const sendMsg = (r: any) => r.tx.msg[0] + +async function main() { + // Native RUNE — denom must stay "rune", type MsgSend, 1.5 → 150000000 + const rune = await buildCosmosTx(pioneer, THOR, { to: FROM, amount: '1.5', fromAddress: FROM }) + eq('RUNE denom stays native', sendMsg(rune).value.amount[0].denom, 'rune') + eq('RUNE amount base units', sendMsg(rune).value.amount[0].amount, '150000000') + + // TCY — denom from caip, fee still rune + const tcy = await buildCosmosTx(pioneer, THOR, { to: FROM, amount: '2', fromAddress: FROM, caip: 'cosmos:thorchain-mainnet-v1/denom:tcy' }) + eq('TCY denom from caip', sendMsg(tcy).value.amount[0].denom, 'tcy') + eq('TCY msg type is MsgSend', sendMsg(tcy).type, 'thorchain/MsgSend') + eq('TCY fee paid in rune', tcy.tx.fee.amount[0].denom, 'rune') + + // RUJI — denom contains '/', must survive greedy parse + const ruji = await buildCosmosTx(pioneer, THOR, { to: FROM, amount: '1', fromAddress: FROM, caip: 'cosmos:thorchain-mainnet-v1/denom:x/ruji' }) + eq('RUJI denom keeps slash', sendMsg(ruji).value.amount[0].denom, 'x/ruji') + + // Token MAX — sends full token balance, NO rune fee reserve subtracted + const max = await buildCosmosTx(pioneer, THOR, { to: FROM, amount: '0', isMax: true, fromAddress: FROM, caip: 'cosmos:thorchain-mainnet-v1/denom:tcy', tokenBalance: '42.5', tokenDecimals: 8 }) + eq('TCY MAX = full balance, no fee reserve', sendMsg(max).value.amount[0].amount, '4250000000') + + // Swap MsgDeposit FROM a bank token — coins asset must be THOR.TCY (not the + // hardcoded THOR.RUNE) so the network takes TCY, with the swap memo. + const dep = await buildCosmosTx(pioneer, THOR, { + to: FROM, amount: '190.76905663', fromAddress: FROM, + caip: 'cosmos:thorchain-mainnet-v1/denom:tcy', tokenDecimals: 8, + isSwapDeposit: true, depositAsset: 'THOR.TCY', + memo: '=:THOR.RUJI:' + FROM + ':7278216218:kk:30', + }) + eq('swap deposit type is MsgDeposit', sendMsg(dep).type, 'thorchain/MsgDeposit') + eq('swap deposit coins asset is THOR.TCY', sendMsg(dep).value.coins[0].asset, 'THOR.TCY') + eq('swap deposit carries the swap memo', sendMsg(dep).value.memo, '=:THOR.RUJI:' + FROM + ':7278216218:kk:30') + + console.log(`\n Result: ${pass} passed, ${fail} failed\n`) + process.exit(fail > 0 ? 1 : 0) +} +main().catch((e) => { console.error('crashed:', e); process.exit(1) }) diff --git a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts index 6fc6baf6..94576a42 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts @@ -97,6 +97,15 @@ export interface BuildCosmosParams { isMax?: boolean isSwapDeposit?: boolean // use MsgDeposit instead of MsgSend (for THORChain/Maya swaps) fromAddress: string + // Bank-token send (e.g. THORChain TCY/RUJI/secured assets). When caip carries a + // `/bank:` segment, the MsgSend uses that denom instead of the chain + // native — the network fee is still paid in the chain's native coin. + caip?: string + tokenBalance?: string // human-readable token balance (for MAX) + tokenDecimals?: number // token decimals (defaults to chain.decimals) + // THORChain/Maya asset for a swap MsgDeposit's coins (e.g. "THOR.RUNE", + // "THOR.TCY", "THOR.RUJI"). Defaults to the chain native if omitted. + depositAsset?: string } export async function buildCosmosTx( @@ -106,7 +115,16 @@ export async function buildCosmosTx( ) { const { to, memo = '', feeLevel = 5, isMax = false, isSwapDeposit = false, fromAddress } = params - const denom = chain.denom || chain.symbol.toLowerCase() + // Bank-token send? A `/denom:` caip segment (Pioneer's scheme, e.g. + // `.../denom:tcy`, `.../denom:x/ruji`) selects a non-native bank denom. Parse + // greedily to end so a denom containing '/' (RUJI `x/ruji`, secured assets + // `bch/bch`) survives. (`bank:` accepted too for forward-compat.) + const bankMatch = params.caip?.match(/\/(?:denom|bank):(.+)$/) + const isToken = !!bankMatch + const denom = isToken ? bankMatch![1] : (chain.denom || chain.symbol.toLowerCase()) + // ponytail: token amount decimals; tcy/ruji are 8 like RUNE so chain.decimals + // is correct today. tokenDecimals override covers a future non-8 bank token. + const amountDecimals = isToken ? (params.tokenDecimals ?? chain.decimals) : chain.decimals // 1. Get account info (API expects short network name, not CAIP networkId) console.log(`${TAG} Fetching account info for ${chain.coin}...`) @@ -147,14 +165,22 @@ export async function buildCosmosTx( let baseAmount: bigint if (isMax) { - const balResp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: fromAddress }] }, { forceRefresh: true }) - const balStr = String((balResp?.data?.balances || [])[0]?.balance ?? '0') - const balBase = toBaseUnits(balStr, chain.decimals) - const feeBase = maxFeeReserveBase(chain, BigInt(fee.amount[0]?.amount || '0')) - baseAmount = balBase - feeBase - if (baseAmount < 0n) baseAmount = 0n + if (isToken) { + // Token MAX: send the whole token balance. The native fee is paid from the + // chain's native coin (rune), a separate balance — so no reserve here. + const balStr = params.tokenBalance + ?? String((await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: params.caip, pubkey: fromAddress }] }, { forceRefresh: true }))?.data?.balances?.[0]?.balance ?? '0') + baseAmount = toBaseUnits(String(balStr), amountDecimals) + } else { + const balResp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: fromAddress }] }, { forceRefresh: true }) + const balStr = String((balResp?.data?.balances || [])[0]?.balance ?? '0') + const balBase = toBaseUnits(balStr, chain.decimals) + const feeBase = maxFeeReserveBase(chain, BigInt(fee.amount[0]?.amount || '0')) + baseAmount = balBase - feeBase + if (baseAmount < 0n) baseAmount = 0n + } } else { - baseAmount = toBaseUnits(params.amount, chain.decimals) + baseAmount = toBaseUnits(params.amount, amountDecimals) } if (baseAmount <= 0n) throw new Error('Amount must be greater than zero') @@ -166,7 +192,7 @@ export async function buildCosmosTx( if (isDeposit) { const depositType = MSG_DEPOSIT_TYPES[chain.id]! - const depositAsset = DEPOSIT_ASSETS[chain.id]! + const depositAsset = params.depositAsset || DEPOSIT_ASSETS[chain.id]! console.log(`${TAG} Building MsgDeposit: asset=${depositAsset}, amount=${baseAmount}, memo=${memo}`) msg = { type: depositType, diff --git a/projects/keepkey-vault/src/bun/txbuilder/index.ts b/projects/keepkey-vault/src/bun/txbuilder/index.ts index 68d0b2ce..33c5296e 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/index.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/index.ts @@ -97,7 +97,7 @@ export async function injectTronMemo(tronGridTx: any, memo: string): Promise { switch (chain.chainFamily) { case 'utxo': { @@ -149,6 +149,10 @@ export async function buildTx( isMax: params.isMax, isSwapDeposit: params.isSwapDeposit, fromAddress: params.fromAddress, + caip: params.caip, + tokenBalance: params.tokenBalance, + tokenDecimals: params.tokenDecimals, + depositAsset: params.depositAsset, }) const { fee: cosmosFee, ...cosmosTx } = cosmosResult return { unsignedTx: cosmosTx, fee: cosmosFee } @@ -352,10 +356,17 @@ export async function buildTx( const amountHex = tokenAmountBase.toString(16).padStart(64, '0') const parameter = recipientHashHex.padStart(64, '0') + amountHex - // fee_limit caps the energy/TRX a smart contract call can burn — 30 TRX - // is generous for a USDT.transfer() (typical cost is ~14 TRX) and - // matches what TronLink defaults to for unknown contracts. - const FEE_LIMIT_SUN = 30_000_000 + // fee_limit caps the energy/TRX a smart contract call can burn — it is + // a CEILING, not a price: only actual consumption is charged. A USDT + // transfer to a zero-USDT recipient is ~130k energy PLUS the + // dynamic-energy penalty (USDT sits over getDynamicEnergyThreshold; + // the factor moves every maintenance cycle, up to 3.4x) — at peak + // that's >300k energy = >30 TRX, so the old 30 TRX limit made those + // sends revert on-chain as "Failed — Out of Energy" while still + // burning the full 30 TRX (e.g. tx c105241a…, 2026-07-02). 100 TRX + // covers the worst case (~57 TRX at max factor) and matches + // TronLink's current USDT default. + const FEE_LIMIT_SUN = 100_000_000 let tronGridTx: any try { @@ -396,6 +407,40 @@ export async function buildTx( tronGridTx = await injectTronMemo(tronGridTx, params.memo) } + // Estimate the REAL fee for display — showing the 100 TRX ceiling as + // "the fee" would 4-10x overstate a typical transfer. Simulate the + // call for its energy cost and price it at the chain's getEnergyFee. + let displayFeeTrx = String(FEE_LIMIT_SUN / 1_000_000) // ponytail: fallback = ceiling (upper bound, honest as "max fee") + try { + const [simResp, chainParamsResp] = await Promise.all([ + fetch('https://api.trongrid.io/wallet/triggerconstantcontract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_address: params.fromAddress, + contract_address: tokenContractFromCaip, + function_selector: 'transfer(address,uint256)', + parameter, + visible: true, + }), + }), + fetch('https://api.trongrid.io/wallet/getchainparameters'), + ]) + const sim = await simResp.json() as any + const chainParams = await chainParamsResp.json() as any + // energy_penalty is the dynamic-energy surcharge on hot contracts + // (USDT is always over getDynamicEnergyThreshold) — it's what made + // the real cost exceed energy_used × price. It moves every + // maintenance cycle, so this is an estimate, not a quote. + const energyUsed = Number(sim?.energy_used) + (Number(sim?.energy_penalty) || 0) + const energyPriceSun = Number(chainParams?.chainParameter?.find((p: any) => p.key === 'getEnergyFee')?.value) + if (energyUsed > 0 && energyPriceSun > 0) { + displayFeeTrx = String(Math.min(energyUsed * energyPriceSun, FEE_LIMIT_SUN) / 1_000_000) + } + } catch (e: any) { + console.warn(`[buildTx] TRON fee estimate failed, displaying fee_limit: ${e.message}`) + } + // Intentionally do NOT pass `toAddress`/`amount` for TRC-20. // Firmware 7.14's TRON FSM has only a TransferContract clear-sign // path — given those fields it shows "Send TRX to @@ -412,7 +457,7 @@ export async function buildTx( rawTx: tronGridTx.raw_data_hex, tronGridTx, } - return { unsignedTx: tronUnsignedTx, fee: String(FEE_LIMIT_SUN / 1_000_000) } + return { unsignedTx: tronUnsignedTx, fee: displayFeeTrx } } // ── Native TRX path (TransferContract) ── diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index a2155fac..762cae27 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -62,6 +62,17 @@ function App() { // with React render batching on fast USB detach/reattach cycles (Windows). const oobEnteredRef = useRef(false) const oobClaimStuckSince = useRef(null) + + // If the device drops back into a setup state (wiped, or rebooted uninitialized + // after a firmware flash) once the wizard already completed, wizardComplete is + // stuck true — Dashboard, which normally consumes it, only mounts when ready, so + // it never clears. That forces the app to the wallet-picker splash instead of + // re-opening onboarding. Clear it so the OOB wizard can mount again. + useEffect(() => { + if (wizardComplete && ["bootloader", "needs_firmware", "needs_init"].includes(deviceState.state)) { + setWizardComplete(false) + } + }, [wizardComplete, deviceState.state]) const [portfolioLoaded, setPortfolioLoaded] = useState(false) const [gridReady, setGridReady] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) @@ -72,6 +83,7 @@ function App() { const [appVersion, setAppVersion] = useState<{ version: string; channel: string } | null>(null) const [restApiEnabled, setRestApiEnabled] = useState(false) const [walletConnectEnabled, setWalletConnectEnabled] = useState(false) + const [hiveEnabled, setHiveEnabled] = useState(false) const [emulatorEnabled, setEmulatorEnabled] = useState(false) const [pendingAppUrl, setPendingAppUrl] = useState(null) const [pendingWcOpen, setPendingWcOpen] = useState(false) @@ -98,7 +110,7 @@ function App() { const refreshSettings = () => { rpcRequest("getAppSettings") .then((s) => { - setRestApiEnabled(s.restApiEnabled); setWalletConnectEnabled(s.walletConnectEnabled); setEmulatorEnabled(s.emulatorEnabled) + setRestApiEnabled(s.restApiEnabled); setWalletConnectEnabled(s.walletConnectEnabled); setEmulatorEnabled(s.emulatorEnabled); setHiveEnabled(s.hiveEnabled) if (s.pioneerApiBase) loadSupportedChains(s.pioneerApiBase).catch(() => {}) }) .catch(() => {}) @@ -834,6 +846,7 @@ function App() { onJumpToVault={() => setActiveTab("vault")} balances={paletteBalances} firmwareVersion={undefined} + hiveEnabled={hiveEnabled} /> ) @@ -957,7 +970,7 @@ function App() { onClose={() => { setSettingsOpen(false) rpcRequest("getAppSettings") - .then((s) => { setRestApiEnabled(s.restApiEnabled); setWalletConnectEnabled(s.walletConnectEnabled); setEmulatorEnabled(s.emulatorEnabled) }) + .then((s) => { setRestApiEnabled(s.restApiEnabled); setWalletConnectEnabled(s.walletConnectEnabled); setEmulatorEnabled(s.emulatorEnabled); setHiveEnabled(s.hiveEnabled) }) .catch(() => {}) window.dispatchEvent(new Event("keepkey-settings-changed")) }} @@ -1007,6 +1020,7 @@ function App() { onJumpToVault={() => setActiveTab("vault")} balances={paletteBalances} firmwareVersion={deviceState.firmwareVersion} + hiveEnabled={hiveEnabled} /> {/* Top-level swap dialog mount for REST-driven /api/v2/swap/open. */} diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 71db45d4..74a70789 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -2,7 +2,7 @@ import React, { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRe import { useTranslation } from "react-i18next" import { Box, Flex, Text, Button, Image, VStack, HStack, IconButton, Spinner } from "@chakra-ui/react" import { FaPlus, FaEye, FaEyeSlash, FaShieldAlt, FaCheck, FaCopy, FaTag, FaChevronDown, FaChevronUp } from "react-icons/fa" -import { rpcRequest, onRpcMessage, rpcFire } from "../lib/rpc" +import { rpcRequest, onRpcMessage } from "../lib/rpc" import type { ChainDef } from "../../shared/chains" import { CHAINS, BTC_SCRIPT_TYPES, btcAccountPath, isChainSupported } from "../../shared/chains" import type { ChainBalance, TokenBalance, TokenVisibilityStatus, AppSettings, SwapAsset } from "../../shared/types" @@ -12,6 +12,7 @@ import { AnimatedUsd } from "./AnimatedUsd" import { formatBalance } from "../lib/formatting" import { useFiat } from "../lib/fiat-context" import { ReceiveView } from "./ReceiveView" +import { HiveAccountPanel } from "./HiveAccountPanel" import { SendForm } from "./SendForm" // Lazy-load optional feature components — defers module evaluation to avoid @@ -26,6 +27,7 @@ import { SweepDialog } from "./SweepDialog" import { ActivityTable, TxDetailDialog, recentFirst, nativePriceByChain, type TxDetail } from "./ActivityPanel" import type { RecentActivity } from "../../shared/types" import { BtcXpubSelector } from "./BtcXpubSelector" +import { UtxoAccountSelector } from "./UtxoAccountSelector" import { EvmAddressSelector } from "./EvmAddressSelector" import { useBtcAccounts } from "../hooks/useBtcAccounts" import { useEvmAddresses } from "../hooks/useEvmAddresses" @@ -34,6 +36,13 @@ import { detectSpamToken, categorizeTokens, type SpamResult } from "../../shared type AssetView = "receive" | "send" | "privacy" +// Litecoin script types — same trio as Bitcoin, standard purpose per type. +const LTC_SCRIPT_TYPES = [ + { scriptType: 'p2pkh', purpose: 44, label: 'Legacy', prefix: 'L' }, + { scriptType: 'p2sh-p2wpkh', purpose: 49, label: 'SegWit', prefix: 'M' }, + { scriptType: 'p2wpkh', purpose: 84, label: 'Native SegWit', prefix: 'ltc1' }, +] + class SwapErrorBoundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { state = { error: null as Error | null } static getDerivedStateFromError(error: Error) { return { error } } @@ -64,9 +73,11 @@ interface AssetPageProps { * wallet's balances. */ watchOnly?: boolean + /** Hidden (passphrase) wallet: UTXO altcoin accounts are never persisted, so hide the account selector. */ + isHiddenWallet?: boolean } -export function AssetPage({ chain, balance, onBack, firmwareVersion, initialAction, initialToken, onViewActivity, watchOnly }: AssetPageProps) { +export function AssetPage({ chain, balance, onBack, firmwareVersion, initialAction, initialToken, onViewActivity, watchOnly, isHiddenWallet }: AssetPageProps) { const { t } = useTranslation("asset") const { fmtCompact, symbol: fiatSymbol } = useFiat() // Watch-only mode never lands on a signing view, regardless of the @@ -91,29 +102,48 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi const [deriveError, setDeriveError] = useState(null) const [currentPath, setCurrentPath] = useState(chain.defaultPath) - // BTC multi-account support (declared early — handleRefresh depends on isBtc + refreshBtcAccounts) + // BTC multi-account support const isBtc = chain.id === 'bitcoin' const { btcAccounts, selectXpub, addAccount, refresh: refreshBtcAccounts, loading: btcLoading } = useBtcAccounts() - // Single-chain refresh + // Single-chain refresh. Always forced (bun getBalance passes forceRefresh:true + // to Pioneer). The result is NOT kept locally: the backend pushes the identical + // data back as 'balance-updated' (and 'btc-accounts-update' / 'evm-addresses-update'), + // which Dashboard merges into the `balance` prop — a local snapshot would only + // shadow newer push/bulk-refresh data for the rest of the mount. const [refreshing, setRefreshing] = useState(false) - const [refreshedBalance, setRefreshedBalance] = useState(null) + const [refreshError, setRefreshError] = useState(null) + // Last time THIS page saw fresh data for the chain (manual refresh success + // or a balance push). Mount-time data is of unknown age, so no stamp then — + // the Synced badge only shows a time it can vouch for. + const [lastSyncedAt, setLastSyncedAt] = useState(null) + // Bumped on every manual refresh so chain-specific panels that fetch their own + // data (HiveAccountPanel) re-fetch too. + const [refreshNonce, setRefreshNonce] = useState(0) const handleRefresh = useCallback(async () => { setRefreshing(true) + setRefreshError(null) try { - const updated = await rpcRequest("getBalance", { chainId: chain.id }) - setRefreshedBalance(updated) - // BTC: re-fetch per-xpub balances so the selector pills update - if (isBtc) await refreshBtcAccounts() + // 90s: backend budget is 60s of Pioneer time plus device derivation — + // the 30s rpc default made slow forced refreshes fail invisibly. + await rpcRequest("getBalance", { chainId: chain.id }, 90000) + setRefreshNonce(n => n + 1) + setLastSyncedAt(new Date()) } catch (e) { console.warn(`[AssetPage] refresh ${chain.id} failed:`, e) + setRefreshError(e instanceof Error ? e.message : String(e)) } finally { setRefreshing(false) } - }, [chain.id, isBtc, refreshBtcAccounts]) - - // Use refreshed balance if available, otherwise prop - const baseBalance = refreshedBalance || balance + }, [chain.id]) + // A fresh balance for this chain (from any refresh path — incl. a backend + // getBalance that outlived the 90s rpc timeout above) supersedes the error: + // keeping the chip up next to just-updated data would be a false alarm. + useEffect(() => { + return onRpcMessage("balance-updated", (updated: ChainBalance) => { + if (updated.chainId === chain.id) { setRefreshError(null); setLastSyncedAt(new Date()) } + }) + }, [chain.id]) // Feature flags: zcash privacy const [swappableChainIds, setSwappableChainIds] = useState>(new Set()) @@ -161,7 +191,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi tokens: selectedEvmChainBalance.tokens, defiPositions: selectedEvmChainBalance.defiPositions, } - : baseBalance + : balance // Multi-address total: show when >1 EVM address has funds on this chain const evmAddressesWithChainBalance = isEvm @@ -200,11 +230,25 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi // TON: bounceable toggle (default: non-bounceable / UQ for safe receiving) const isTon = chain.chainFamily === 'ton' + const isHive = chain.chainFamily === 'hive' const [tonBounceable, setTonBounceable] = useState(false) - const deriveAddress = useCallback(async (path?: number[], overrideBounceable?: boolean) => { + // UTXO altcoin script-type picker (Litecoin only — same three types as + // Bitcoin, standard purpose per type: 44 legacy / 49 wrapped / 84 native). + const utxoScripts = chain.id === 'litecoin' ? LTC_SCRIPT_TYPES : null + const [utxoScriptType, setUtxoScriptType] = useState(chain.scriptType || 'p2pkh') + + const deriveAddress = useCallback(async (path?: number[], overrideBounceable?: boolean, overrideScriptType?: string) => { const usePath = path || effectivePath if (path) setCurrentPath(path) + // Watch-only: no device to derive from — show the cached address only. + // Re-deriving a different path requires the device, so isn't possible offline. + if (watchOnly) { + setAddress(balance?.address || null) + setDeriveError(balance?.address ? null : 'Address unavailable offline') + setLoading(false) + return + } setLoading(true) setDeriveError(null) try { @@ -213,7 +257,10 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi showDisplay: false, coin: chain.chainFamily === 'evm' ? 'Ethereum' : chain.coin, } - const st = (isBtc && btcSelected) ? btcSelected.scriptType : chain.scriptType + const st = overrideScriptType + || ((isBtc && btcSelected) ? btcSelected.scriptType + : utxoScripts ? utxoScriptType + : chain.scriptType) if (st) params.scriptType = st if (isTon) params.bounceable = overrideBounceable ?? tonBounceable const result = await rpcRequest(chain.rpcMethod, params, 60000) @@ -225,12 +272,19 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi setAddress(null) } setLoading(false) - }, [chain, effectivePath, isBtc, btcSelected, isTon, tonBounceable]) + }, [chain, effectivePath, isBtc, btcSelected, isTon, tonBounceable, watchOnly, balance?.address, utxoScripts, utxoScriptType]) // Re-derive address when BTC xpub selection or change/index changes // Cancellation guard prevents stale responses from overwriting current address (Finding 5) useEffect(() => { if (!isBtc || !btcSelected) return + // Watch-only: no device — show the cached BTC address; can't derive per-index offline. + if (watchOnly) { + setAddress(balance?.address || null) + setDeriveError(balance?.address ? null : 'Address unavailable offline') + setLoading(false) + return + } let cancelled = false const path = btcSelected.fullPath ;(async () => { @@ -302,6 +356,9 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi // next non-empty push reseeds it with device B's address. useEffect(() => { if (!isEvm) return + // Watch-only: balance.address is the source of truth; the evmAddresses hook is + // device-backed and stays empty offline, which would wipe the cached address. + if (watchOnly) return if (evmAddresses.addresses.length === 0) { setAddress(null); return } const selected = evmAddresses.addresses.find(a => a.addressIndex === evmAddresses.selectedIndex) if (selected) { @@ -312,7 +369,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi setAddress(selected.address) setCurrentPath([0x8000002C, 0x8000003C, 0x80000000, 0, selected.addressIndex]) } - }, [isEvm, evmAddresses.selectedIndex, evmAddresses.addresses]) + }, [isEvm, evmAddresses.selectedIndex, evmAddresses.addresses, watchOnly]) // Auto-derive once on mount; TON always re-derives to ensure correct bounceable flag; // UTXO chains always re-derive because balance.address may be empty (xpub is not an address) @@ -324,21 +381,67 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi if (isTon || isUtxo || (!address && !deriveError)) deriveAddress() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // Fetch xpub/zpub for non-BTC UTXO chains (Litecoin, DASH, DOGE, BCH) + // UTXO altcoin multi-account (LTC/DOGE/DASH/…): tracked accounts come from + // the device-scoped pubkey cache (addUtxoAccount / audit "track"); the + // selector switches the receive path to m/purpose'/coin'/N'/0/0, with the + // purpose taken from the selected script type when the chain has a picker. + const isAltUtxo = isUtxo && !isBtc + const [utxoAccounts, setUtxoAccounts] = useState([0]) + const [utxoAccount, setUtxoAccount] = useState(0) + const [utxoAdding, setUtxoAdding] = useState(false) + const utxoPath = useCallback((n: number, st?: string) => { + const p = [...chain.defaultPath] + const purpose = utxoScripts?.find(s => s.scriptType === (st || utxoScriptType))?.purpose + if (purpose != null) p[0] = (0x80000000 + purpose) >>> 0 + p[2] = (0x80000000 + n) >>> 0 + return p + }, [chain.defaultPath, utxoScripts, utxoScriptType]) + useEffect(() => { + if (!isAltUtxo || watchOnly) return + rpcRequest<{ accounts: number[] }>('getUtxoAccounts', { chainId: chain.id }, 15000) + .then(res => { if (res?.accounts?.length) setUtxoAccounts(res.accounts) }) + .catch(e => console.warn(`[AssetPage] ${chain.coin} getUtxoAccounts failed:`, e)) + }, [isAltUtxo, watchOnly, chain.id, chain.coin]) + const selectUtxoAccount = useCallback((n: number) => { + setUtxoAccount(n) + deriveAddress(utxoPath(n)) + }, [deriveAddress, utxoPath]) + const selectUtxoScript = useCallback((st: string) => { + setUtxoScriptType(st) + deriveAddress(utxoPath(utxoAccount, st), undefined, st) + }, [deriveAddress, utxoPath, utxoAccount]) + const addUtxoAccount = useCallback(async () => { + setUtxoAdding(true) + try { + const next = Math.max(...utxoAccounts) + 1 + await rpcRequest('addUtxoAccount', { chainId: chain.id, level: next }, 60000) + setUtxoAccounts(prev => [...new Set([...prev, next])].sort((a, b) => a - b)) + setUtxoAccount(next) + deriveAddress(utxoPath(next)) + } catch (e: any) { + console.error(`[AssetPage] addUtxoAccount ${chain.coin}:`, e) + setDeriveError(e.message || 'Could not add account') + } finally { + setUtxoAdding(false) + } + }, [utxoAccounts, chain.id, chain.coin, deriveAddress, utxoPath]) + + // Fetch the account xpub for non-BTC UTXO chains (Litecoin, DASH, DOGE, BCH) + // — encoding follows the selected script type (xpub/Ltub vs Mtub vs zpub). const [utxoXpub, setUtxoXpub] = useState(null) useEffect(() => { - if (!isUtxo || isBtc) return + if (!isAltUtxo) return rpcRequest>('getPublicKeys', { paths: [{ - addressNList: chain.defaultPath.slice(0, 3), + addressNList: utxoPath(utxoAccount).slice(0, 3), coin: chain.coin, - scriptType: chain.scriptType, + scriptType: utxoScripts ? utxoScriptType : chain.scriptType, curve: 'secp256k1', }], }, 30000) .then(result => { if (result?.[0]?.xpub) setUtxoXpub(result[0].xpub) }) .catch(e => console.warn(`[AssetPage] ${chain.coin} xpub fetch failed:`, e)) - }, [isUtxo, isBtc, chain.coin, chain.scriptType, chain.defaultPath]) + }, [isAltUtxo, chain.coin, chain.scriptType, utxoPath, utxoAccount, utxoScripts, utxoScriptType]) // ── Token spam filter ────────────────────────────────────────────── const tokens = useMemo(() => activeBalance?.tokens || [], [activeBalance?.tokens]) @@ -393,32 +496,11 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi const [showNameReg, setShowNameRegRaw] = useState(false) const setShowNameReg = (open: boolean) => { if (open && watchOnly) return; setShowNameRegRaw(open) } - // Scoped Pioneer push subscription: only refresh while this page is mounted, - // and only when the event chain matches this asset or the active swap output. - const [swapOutputChainId, setSwapOutputChainId] = useState(null) - useEffect(() => { - return onRpcMessage("tx-push-received", (payload: { chain?: string; txid?: string }) => { - if (!payload.chain && !payload.txid) return // truly empty pings are not actionable - if (payload.chain) { - // Chain-scoped push: only refresh if it matches this asset or the active swap output. - // payload.chain is CAIP-19 (e.g. "eip155:1/slip44:60") — match against exact caip or - // networkId with trailing slash to avoid eip155:1 matching eip155:10 (Optimism). - const matches = payload.chain === chain.caip || payload.chain.startsWith(`${chain.networkId}/`) - const outputDef = swapOutputChainId ? CHAINS.find(c => c.id === swapOutputChainId) : null - const matchesOutput = outputDef - ? (payload.chain === outputDef.caip || payload.chain.startsWith(`${outputDef.networkId}/`)) - : false - if (!matches && !matchesOutput) return - // Output-chain match without current-chain match: refresh the output chain, - // not this page's chain (handleRefresh only fetches chain.id). - if (matchesOutput && !matches) { - rpcFire('getBalance', { chainId: swapOutputChainId! }) - return - } - } - handleRefresh() - }) - }, [handleRefresh, chain.caip, chain.networkId, swapOutputChainId]) + // NOTE: no scoped push subscription here. App.tsx's always-mounted + // 'tx-push-received' listener already force-resyncs whatever chain a push + // matches (this page's chain, a swap output, custom chains) and the backend's + // 'balance-updated' push flows back into this page via the `balance` prop — + // a second subscription here just doubled the forced Pioneer fetch. // Activity preview const [previewActivities, setPreviewActivities] = useState([]) @@ -822,10 +904,18 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi {/* Sync status — design study: replaces the redundant chain symbol that used to sit here. Single source of sync truth (the duplicate badge on the right column was removed below). */} - {activeBalance ? ( - + {refreshError ? ( + + + {t("refreshFailed")} + + ) : activeBalance ? ( + - {t("synced")} + + {t("synced")}{lastSyncedAt ? ` · ${lastSyncedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''} + ) : ( @@ -939,7 +1029,9 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi )} - )} - + } {/* Mobile-only balance row */} @@ -982,8 +1074,8 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi ) : activeBalance && ( <> - - {activeBalance.balance} + + {formatBalance(activeBalance.balance)} {chain.symbol} {cleanBalanceUsd > 0 && ( @@ -1015,7 +1107,10 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi one address; empty keeps the pills centered when there isn't. */} - {isBtc && btcAccounts.accounts.length > 0 && ( + {/* Account selectors call device/backend account RPCs and mix live + wallet account state with the cached receive address — hidden + in watch-only. */} + {!watchOnly && isBtc && btcAccounts.accounts.length > 0 && ( )} - {isEvm && evmAddresses.addresses.length >= 1 && ( + {!watchOnly && !isHiddenWallet && isAltUtxo && ( + + )} + {!watchOnly && isEvm && evmAddresses.addresses.length >= 1 && ( }> + ) : isHive ? ( + ) : ( { setTonBounceable(v); deriveAddress(undefined, v) }} + watchOnly={watchOnly} /> )} @@ -1357,7 +1468,6 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi nativeBalanceUsd: btcSelected.xpubData.balanceUsd, } : activeBalance} address={address} - onOutputAssetChange={setSwapOutputChainId} initialFromAsset={initialFromAsset} initialFromCaip={ selectedToken && parseFloat(selectedToken.balance) > 0 diff --git a/projects/keepkey-vault/src/mainview/components/AuditCustomPath.tsx b/projects/keepkey-vault/src/mainview/components/AuditCustomPath.tsx index 4d7d2b48..9fb60685 100644 --- a/projects/keepkey-vault/src/mainview/components/AuditCustomPath.tsx +++ b/projects/keepkey-vault/src/mainview/components/AuditCustomPath.tsx @@ -90,7 +90,7 @@ export function AuditCustomPath({ chainId, family, defaultPath, scriptType, onRe try { const r = await rpcRequest('auditDeriveCustom', { chainId, addressNList: path, scriptType: family === 'utxo' ? script : undefined, - }, 120000) + }, 900000) onResult(r) } catch (e: any) { setErr(e.message) diff --git a/projects/keepkey-vault/src/mainview/components/AuditDialog.tsx b/projects/keepkey-vault/src/mainview/components/AuditDialog.tsx index d533151d..1863fde2 100644 --- a/projects/keepkey-vault/src/mainview/components/AuditDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AuditDialog.tsx @@ -23,12 +23,43 @@ import { ChainLogo } from "./ChainLogo" import { getAssetIcon } from "../../shared/assetLookup" import { AuditCustomPath } from "./AuditCustomPath" import { AuditKnownPaths } from "./AuditKnownPaths" +import { AuditSweepButton } from "./AuditSweepButton" import { AuditBtcDeep } from "./AuditBtcDeep" import { AuditInspector } from "./AuditInspector" import type { ChainDef } from "../../shared/chains" import type { AuditReport, AuditPortfolioSnapshot, AuditMode, AuditChainFinding, AuditDerivedAddress } from "../../shared/types" const SUPPORT_URL = "https://support.keepkey.com" + +/** Clipboard write that actually reports failure: async API first, hidden + * textarea + execCommand as the WKWebView fallback. */ +async function copyTextRobust(text: string): Promise { + try { await navigator.clipboard.writeText(text); return true } catch { /* fall through */ } + try { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand('copy') + document.body.removeChild(ta) + return ok + } catch { return false } +} + +/** base64url-encode the audit payload for the support ?audit= GET param. */ +function encodeAuditParam(payload: unknown): string { + const json = JSON.stringify(payload) + const b64 = btoa(unescape(encodeURIComponent(json))) + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function dedupXpubs(xpubs: Array<{ scriptType: string; xpub: string; pathStr: string }>) { + const seen = new Set() + return xpubs.filter(x => x.xpub && !seen.has(x.xpub) && seen.add(x.xpub)) +} + const ANIM = ` @keyframes auditPulse { 0%,100% { box-shadow: 0 0 0 2px rgba(233,196,106,0.55); } 50% { box-shadow: 0 0 0 6px rgba(233,196,106,0.10); } } @keyframes auditGlow { 0%,100% { box-shadow: 0 0 14px rgba(233,196,106,0.18); } 50% { box-shadow: 0 0 34px rgba(233,196,106,0.45); } } @@ -284,15 +315,15 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd // All UTXO chains (incl. Bitcoin) use the xpub-based per-account scan. const utxoAccountScannable = !!currentDef && currentDef.chainFamily === 'utxo' const accountScannable = levelScannable || utxoAccountScannable - const hasCommon = current?.family === 'evm' || current?.chainId === 'bitcoin' + const hasCommon = current?.family === 'evm' || current?.chainId === 'bitcoin' || current?.chainId === 'litecoin' const runScan = useCallback(async (chain: AuditChainFinding, fromLevel: number, count: number, markAuto: boolean) => { patchLadder(chain.chainId, markAuto ? { autoScanning: true, autoScanned: true, scanErr: null } : { scanning: true, scanErr: null }) try { // UTXO altcoins: xpub-based per-account scan; everything else: single-address level scan. const { results } = chain.family === 'utxo' - ? await rpcRequest<{ results: AuditDerivedAddress[] }>('auditScanUtxoAccounts', { chainId: chain.chainId, fromLevel, count }, 180000) - : await rpcRequest<{ results: AuditDerivedAddress[] }>('auditScanLevels', { chainId: chain.chainId, fromLevel, count }, 180000) + ? await rpcRequest<{ results: AuditDerivedAddress[] }>('auditScanUtxoAccounts', { chainId: chain.chainId, fromLevel, count }, 900000) + : await rpcRequest<{ results: AuditDerivedAddress[] }>('auditScanLevels', { chainId: chain.chainId, fromLevel, count }, 900000) setLadders(prev => { const cur = prev[chain.chainId] || EMPTY_LADDER // Dedupe by account/index level so a re-run (or a double effect) never @@ -347,10 +378,16 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd while (cur < level) { set = await rpcRequest('addBtcAccount', undefined, 60000); cur = set.accounts.length ? Math.max(...set.accounts.map((a: any) => a.accountIndex)) : cur + 1 } } else if (chain.family === 'evm') { await rpcRequest('addEvmAddressIndex', { index: level }, 60000) + } else if (chain.family === 'utxo') { + // Non-BTC UTXO altcoins (LTC/DOGE/DASH/…): persist the account's xpubs to + // the device-scoped pubkey cache so they show + spend from now on. Blocked + // for hidden wallets upstream (canTrack), so this never runs there. + await rpcRequest('addUtxoAccount', { chainId: chain.chainId, level }, 60000) } // Honest persistence promise: EVM indices are written to disk (show from - // now on); BTC accounts are NOT persisted yet (session only); hidden-wallet - // indices are never persisted by design. + // now on); UTXO altcoin accounts are written to the device-scoped pubkey + // cache (show from now on); BTC accounts are NOT persisted yet (session + // only); hidden-wallet state is never persisted by design. const recovered = isHidden ? `Tracking for this session.` : chain.chainId === 'bitcoin' @@ -361,7 +398,8 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd } catch (e: any) { patchLadder(chain.chainId, { recovering: false, recoverErr: e.message }) } }, [isHidden, onRecovered, patchLadder]) - const copyHandoff = useCallback((chain: AuditChainFinding, l: Ladder, balanceUsd: number) => { + const buildHandoff = useCallback((chain: AuditChainFinding, l: Ladder, balanceUsd: number) => { + const all = [...l.scanned, ...l.customResults, ...l.extraFound] const lines: string[] = [] lines.push('KeepKey Vault — Balance Audit handoff') lines.push(`Time: ${new Date().toISOString()}`) @@ -371,7 +409,6 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd lines.push('') lines.push('Addresses checked:') if (isHidden) lines.push(' [hidden wallet — addresses redacted]') - const all = [...l.scanned, ...l.customResults, ...l.extraFound] for (const a of all) { const bal = a.balanceError ? '(unverified — balance check failed)' : `${a.native} ${a.symbol}${a.hasBalance ? ' (FUNDED)' : ''}` lines.push(isHidden ? ` ${a.pathStr}: ${bal}` : ` ${a.pathStr} ${a.address} ${bal}`) @@ -380,10 +417,53 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd if (!all.length) lines.push(' (none beyond the default address)') lines.push('') lines.push(`Support: ${SUPPORT_URL}`) - navigator.clipboard.writeText(lines.join('\n')).catch(() => {}) - openUrl(SUPPORT_URL) + + // Structured payload for support.keepkey.com — carried in the ?audit= GET + // param so the ticket auto-includes what was checked (see + // docs/handoff-support-audit-get-param.md). Hidden wallets: no addresses, + // no xpubs — path + balance shape only, same redaction as the text report. + const xpubs = isHidden ? [] : dedupXpubs(all.flatMap(a => a.xpubs || [])) + const payload = { + v: 1, + time: new Date().toISOString(), + chainId: chain.chainId, + symbol: chain.symbol, + dashboardUsd: Number(balanceUsd.toFixed(2)), + expected: l.handoffNote.trim() || undefined, + hidden: isHidden || undefined, + results: all.map(a => ({ + path: a.pathStr, + address: isHidden ? undefined : a.address, + balance: a.native, + symbol: a.symbol, + funded: a.hasBalance || undefined, + error: a.balanceError || undefined, + tokens: a.tokens?.length ? a.tokens.map(t => ({ symbol: t.symbol, balance: t.balance, usd: t.balanceUsd })) : undefined, + })), + xpubs, + } + let chunk = encodeAuditParam(payload) + if (chunk.length > 7000) { + // URL-length guard: drop empty rows first, then tokens. + chunk = encodeAuditParam({ + ...payload, + results: payload.results.filter(r => r.funded || r.error).map(r => ({ ...r, tokens: undefined })), + truncated: true, + } as any) + } + return { text: lines.join('\n'), url: `${SUPPORT_URL}/?audit=${chunk}` } }, [isHidden]) + const [handoffCopied, setHandoffCopied] = useState(false) + const copyHandoff = useCallback(async (chain: AuditChainFinding, l: Ladder, balanceUsd: number, openSupport: boolean) => { + const { text, url } = buildHandoff(chain, l, balanceUsd) + // Copy FIRST and await it — opening the browser steals focus, and a + // focus-less clipboard write rejects (the old silent-failure bug). + const ok = await copyTextRobust(`${text}\n${url}`) + if (ok) { setHandoffCopied(true); setTimeout(() => setHandoffCopied(false), 2000) } + if (openSupport) openUrl(url) + }, [buildHandoff]) + // Navigation const goWalk = useCallback(() => { setPhase('walkthrough'); scrollTop() }, [scrollTop]) const goChain = useCallback((i: number) => { setChainIdx(i); scrollTop() }, [scrollTop]) @@ -443,7 +523,9 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd ) const AddrRow = ({ chain, a, gold }: { chain: AuditChainFinding; a: AuditDerivedAddress; gold?: boolean }) => { - const canTrack = (chain.chainId === 'bitcoin' || chain.family === 'evm') && a.level != null + // UTXO altcoins persist via the device-scoped pubkey cache, which deliberately + // never holds hidden-wallet xpubs — so they're only trackable on the standard wallet. + const canTrack = (chain.chainId === 'bitcoin' || chain.family === 'evm' || (chain.family === 'utxo' && !isHidden)) && a.level != null const fundedNoTrack = a.hasBalance && !canTrack const hasXpubs = !!a.xpubs?.length return ( @@ -464,6 +546,13 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd {hasXpubs && } + {/* Address-level UTXO finds (uncommon path/scriptType combos, custom + paths) can't be spent from the Send page — sweep them to the + standard receive address right here. sweepable === false marks a + standard-scheme row (the sweep destination) — never offer those. */} + {a.hasBalance && chain.family === 'utxo' && !!a.addressNList?.length && a.sweepable !== false && ( + + )} {a.tokens && a.tokens.length > 0 && ( {a.tokens.map((t, i) => ( @@ -700,7 +789,7 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd )} {/* common (other-wallet) paths */} - {ladder.showCommon && current.family === 'evm' && pushExtra(current.chainId, r)} onOpenUrl={openUrl} />} + {ladder.showCommon && (current.family === 'evm' || current.chainId === 'litecoin') && pushExtra(current.chainId, r)} onOpenUrl={openUrl} />} {ladder.showCommon && current.chainId === 'bitcoin' && Deep Bitcoin scans onRecovered?.()} />} {/* custom + inspector */} @@ -716,7 +805,14 @@ export function AuditDialog({ onClose, snapshot, isHidden, chainCatalog, chainAd We’ll bundle everything we checked into a report you can hand to support.