From 2b191abc160c3e018310e3bbb66f4d86516ade43 Mon Sep 17 00:00:00 2001 From: Highlander Date: Mon, 20 Jul 2026 11:47:40 -0300 Subject: [PATCH 1/5] feat(hive): limit_order_create / limit_order_cancel clear-sign gate (#130) * docs(hive): handoff for vault limit_order_create/cancel serializer Firmware clear-signs both ops (PR #315). Vault has no serializer for them, so Hive internal-market swaps still fail at hive_broadcast. Doc pins the byte layout the firmware parser expects. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(hive): allow limit_order_create / limit_order_cancel Opens the client clear-sign gate now that the vault serializer exists (keepkey-vault #373) and the firmware parses both ops (keepkey-firmware #315). Adds an approval-screen summary line for each. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- HANDOFF_vault_hive_limit_order_serializer.md | 124 ++++++++++++++++++ .../src/background/chains/hiveHandler.ts | 6 + 2 files changed, 130 insertions(+) create mode 100644 HANDOFF_vault_hive_limit_order_serializer.md diff --git a/HANDOFF_vault_hive_limit_order_serializer.md b/HANDOFF_vault_hive_limit_order_serializer.md new file mode 100644 index 0000000..18e100f --- /dev/null +++ b/HANDOFF_vault_hive_limit_order_serializer.md @@ -0,0 +1,124 @@ +# HANDOFF — vault: Hive `limit_order_create` / `limit_order_cancel` serializer + +**Status:** DONE. Firmware = keepkey-firmware PR #315, vault = keepkey-vault PR +#373, client gate = the PR carrying this doc. All three must ship together; +until the vault PR merges, Hive internal-market swaps still fail. + +## The bug this closes + +A dApp swap on the Hive internal market fails in the browser extension: + +``` +| handleWalletRequest | Error processing method hive_broadcast: +Error: Operation not in the KeepKey clear-sign table (got limit_order_create) +``` + +Three layers had to know the op. Two are now done: + +| layer | file | state | +|---|---|---| +| Client gate | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/hiveHandler.ts` (`SUPPORTED_OPS`) | ✅ this PR | +| Vault serializer | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/txbuilder/hive-ops.ts` | ✅ keepkey-vault PR #373 | +| Firmware clear-sign | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-firmware-consolidated/lib/firmware/hive.c` | ✅ PR #315 | + +## What the firmware now accepts + +The parser is authoritative — match it byte-for-byte or the device rejects. + +### `limit_order_create` (op id 5), **active** tier + +``` +varint(5) +str(owner) 1..16 bytes +u32le(orderid) +asset(amount_to_sell) HIVE or HBD, must be > 0 +asset(min_to_receive) HIVE or HBD, must be > 0, symbol MUST differ from amount_to_sell +u8(fill_or_kill) exactly 0 or 1 — any other byte is rejected +u32le(expiration) unix seconds +``` + +### `limit_order_cancel` (op id 6), **active** tier + +``` +varint(6) +str(owner) 1..16 bytes +u32le(orderid) +``` + +### Asset encoding (already implemented — reuse `asset()` in `hive-ops.ts`) + +16 bytes: `int64le(amount)` + `u8(precision)` + 7-byte NUL-padded symbol. +HIVE/HBD precision 3, VESTS precision 6. Firmware rejects a symbol/precision +mismatch and rejects negative amounts. + +## Firmware-side rejections to mirror host-side (fail fast with a better message) + +- same symbol on both sides of the order → `"Hive tx: order symbols must differ"` +- either amount zero → `"Hive tx: amount must be greater than zero"` +- `fill_or_kill` not 0/1 → `"Hive tx: malformed operation"` +- mixing these (active tier) with posting-tier ops in one tx → `"Hive tx: mixed posting/active ops"` + +## Suggested implementation + +In `hive-ops.ts`, add to the `serializeOp` switch alongside the existing ops: + +```ts +const OP_LIMIT_ORDER_CREATE = 5 +const OP_LIMIT_ORDER_CANCEL = 6 + +case 'limit_order_create': { + const sell = positiveAsset(p.amount_to_sell, ['HIVE', 'HBD'], 'limit_order_create amount_to_sell') + const recv = positiveAsset(p.min_to_receive, ['HIVE', 'HBD'], 'limit_order_create min_to_receive') + // firmware refuses a same-symbol pair; reject here for a clearer error + if (sell.subarray(9, 16).equals(recv.subarray(9, 16))) { + throw new Error('limit_order_create: sell and receive symbols must differ') + } + return { + bytes: Buffer.concat([ + varint(OP_LIMIT_ORDER_CREATE), + str(p.owner), + u32(Number(p.orderid), 'limit_order_create orderid'), + sell, recv, + boolByte(p.fill_or_kill, 'limit_order_create fill_or_kill'), + u32(Number(p.expiration), 'limit_order_create expiration'), + ]), + tier: 'active', + } +} + +case 'limit_order_cancel': + return { + bytes: Buffer.concat([ + varint(OP_LIMIT_ORDER_CANCEL), + str(p.owner), + u32(Number(p.orderid), 'limit_order_cancel orderid'), + ]), + tier: 'active', + } +``` + +Then add both names to `SUPPORTED_OPS` in `hiveHandler.ts` and give each an +`opSummary()` line for the side-panel approval. + +## Gotchas + +- `expiration` is a unix timestamp. The device has **no RTC** and cannot + sanity-check it — the host is the only place this can be bounded. hived + requires `expiration > now` and `<= now + 28 days`; enforce that host-side + or users will sign orders that the chain rejects. +- `orderid` is caller-chosen and must be unique per account among open orders. + Reusing a live id is rejected on-chain. +- These are **active**-tier ops. They cannot share a transaction with + posting-tier ops (vote, comment, comment_options, claim_reward_balance) — + one signature can't satisfy both post-HF28. Firmware enforces this too. + +## Verification + +Firmware unit tests covering this exact wire format are in +`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-firmware-consolidated/unittests/firmware/hive.cpp` +(`Hive.LimitOrderCreateRetainsEveryDisplayedField`, +`Hive.LimitOrderRejectsDegenerateOrders`, `Hive.LimitOrderCancelParses`). +Mirror those vectors in the vault's `hive-ops.test.ts` to confirm the two +serializers agree before touching a device. + +Requires firmware >= the release cut from PR #315. diff --git a/chrome-extension/src/background/chains/hiveHandler.ts b/chrome-extension/src/background/chains/hiveHandler.ts index 995a4a2..4fab254 100644 --- a/chrome-extension/src/background/chains/hiveHandler.ts +++ b/chrome-extension/src/background/chains/hiveHandler.ts @@ -580,6 +580,8 @@ const SUPPORTED_OPS = new Set([ 'claim_reward_balance', 'delegate_vesting_shares', 'account_update2', + 'limit_order_create', + 'limit_order_cancel', ]); /** Strict "x.xxx" normalization — same no-parseFloat rule as hiveTransfer. */ @@ -657,6 +659,10 @@ function opSummary(name: string, p: Record): string { : `Delegate ${p.vesting_shares} → @${p.delegatee}`; case 'account_update2': return `Update profile @${p.account}`; + case 'limit_order_create': + return `Sell ${p.amount_to_sell} for ${p.min_to_receive}${p.fill_or_kill ? ' (fill or kill)' : ''}`; + case 'limit_order_cancel': + return `Cancel order ${p.orderid} (@${p.owner})`; default: return name; } From 09e4ca897119a75d76b8122d57938bb6b13fee38 Mon Sep 17 00:00:00 2001 From: Highlander Date: Mon, 20 Jul 2026 17:08:41 -0300 Subject: [PATCH 2/5] feat(bex): every page-touching MCP tool shows UI in the page (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driving overlay fired only from showThen(), which has three callers — click, type and select. Every other tool that touches a page ran with no indication to the user at all: bex_snapshot, bex_find and bex_read_page read the DOM, bex_console/bex_network/bex_perf read page state, bex_storage reads localStorage, and bex_screenshot captures the tab. Reading and capturing a user's page are precisely the operations that most need to be visible. Enforce it at the dispatcher instead of per call site. executeBrowserTool() announces before the switch, so a tool added later is covered by construction rather than by remembering. Each tool is either in ANNOUNCE_CAPTIONS or in NO_ANNOUNCE with a stated reason; announceContract.test.ts fails the build on any tool that is in neither, so a silent tool cannot ship. Exempt, with reasons: bex_panel is the UI itself; bex_tabs touches no page; bex_navigate and bex_bring_to_front are self-evident (the user watches their own tab move); click/type/select are already announced page-side by showThen, which additionally points at the target. bex_screenshot now refuses a tab whose content script is missing. Reaching the content script is what proves the tab CAN show the indicator, and it is already required to hide the overlay out of the capture — so a capture that cannot be announced is no longer taken. The remedy is the page reload the no_content_script error already names. Banner lifetime: BANNER_IDLE_MS was 8s, so the "MCP is driving this tab" banner vanished during any quiet stretch — including while waiting on a hardware-wallet confirmation, the longest silence and the highest stakes in the whole flow. It now stays up until the agent says it is done (panel 'done' or hide), with a 5 minute safety net for an agent that dies mid-session. No new permissions. The overlay is plain DOM in the content script that is already declaratively injected on ; the scripting permission dropped in b3e3126 was only ever for executeScript into pre-existing tabs. --- .../src/background/announceContract.test.ts | 66 +++++++++++++++++++ .../src/background/browserTools.ts | 56 +++++++++++++++- pages/content/src/agentDom.ts | 18 ++++- pages/content/src/agentOverlay.ts | 53 ++++++++++++++- 4 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 chrome-extension/src/background/announceContract.test.ts diff --git a/chrome-extension/src/background/announceContract.test.ts b/chrome-extension/src/background/announceContract.test.ts new file mode 100644 index 0000000..cfa8826 --- /dev/null +++ b/chrome-extension/src/background/announceContract.test.ts @@ -0,0 +1,66 @@ +/** + * The transparency contract, enforced. + * + * Rule: if an MCP tool touches a web page, that page shows the user it is + * happening. The overlay used to fire only from showThen() (click/type/select), + * so every read — snapshot, read_page, console, network, storage, screenshot — + * ran invisibly. That is the class of bug this file exists to prevent coming + * back. + * + * A new browser tool must land in ANNOUNCE_CAPTIONS or NO_ANNOUNCE. There is no + * third option and no default, so "I forgot the overlay" fails the build + * instead of shipping a silent tool. + */ +import { describe, it, expect } from 'vitest'; +import { BROWSER_TOOLS, ANNOUNCE_CAPTIONS, NO_ANNOUNCE } from './browserTools'; + +const toolNames = () => BROWSER_TOOLS.map(t => t.name); + +describe('MCP transparency contract', () => { + it('classifies every browser tool as announced or explicitly exempt', () => { + const unclassified = toolNames().filter(n => !(n in ANNOUNCE_CAPTIONS) && !(n in NO_ANNOUNCE)); + expect( + unclassified, + `Unclassified browser tool(s): ${unclassified.join(', ')}. ` + + 'Every tool must either announce itself in the page (add a caption to ' + + 'ANNOUNCE_CAPTIONS) or state why it need not (add it to NO_ANNOUNCE). ' + + 'If it reads or captures page content, it announces.', + ).toEqual([]); + }); + + it('never classifies a tool as both', () => { + const both = toolNames().filter(n => n in ANNOUNCE_CAPTIONS && n in NO_ANNOUNCE); + expect(both).toEqual([]); + }); + + it('does not classify tools that no longer exist', () => { + const known = new Set(toolNames()); + const stale = [...Object.keys(ANNOUNCE_CAPTIONS), ...Object.keys(NO_ANNOUNCE)].filter(n => !known.has(n)); + expect(stale, `Stale entries for removed tool(s): ${stale.join(', ')}`).toEqual([]); + }); + + it('announces every tool that reads or captures page content', () => { + // Pinned by name on purpose. These are the operations a user cannot + // otherwise perceive, so moving one into NO_ANNOUNCE has to be a deliberate + // edit to this list with a reviewer looking at it. + const mustAnnounce = [ + 'bex_snapshot', + 'bex_find', + 'bex_read_page', + 'bex_console', + 'bex_network', + 'bex_perf', + 'bex_storage', + 'bex_screenshot', + ]; + for (const name of mustAnnounce) { + expect(ANNOUNCE_CAPTIONS[name], `${name} reads page content and must announce`).toBeTruthy(); + } + }); + + it('gives every exemption a stated reason', () => { + for (const [name, reason] of Object.entries(NO_ANNOUNCE)) { + expect(reason.length, `${name} is exempt without saying why`).toBeGreaterThan(10); + } + }); +}); diff --git a/chrome-extension/src/background/browserTools.ts b/chrome-extension/src/background/browserTools.ts index ef7a81e..36073c6 100644 --- a/chrome-extension/src/background/browserTools.ts +++ b/chrome-extension/src/background/browserTools.ts @@ -365,9 +365,13 @@ async function screenshot(tab: chrome.tabs.Tab, quality: number): Promise {}); + // Hide the agent overlay so it doesn't land in the capture. This is also the + // transparency check: reaching the content script is what proves the tab CAN + // show the driving indicator. It cannot, we don't capture — a silent + // screenshot of a page the user has no indication we are reading is exactly + // what the contract forbids. (dom() throws no_content_script here; the + // remedy is the same page reload it already tells the user about.) + if (tab.id != null) await dom(tab.id, 'overlay', { show: false }); let dataUrl: string; try { dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId!, { format: 'jpeg', quality }); @@ -397,7 +401,53 @@ export function isBrowserTool(name: string): boolean { return BROWSER_TOOLS.some(t => t.name === name); } +/** + * The transparency contract: if MCP touches a page, the page says so. + * + * Caption shown in the page overlay before the tool runs. Every browser tool + * must appear HERE or in NO_ANNOUNCE — announceContract.test.ts fails the build + * otherwise, so a tool added later cannot silently become invisible. + */ +export const ANNOUNCE_CAPTIONS: Record = { + bex_snapshot: 'reading page structure', + bex_find: 'searching page', + bex_read_page: 'reading page text', + bex_console: 'reading console', + bex_network: 'reading network activity', + bex_perf: 'reading performance data', + bex_storage: 'reading local storage', + bex_screenshot: 'capturing screenshot', +}; + +/** Tools that need no announce, each with the reason it is exempt. */ +export const NO_ANNOUNCE: Record = { + bex_panel: 'is the transparency UI itself', + bex_tabs: 'browser-level; touches no page content', + bex_navigate: 'self-evident — the user watches their tab move', + bex_bring_to_front: 'self-evident — the user watches their window raise', + bex_click: 'announced page-side by showThen(), which also points at the target', + bex_type: 'announced page-side by showThen(), which also points at the target', + bex_select: 'announced page-side by showThen(), which also points at the target', +}; + +/** + * Raise the page overlay before a tool runs. Never throws: a tab that cannot + * show the indicator is handled by each tool (bex_screenshot refuses), not by + * failing every read here. + */ +async function announce(tool: string, args: any): Promise { + const caption = ANNOUNCE_CAPTIONS[tool]; + if (!caption) return; + try { + const tab = await resolveTab(args?.tabId); + if (tab.id != null) await dom(tab.id, 'announce', { kind: caption }); + } catch { + /* no content script, or the tab vanished — never block the tool on the UI */ + } +} + export async function executeBrowserTool(tool: string, args: any): Promise { + await announce(tool, args); switch (tool) { case 'bex_tabs': { const action = args?.action ?? 'list'; diff --git a/pages/content/src/agentDom.ts b/pages/content/src/agentDom.ts index 98b1712..552d9f3 100644 --- a/pages/content/src/agentDom.ts +++ b/pages/content/src/agentDom.ts @@ -21,7 +21,7 @@ import { getPageConsole } from './consoleBridge'; import { pullObs } from './obsBridge'; -import { overlayAct, overlaySetVisible } from './agentOverlay'; +import { overlayAct, overlayAnnounce, overlayEndSession, overlaySetVisible } from './agentOverlay'; import { panelShow, panelHide, panelLog, panelStatus } from './agentPanel'; const TAG = ' | agentDom | '; @@ -450,13 +450,25 @@ async function handle(msg: any): Promise { await overlaySetVisible(msg.show !== false); return { overlay: msg.show !== false }; + // The transparency floor: the background announces every page-touching tool + // here BEFORE running it, so reads and captures are as visible as clicks. + case 'announce': + overlayAnnounce(String(msg.kind ?? 'working'), String(msg.detail ?? '')); + panelLog(msg.detail ? `${msg.kind}: ${msg.detail}` : String(msg.kind ?? 'working')); + return { announced: msg.kind ?? 'working' }; + case 'panel': { if (msg.action === 'hide') { + overlayEndSession(); panelHide(); return { panel: 'hidden' }; } - if (msg.message != null || msg.level) panelStatus(String(msg.message ?? ''), msg.level ?? 'info'); - else panelShow(); + if (msg.message != null || msg.level) { + panelStatus(String(msg.message ?? ''), msg.level ?? 'info'); + // 'done' is the agent saying it has stopped driving — drop the banner + // now rather than leaving it up until the safety timer expires. + if (msg.level === 'done') overlayEndSession(); + } else panelShow(); return { panel: 'shown' }; } diff --git a/pages/content/src/agentOverlay.ts b/pages/content/src/agentOverlay.ts index d670ec4..c5a7a9d 100644 --- a/pages/content/src/agentOverlay.ts +++ b/pages/content/src/agentOverlay.ts @@ -15,7 +15,17 @@ const ROOT_ID = '__bex_agent_overlay'; const Z = 2147483647; // max z-index — sit above everything the page draws const HIGHLIGHT_MS = 1100; // how long the box + label linger after an action -const BANNER_IDLE_MS = 8000; // hide the "driving" banner after this much quiet +/** + * Safety net only — NOT the normal way the banner goes away. + * + * The banner must stay up for the whole driving session: a hardware-wallet + * confirmation routinely leaves the tab quiet for far longer than a few + * seconds, and that silence is exactly when the user most needs to see that an + * agent is in control. It is cleared explicitly by overlayEndSession() (the + * panel's 'done'/hide). This timer only covers an agent that dies mid-session + * and never sends one. + */ +const BANNER_IDLE_MS = 300_000; const GOLD = '#d29929'; // brand token let root: HTMLElement | null = null; @@ -115,6 +125,47 @@ function showBanner(): void { }, BANNER_IDLE_MS); } +/** + * Announce a page-touching action that has no target element to point at — + * reading the DOM, capturing a screenshot, pulling console/network/storage. + * + * These are the operations a user cannot otherwise perceive at all, so this is + * the floor of the transparency contract: the banner goes up and the caption + * names what is being done. overlayAct() is the richer treatment for actions + * that DO have a target. + * + * Best-effort, never throws — a broken overlay must not break a wallet action. + */ +export function overlayAnnounce(kind: string, detail = ''): void { + try { + if (!ensureRoot() || !label) return; + showBanner(); + // Park the caption top-left, under the banner: there is no element to anchor + // it to, and it must not imply one. + Object.assign(label.style, { left: '8px', top: '34px', opacity: '1' }); + label.textContent = detail ? `${kind}: ${detail}` : kind; + clearTimeout(fadeTimer); + fadeTimer = setTimeout(() => { + if (label) label.style.opacity = '0'; + }, HIGHLIGHT_MS); + } catch { + /* overlay must never break an action */ + } +} + +/** The agent is done driving: drop the banner instead of waiting out the safety timer. */ +export function overlayEndSession(): void { + try { + clearTimeout(bannerTimer); + if (banner) banner.style.display = 'none'; + if (cursor) cursor.style.opacity = '0'; + if (box) box.style.opacity = '0'; + if (label) label.style.opacity = '0'; + } catch { + /* ignore */ + } +} + /** Point at + outline `target` and caption the action. Best-effort, never throws. */ export function overlayAct(kind: string, target: Element, detail: string): void { try { From e2a0e57ff7d88b08e80ab1b1bdae81e08d66f572 Mon Sep 17 00:00:00 2001 From: Highlander Date: Mon, 20 Jul 2026 18:19:08 -0300 Subject: [PATCH 3/5] fix(hive): render op summaries in the approval card (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(hive): render op summaries in the approval card hiveHandler built a one-line summary per operation (opSummary) into unsignedTx.operations and no component ever read it. Grepping pages/side-panel/src/approval for `.operations` returned zero hits, so a Hive batch fell through to the generic amount table, which has no destination or amount to show for an op batch and rendered "N/A" and "0". The summaries surfaced only inside RequestDataCard's collapsed raw-JSON dump. That matters most for the four ops the extension does not construct itself — claim_reward_balance, account_update2, limit_order_create and limit_order_cancel reach the device via requestBroadcast, so a dApp composes them and the panel was the user's only look at them before the device screen. The OLED is authoritative and was always correct, so this was never a signing hole; but the clear-sign table exists so the user can compare the screen against what the dApp asked for, and half of that comparison was an unreadable blob. RequestDetailsCard now renders the account plus one row per op, falling back to the op name rather than blanking a row — an unsummarized op must stay visible, not disappear. SUPPORTED_OPS and opSummary move to hiveOps.ts, a leaf module. hiveHandler.ts imports @extension/storage, which touches chrome.* at import time and throws under vitest ("chrome is not defined"), so the table was untestable where it lived. No logic changed in the move. hiveOpSummary.test.ts pins the contract the card depends on: every op in SUPPORTED_OPS must summarize to something other than its own bare name (opSummary's default arm returns `name`, which is exactly the unreadable render this fixes) and must carry a test payload. It also pins the details a user has to check against the OLED — amounts with their symbols, the counterparty account, the '0.000000 VESTS' sentinels that mean stop/remove rather than "send zero", and comment_options beneficiary names and percentages, since a payout redirect is the one thing in the table an attacker would most want unreadable. Differential-verified: deleting the limit_order_create case fails with "limit_order_create falls through to the default arm — add a case to opSummary()". 121 tests pass, up from 116. type-check clean across all 15 packages. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(hive): show the signed payload, not a lossy render of it Review of #131 found three ways the approval could differ from what the device signs. custom_json rendered String(p.json). The vault serializes `typeof json === 'string' ? json : JSON.stringify(json ?? {})` (hive-ops.ts:151), so a dApp passing an object got "[object Object]" in the panel while the object's real contents went to the device — the approval showed neither the value nor that it was hiding one. Mirror the vault's own expression instead. Truncation at 120 chars was unmarked, so two payloads sharing a prefix rendered as the same string. The cut now carries the dropped length. comment_options showed only the post and its beneficiaries, omitting max_accepted_payout, percent_hbd, allow_votes and allow_curation_rewards. A declined payout, an all-HIVE split and a votes-disabled post therefore had identical browser approvals. Every control that differs from the Hive default is now named; defaults stay quiet so the common case remains a one-liner. percent_steem_dollars is read as the alias the vault also accepts (hive-ops.ts:225) — ignoring it would have shown the default while a non-default value was signed. unsignedTx.operations kept only {op, summary}, so the Raw tab could not recover what the summary elides. It now carries `params` verbatim. RequestDataCard renders transaction.unsignedTx and nothing else — the event's own `request` is never displayed in any tab — so this is the only surface on which the operation body appears at all. The approval also opened on the Raw tab (defaultIndex={1}), whose data section is useState(false) and so renders a collapsed chevron with no transaction facts on it. A user could approve having seen nothing. Basic is now the default. This affects all five chains routed to OtherTransaction (ripple, solana, ton, tron, hive), and is an improvement or a wash for each: Hive, Ripple and Tron contract-calls gain a populated table, and the chains that render N/A in Basic were previously landing on an empty panel anyway, so the cost is one click to reach Raw and no fact became less visible. Differential-verified: restoring String(p.json) fails with "expected 'follow: [object Object]' not to contain '[object Object]'"; dropping the payout controls fails with "expected 'Payout options for @alice/a-post' to contain '0.000 HBD'". 125 tests pass, up from 121. type-check, prettier and build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/background/chains/hiveHandler.ts | 72 +------- .../background/chains/hiveOpSummary.test.ts | 159 ++++++++++++++++++ .../src/background/chains/hiveOps.ts | 117 +++++++++++++ .../src/approval/other/RequestDetailsCard.tsx | 44 +++++ pages/side-panel/src/approval/other/index.tsx | 4 +- 5 files changed, 329 insertions(+), 67 deletions(-) create mode 100644 chrome-extension/src/background/chains/hiveOpSummary.test.ts create mode 100644 chrome-extension/src/background/chains/hiveOps.ts diff --git a/chrome-extension/src/background/chains/hiveHandler.ts b/chrome-extension/src/background/chains/hiveHandler.ts index 4fab254..b0b9203 100644 --- a/chrome-extension/src/background/chains/hiveHandler.ts +++ b/chrome-extension/src/background/chains/hiveHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as wallet from '../wallet'; import { createProviderRpcError, createTimeoutError } from '../utils'; import { requireHiveFirmware } from '../firmware'; +import { SUPPORTED_OPS, opSummary } from './hiveOps'; const TAG = ' | hiveHandler | '; @@ -563,27 +564,6 @@ async function hiveSignBuffer( return { result: signature, publicKey: public_key }; } -// Firmware clear-sign op table — phase 1 + phase 2 -// (handoff-hive-sign-operations-phase2.md). The vault serializer and the -// firmware both re-enforce this; the check here just fails fast with a -// clear dApp-facing error. -const SUPPORTED_OPS = new Set([ - 'vote', - 'comment', - 'custom_json', - 'transfer_to_vesting', - 'withdraw_vesting', - 'convert', - 'comment_options', - 'transfer_to_savings', - 'transfer_from_savings', - 'claim_reward_balance', - 'delegate_vesting_shares', - 'account_update2', - 'limit_order_create', - 'limit_order_cancel', -]); - /** Strict "x.xxx" normalization — same no-parseFloat rule as hiveTransfer. */ function normalizeAmount3(amount: any, what: string): string { if (typeof amount !== 'string' || !/^\d+(\.\d{1,3})?$/.test(amount)) { @@ -624,50 +604,6 @@ async function hpToVests(hp3: string): Promise { // conversions/withdrawals; per-account id tracking if a dApp ever collides. const epochRequestId = () => Math.floor(Date.now() / 1000); -/** One-line device-preview summary per op for the side-panel approval. */ -function opSummary(name: string, p: Record): string { - switch (name) { - case 'vote': - return `@${p.voter} → @${p.author}/${p.permlink} (${(Number(p.weight) / 100).toFixed(0)}%)`; - case 'comment': - return `@${p.author}: ${p.title || p.permlink}`; - case 'custom_json': - return `${p.id}: ${String(p.json).slice(0, 120)}`; - case 'transfer_to_vesting': - return `Power up ${p.amount} → @${p.to}`; - case 'withdraw_vesting': - return String(p.vesting_shares).startsWith('0.000000') - ? `Stop power down (@${p.account})` - : `Power down ${p.vesting_shares} from @${p.account}`; - case 'convert': - return `Convert ${p.amount} → HIVE (request ${p.requestid})`; - case 'comment_options': - return `Payout options for @${p.author}/${p.permlink}${ - (p.extensions?.[0]?.[1]?.beneficiaries ?? []) - .map((b: any) => ` · ${(Number(b.weight) / 100).toFixed(1)}% → @${b.account}`) - .join('') || '' - }`; - case 'transfer_to_savings': - return `Savings deposit ${p.amount} → @${p.to}`; - case 'transfer_from_savings': - return `Savings withdraw ${p.amount} → @${p.to}`; - case 'claim_reward_balance': - return `Claim ${p.reward_hive}, ${p.reward_hbd}, ${p.reward_vests}`; - case 'delegate_vesting_shares': - return String(p.vesting_shares).startsWith('0.000000') - ? `Remove delegation from @${p.delegatee}` - : `Delegate ${p.vesting_shares} → @${p.delegatee}`; - case 'account_update2': - return `Update profile @${p.account}`; - case 'limit_order_create': - return `Sell ${p.amount_to_sell} for ${p.min_to_receive}${p.fill_or_kill ? ' (fill or kill)' : ''}`; - case 'limit_order_cancel': - return `Cancel order ${p.orderid} (@${p.owner})`; - default: - return name; - } -} - /** * Shared path for vote/post/custom_json/broadcast: validate ops against the * firmware's phase-1 clear-sign table, approve, sign via the vault @@ -700,7 +636,11 @@ async function hiveSignAndBroadcastOps( const event = buildEvent(requestInfo, displayType, params); (event as any).unsignedTx = { from: from.name, - operations: operations.map(([name, p]) => ({ op: name, summary: opSummary(name, p) })), + // `params` verbatim so the Raw tab remains a complete record: the summary + // is a one-liner and necessarily elides (long custom_json, default payout + // controls), and a value no view can recover is a value the user cannot + // check against the device screen. + operations: operations.map(([name, p]) => ({ op: name, summary: opSummary(name, p), params: p })), }; await requestUserApproval(event, requestInfo, displayType, params, requireApproval); diff --git a/chrome-extension/src/background/chains/hiveOpSummary.test.ts b/chrome-extension/src/background/chains/hiveOpSummary.test.ts new file mode 100644 index 0000000..e182c51 --- /dev/null +++ b/chrome-extension/src/background/chains/hiveOpSummary.test.ts @@ -0,0 +1,159 @@ +/** + * Every clear-signable Hive op must render a readable summary. + * + * The side-panel approval card shows `unsignedTx.operations[].summary` and + * nothing else — a Hive tx has no single destination or amount to fall back + * on. opSummary()'s `default` arm returns the bare op name, so an op added to + * SUPPORTED_OPS without a matching `case` degrades the approval to + * "limit_order_create" with no values in it, silently. That is the exact + * unreadable-confirm this pairs with RequestDetailsCard to prevent. + */ +import { describe, it, expect } from 'vitest'; +import { SUPPORTED_OPS, opSummary } from './hiveOps'; + +// One realistic payload per op, in the vault serializer's field names. +const SAMPLES: Record> = { + vote: { voter: 'alice', author: 'bob', permlink: 'a-post', weight: 10000 }, + comment: { author: 'alice', permlink: 'a-post', title: 'Hello', body: 'hi', json_metadata: '{}' }, + custom_json: { required_auths: [], required_posting_auths: ['alice'], id: 'follow', json: '["follow",{}]' }, + // to !== from on purpose: a self-power-up would hide a from/to swap. + transfer_to_vesting: { from: 'alice', to: 'bob', amount: '1.500 HIVE' }, + withdraw_vesting: { account: 'alice', vesting_shares: '1000.000000 VESTS' }, + limit_order_create: { + owner: 'alice', + orderid: 1, + amount_to_sell: '1.500 HIVE', + min_to_receive: '0.400 HBD', + fill_or_kill: false, + expiration: 1700003600, + }, + limit_order_cancel: { owner: 'alice', orderid: 1 }, + convert: { owner: 'alice', requestid: 1, amount: '0.400 HBD' }, + comment_options: { + author: 'alice', + permlink: 'a-post', + max_accepted_payout: '1000000.000 HBD', + percent_hbd: 10000, + allow_votes: true, + allow_curation_rewards: true, + extensions: [], + }, + transfer_to_savings: { from: 'alice', to: 'bob', amount: '1.500 HIVE', memo: '' }, + transfer_from_savings: { from: 'alice', request_id: 1, to: 'bob', amount: '1.500 HIVE', memo: '' }, + claim_reward_balance: { + account: 'alice', + reward_hive: '1.500 HIVE', + reward_hbd: '0.400 HBD', + reward_vests: '1000.000000 VESTS', + }, + delegate_vesting_shares: { delegator: 'alice', delegatee: 'bob', vesting_shares: '1000.000000 VESTS' }, + account_update2: { account: 'alice', json_metadata: '', posting_json_metadata: '{}' }, +}; + +describe('Hive op summaries', () => { + it('has a sample payload for every supported op', () => { + const missing = [...SUPPORTED_OPS].filter(op => !(op in SAMPLES)); + expect(missing, `No test payload for: ${missing.join(', ')}`).toEqual([]); + }); + + it('summarizes every supported op with more than its own name', () => { + for (const op of SUPPORTED_OPS) { + const summary = opSummary(op, SAMPLES[op]); + // The `default` arm returns `name` verbatim. Anything equal to the op + // name means no case matched and the approval would render unreadably. + expect(summary, `${op} falls through to the default arm — add a case to opSummary()`).not.toBe(op); + expect(summary.length, `${op} summary is empty`).toBeGreaterThan(0); + } + }); + + it('renders the values a user must check against the device screen', () => { + // Amount + counterparty are what the OLED shows; the panel has to show the + // same thing or the comparison the clear-sign table exists for is impossible. + expect(opSummary('limit_order_create', SAMPLES.limit_order_create)).toContain('1.500 HIVE'); + expect(opSummary('limit_order_create', SAMPLES.limit_order_create)).toContain('0.400 HBD'); + expect(opSummary('transfer_to_savings', SAMPLES.transfer_to_savings)).toContain('bob'); + // Recipient, not sender — the vault serializes str(from), str(to) and a + // swapped pair would still render plausibly. + expect(opSummary('transfer_to_vesting', SAMPLES.transfer_to_vesting)).toContain('@bob'); + expect(opSummary('transfer_to_vesting', SAMPLES.transfer_to_vesting)).toContain('1.500 HIVE'); + expect(opSummary('claim_reward_balance', SAMPLES.claim_reward_balance)).toContain('1000.000000 VESTS'); + }); + + it('distinguishes the zero-amount sentinels from real amounts', () => { + // '0.000000 VESTS' means stop/remove, not "send zero" — a user approving + // these must not see the same wording as an actual power-down. + expect(opSummary('withdraw_vesting', { account: 'alice', vesting_shares: '0.000000 VESTS' })).toMatch(/stop/i); + expect( + opSummary('delegate_vesting_shares', { delegator: 'alice', delegatee: 'bob', vesting_shares: '0.000000 VESTS' }), + ).toMatch(/remove/i); + }); + + it('shows the JSON a custom_json actually signs, object or string', () => { + // The vault serializes `typeof json === 'string' ? json : JSON.stringify(json)` + // (hive-ops.ts:151). String(obj) would render "[object Object]" while the + // object's real contents get signed — approval showing neither. + const asObject = opSummary('custom_json', { id: 'follow', json: { follow: 'bob' } }); + expect(asObject).not.toContain('[object Object]'); + expect(asObject).toContain('"follow":"bob"'); + + const asString = opSummary('custom_json', { id: 'follow', json: '["follow",{"a":1}]' }); + expect(asString).toContain('["follow",{"a":1}]'); + }); + + it('marks a truncated custom_json so two payloads cannot look identical', () => { + const prefix = 'x'.repeat(120); + const a = opSummary('custom_json', { id: 'test', json: prefix + 'AAAA' }); + const b = opSummary('custom_json', { id: 'test', json: prefix + 'BBBBBBBB' }); + expect(a).toContain('…'); + expect(a).not.toBe(b); + expect(a).toContain('+4 more chars'); + expect(b).toContain('+8 more chars'); + // Short payloads must not be marked at all. + expect(opSummary('custom_json', { id: 'test', json: '{"a":1}' })).not.toContain('…'); + }); + + it('surfaces every non-default comment_options payout control', () => { + // Defaults stay quiet — a default-everything comment_options is just the post. + expect(opSummary('comment_options', SAMPLES.comment_options)).toBe('Payout options for @alice/a-post'); + + const declined = opSummary('comment_options', { ...SAMPLES.comment_options, max_accepted_payout: '0.000 HBD' }); + expect(declined).toContain('0.000 HBD'); + + const allHive = opSummary('comment_options', { ...SAMPLES.comment_options, percent_hbd: 0 }); + expect(allHive).toContain('0.0% HBD'); + + const noVotes = opSummary('comment_options', { ...SAMPLES.comment_options, allow_votes: false }); + expect(noVotes).toMatch(/votes disabled/i); + + const noCuration = opSummary('comment_options', { + ...SAMPLES.comment_options, + allow_curation_rewards: false, + }); + expect(noCuration).toMatch(/curation rewards disabled/i); + + // Materially different payout behaviour must not render identically. + expect(declined).not.toBe(allHive); + expect(allHive).not.toBe(noVotes); + }); + + it('reads percent_steem_dollars, the legacy alias the vault also accepts', () => { + // hive-ops.ts:225 falls back to it; a summary that ignored it would show + // the default while a non-default value was signed. + const legacy = opSummary('comment_options', { + ...SAMPLES.comment_options, + percent_hbd: undefined, + percent_steem_dollars: 0, + }); + expect(legacy).toContain('0.0% HBD'); + }); + + it('names the beneficiaries a comment_options redirects payout to', () => { + const withBenes = { + ...SAMPLES.comment_options, + extensions: [[0, { beneficiaries: [{ account: 'carol', weight: 2500 }] }]], + }; + const summary = opSummary('comment_options', withBenes); + expect(summary).toContain('carol'); + expect(summary).toContain('25.0%'); + }); +}); diff --git a/chrome-extension/src/background/chains/hiveOps.ts b/chrome-extension/src/background/chains/hiveOps.ts new file mode 100644 index 0000000..7f1f9fb --- /dev/null +++ b/chrome-extension/src/background/chains/hiveOps.ts @@ -0,0 +1,117 @@ +/** + * The Hive clear-sign op table and its approval-card summaries. + * + * A leaf module on purpose: hiveHandler.ts imports @extension/storage, which + * touches chrome.* at import time and so cannot load under vitest. Keeping the + * table and the pure formatter here is what lets hiveOpSummary.test.ts import + * them at all. + */ + +// Firmware clear-sign op table — phase 1 + phase 2 +// (handoff-hive-sign-operations-phase2.md). The vault serializer and the +// firmware both re-enforce this; the check here just fails fast with a +// clear dApp-facing error. +export const SUPPORTED_OPS = new Set([ + 'vote', + 'comment', + 'custom_json', + 'transfer_to_vesting', + 'withdraw_vesting', + 'convert', + 'comment_options', + 'transfer_to_savings', + 'transfer_from_savings', + 'claim_reward_balance', + 'delegate_vesting_shares', + 'account_update2', + 'limit_order_create', + 'limit_order_cancel', +]); + +/** Hive's own defaults for comment_options — anything else is worth showing. */ +const DEFAULT_MAX_PAYOUT = '1000000.000 HBD'; +const DEFAULT_PERCENT_HBD = 10000; + +/** + * Cap a value for one-line display, always marking the cut. + * + * Silent truncation renders two different payloads sharing a 120-char prefix + * as the same string, so the marker carries the dropped length. The full value + * is in the Raw tab (hiveHandler stashes `params` verbatim) — this is the + * one-line view, not the record. + */ +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max)}… (+${s.length - max} more chars)`; +} + +/** + * The exact JSON the vault will serialize — mirrors hive-ops.ts:151. + * + * A dApp may pass `json` as an object; `String(obj)` yields "[object Object]" + * while the object's actual contents are what gets signed. The approval must + * show the signed bytes, not a placeholder for them. + */ +function serializedJson(json: any): string { + return typeof json === 'string' ? json : JSON.stringify(json ?? {}); +} + +/** One-line device-preview summary per op for the side-panel approval. */ +export function opSummary(name: string, p: Record): string { + switch (name) { + case 'vote': + return `@${p.voter} → @${p.author}/${p.permlink} (${(Number(p.weight) / 100).toFixed(0)}%)`; + case 'comment': + return `@${p.author}: ${p.title || p.permlink}`; + case 'custom_json': + return `${p.id}: ${truncate(serializedJson(p.json), 120)}`; + case 'transfer_to_vesting': + return `Power up ${p.amount} → @${p.to}`; + case 'withdraw_vesting': + return String(p.vesting_shares).startsWith('0.000000') + ? `Stop power down (@${p.account})` + : `Power down ${p.vesting_shares} from @${p.account}`; + case 'convert': + return `Convert ${p.amount} → HIVE (request ${p.requestid})`; + case 'comment_options': { + // Every payout control the device screen shows, whenever it differs from + // the Hive default. Showing only the post + beneficiaries made two + // comment_options with materially different payout behaviour — capped + // payout, all-HIVE split, votes or curation disabled — render + // identically in the browser. + const percentHbd = Number(p.percent_hbd ?? p.percent_steem_dollars); + const controls: string[] = []; + if (p.max_accepted_payout != null && p.max_accepted_payout !== DEFAULT_MAX_PAYOUT) { + controls.push(`max payout ${p.max_accepted_payout}`); + } + if (Number.isFinite(percentHbd) && percentHbd !== DEFAULT_PERCENT_HBD) { + controls.push(`${(percentHbd / 100).toFixed(1)}% HBD`); + } + if (p.allow_votes === false) controls.push('votes disabled'); + if (p.allow_curation_rewards === false) controls.push('curation rewards disabled'); + const beneficiaries = (p.extensions?.[0]?.[1]?.beneficiaries ?? []) + .map((b: any) => ` · ${(Number(b.weight) / 100).toFixed(1)}% → @${b.account}`) + .join(''); + return `Payout options for @${p.author}/${p.permlink}${ + controls.length ? ` · ${controls.join(' · ')}` : '' + }${beneficiaries}`; + } + case 'transfer_to_savings': + return `Savings deposit ${p.amount} → @${p.to}`; + case 'transfer_from_savings': + return `Savings withdraw ${p.amount} → @${p.to}`; + case 'claim_reward_balance': + return `Claim ${p.reward_hive}, ${p.reward_hbd}, ${p.reward_vests}`; + case 'delegate_vesting_shares': + return String(p.vesting_shares).startsWith('0.000000') + ? `Remove delegation from @${p.delegatee}` + : `Delegate ${p.vesting_shares} → @${p.delegatee}`; + case 'account_update2': + return `Update profile @${p.account}`; + case 'limit_order_create': + return `Sell ${p.amount_to_sell} for ${p.min_to_receive}${p.fill_or_kill ? ' (fill or kill)' : ''}`; + case 'limit_order_cancel': + return `Cancel order ${p.orderid} (@${p.owner})`; + default: + return name; + } +} diff --git a/pages/side-panel/src/approval/other/RequestDetailsCard.tsx b/pages/side-panel/src/approval/other/RequestDetailsCard.tsx index 9193989..964bd7b 100644 --- a/pages/side-panel/src/approval/other/RequestDetailsCard.tsx +++ b/pages/side-panel/src/approval/other/RequestDetailsCard.tsx @@ -278,6 +278,50 @@ export default function RequestDetailsCard({ transaction }: any) { ); } + // Hive operation batches have no single destination or amount — a tx can + // carry up to four ops of different shapes. hiveHandler stashes a rendered + // one-liner per op (opSummary); show those instead of the amount table, + // which would render "N/A" and "0" for every one of them. + // + // This is the user's half of the clear-sign check: the device OLED is + // authoritative, and they can only compare it against something readable. + const operations: Array<{ op?: string; summary?: string }> | undefined = unsignedTx?.operations; + if (Array.isArray(operations) && operations.length > 0) { + return ( +
+ + + + + {unsignedTx?.from && ( + + + + + )} + {operations.map((o, i) => ( + + + {/* Fall back to the op name rather than blanking the row: + an unsummarized op must still be visible, not absent. */} + + + ))} + +
+ Account: + @{unsignedTx.from}
+ {o.op || 'operation'} + + {o.summary || o.op || 'N/A'} +
+
+ +
+
+ ); + } + return (
diff --git a/pages/side-panel/src/approval/other/index.tsx b/pages/side-panel/src/approval/other/index.tsx index cbb8e49..92a8e7c 100644 --- a/pages/side-panel/src/approval/other/index.tsx +++ b/pages/side-panel/src/approval/other/index.tsx @@ -65,7 +65,9 @@ export function OtherTransaction({ transaction: initialTransaction, handleRespon - + {/* Basic first: Raw opens to a collapsed data section, so defaulting + to it let a user approve without ever seeing the rendered details. */} + Basic {/*Fees*/} From 8f6d9613d9e9d2cd7a10ea2f03dfefe9e81a019f Mon Sep 17 00:00:00 2001 From: xvlad <116202536+sktbrd@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:32:01 -0300 Subject: [PATCH 4/5] fix(injected): reject with an Error, not a bare string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #132. The background sends failures across postMessage as a plain string (`sendResponse({ error: formatUserError(error) })`), and every injected provider rejected with that string untouched. EIP-1193 requires rejecting with an object carrying `code` and `message`, and dApp libraries rely on it: wagmi/viem/ethers inspect the rejection value, `in` throws on a primitive, and the user gets TypeError: Cannot use 'in' operator to search for 'data' in KeepKey Vault is not running. Open the KeepKey Vault desktop app... instead of the instruction we wrote for exactly that situation. The message we most want read is the one we made unreadable. Nothing about that message is special — every error from the extension rejected as a primitive. It is just long enough to make the TypeError absurd. Add toProviderError() and apply it at all five reject sites: injected.ts (EVM), solana-provider, tron-provider, solana-wallet-standard. It keeps an existing Error by reference so stacks are not discarded, preserves a `code` when one is already set, rebuilds structured errors that lost their prototype crossing postMessage, and never throws — a normalizer that can fail is worse than the bug it fixes. Follow-up (deliberately not in this change): formatUserError() flattens errors to a string, so the 4900 "provider disconnected" code from createVaultRequiredError() is dropped before it reaches the dApp. Sending `{ message, code }` would preserve it — toProviderError already handles that shape — but it changes what every consumer of `response.error` receives and wants a side-panel audit first. Co-Authored-By: Claude Opus 5 --- chrome-extension/src/injected/injected.ts | 3 +- .../src/injected/provider-error.test.ts | 56 ++++++++++++++++ .../src/injected/provider-error.ts | 67 +++++++++++++++++++ .../src/injected/solana-provider.ts | 3 +- .../src/injected/solana-wallet-standard.ts | 3 +- .../src/injected/tron-provider.ts | 3 +- 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 chrome-extension/src/injected/provider-error.test.ts create mode 100644 chrome-extension/src/injected/provider-error.ts diff --git a/chrome-extension/src/injected/injected.ts b/chrome-extension/src/injected/injected.ts index b13d267..3c7da25 100644 --- a/chrome-extension/src/injected/injected.ts +++ b/chrome-extension/src/injected/injected.ts @@ -13,6 +13,7 @@ import { registerSolanaWallet } from './solana-wallet-register'; import { KeepKeySolanaProvider } from './solana-provider'; import { KeepKeyTronProvider } from './tron-provider'; import { createHiveKeychainShim } from './hive-provider'; +import { toProviderError } from './provider-error'; import { installConsoleCapture } from './consoleCapture'; import { installPageObserver } from './pageObserver'; @@ -357,7 +358,7 @@ import { installPageObserver } from './pageObserver'; `[HANDOFF] dApp ← KeepKey (${chain}/${method}) REJECT\n params=${JSON.stringify(params)}\n error=`, error, ); - reject(error); + reject(toProviderError(error, `${method} failed`)); } else { const resultType = typeof result; const resultPreview = diff --git a/chrome-extension/src/injected/provider-error.test.ts b/chrome-extension/src/injected/provider-error.test.ts new file mode 100644 index 0000000..7b73d06 --- /dev/null +++ b/chrome-extension/src/injected/provider-error.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { toProviderError } from './provider-error'; + +const VAULT_MESSAGE = + 'KeepKey Vault is not running. Open the KeepKey Vault desktop app, then try again. Get it at https://keepkey.com/launch'; + +describe('toProviderError', () => { + it('wraps a bare string so dApp libraries can inspect it', () => { + // The regression: the background sends failures as a plain string, and + // rejecting with a primitive made wagmi/viem/ethers throw + // "Cannot use 'in' operator to search for 'data' in ", + // hiding the actual instruction from the user. + const err = toProviderError(VAULT_MESSAGE); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe(VAULT_MESSAGE); + expect(err.code).toBe(-32603); + expect(() => 'data' in err).not.toThrow(); + }); + + it('preserves an existing Error, adding a code when missing', () => { + const original = new Error('boom'); + const err = toProviderError(original); + + expect(err).toBe(original); // same reference — stack is not discarded + expect(err.code).toBe(-32603); + }); + + it('does not clobber a code the caller already set', () => { + const original = Object.assign(new Error('disconnected'), { code: 4900 }); + expect(toProviderError(original).code).toBe(4900); + }); + + it('rebuilds a structured error that lost its prototype over postMessage', () => { + const err = toProviderError({ message: 'user rejected', code: 4001, data: { hint: 'x' } }); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe('user rejected'); + expect(err.code).toBe(4001); + expect(err.data).toEqual({ hint: 'x' }); + }); + + it('falls back for null, undefined and empty values', () => { + expect(toProviderError(null, 'eth_call failed').message).toBe('eth_call failed'); + expect(toProviderError(undefined, 'eth_call failed').message).toBe('eth_call failed'); + expect(toProviderError('', 'eth_call failed').message).toBe('eth_call failed'); + expect(toProviderError({}, 'eth_call failed').message).toBe('eth_call failed'); + }); + + it('never throws, whatever it is handed', () => { + for (const input of [0, false, Symbol('s'), 123n, [], () => {}]) { + expect(() => toProviderError(input)).not.toThrow(); + expect(toProviderError(input)).toBeInstanceOf(Error); + } + }); +}); diff --git a/chrome-extension/src/injected/provider-error.ts b/chrome-extension/src/injected/provider-error.ts new file mode 100644 index 0000000..f625e31 --- /dev/null +++ b/chrome-extension/src/injected/provider-error.ts @@ -0,0 +1,67 @@ +/** + * Error normalization for the injected providers. + * + * EIP-1193 requires a provider to reject with an object carrying `code` and + * `message`. Our background script sends failures across postMessage as a bare + * string (`sendResponse({ error: formatUserError(error) })`), and every + * provider used to `reject(error)` with that string untouched. + * + * Rejecting with a primitive breaks the dApp libraries downstream, because + * they inspect the rejection value before showing it. wagmi/viem/ethers all do + * some form of membership test, and `in` throws on a primitive: + * + * TypeError: Cannot use 'in' operator to search for 'data' in + * KeepKey Vault is not running. Open the KeepKey Vault desktop app... + * + * The user then sees a JavaScript error instead of the instruction we went to + * the trouble of writing. Normalizing here — at the boundary where values + * leave us for dApp code — fixes every provider at once and keeps us honest + * with the spec regardless of what the background sends. + */ + +/** EIP-1193 provider error: an Error with a numeric `code`, optionally `data`. */ +export interface ProviderRpcError extends Error { + code: number; + data?: unknown; +} + +/** JSON-RPC internal error — the safe default when no code survived the trip. */ +const INTERNAL_ERROR = -32603; + +/** + * Coerce anything a provider might be handed into a proper ProviderRpcError. + * + * Preserves an existing `code`/`data` when present, so a meaningful code such + * as 4900 ("provider disconnected") still reaches the dApp. Never throws — a + * normalizer that can fail is worse than the bug it fixes. + */ +export function toProviderError(raw: unknown, fallbackMessage = 'Request failed'): ProviderRpcError { + // Already an Error: attach a code if it lacks one and pass it through, so we + // don't discard a stack or a subclass the caller cared about. + if (raw instanceof Error) { + const err = raw as ProviderRpcError; + if (typeof err.code !== 'number') err.code = INTERNAL_ERROR; + return err; + } + + if (typeof raw === 'string') { + const err = new Error(raw || fallbackMessage) as ProviderRpcError; + err.code = INTERNAL_ERROR; + return err; + } + + // Structured error that lost its prototype crossing postMessage, e.g. + // { message, code, data }. Keep whatever fields made it across. + if (raw && typeof raw === 'object') { + const src = raw as { message?: unknown; code?: unknown; data?: unknown }; + const message = typeof src.message === 'string' && src.message ? src.message : fallbackMessage; + const err = new Error(message) as ProviderRpcError; + err.code = typeof src.code === 'number' ? src.code : INTERNAL_ERROR; + if (src.data !== undefined) err.data = src.data; + return err; + } + + const err = new Error(fallbackMessage) as ProviderRpcError; + err.code = INTERNAL_ERROR; + return err; +} diff --git a/chrome-extension/src/injected/solana-provider.ts b/chrome-extension/src/injected/solana-provider.ts index 99ec4ba..81ed839 100644 --- a/chrome-extension/src/injected/solana-provider.ts +++ b/chrome-extension/src/injected/solana-provider.ts @@ -25,6 +25,7 @@ */ import type { ChainType } from './types'; +import { toProviderError } from './provider-error'; type WalletRequestFn = ( method: string, @@ -335,7 +336,7 @@ export class KeepKeySolanaProvider { #rpc(method: string, params: any[]): Promise { return new Promise((resolve, reject) => { this.#walletRequest(method, params, 'solana' as ChainType, (error, result) => { - if (error) reject(error); + if (error) reject(toProviderError(error, `${method} failed`)); else resolve(result); }); }); diff --git a/chrome-extension/src/injected/solana-wallet-standard.ts b/chrome-extension/src/injected/solana-wallet-standard.ts index b39713d..1edbd61 100644 --- a/chrome-extension/src/injected/solana-wallet-standard.ts +++ b/chrome-extension/src/injected/solana-wallet-standard.ts @@ -7,6 +7,7 @@ */ import type { ChainType } from './types'; +import { toProviderError } from './provider-error'; // ---------- Base58 (inline, no external dep) ---------- @@ -374,7 +375,7 @@ export class KeepKeySolanaWallet { #rpc(method: string, params: any[]): Promise { return new Promise((resolve, reject) => { this.#walletRequest(method, params, 'solana' as ChainType, (error, result) => { - if (error) reject(error); + if (error) reject(toProviderError(error, `${method} failed`)); else resolve(result); }); }); diff --git a/chrome-extension/src/injected/tron-provider.ts b/chrome-extension/src/injected/tron-provider.ts index acafe01..eebe964 100644 --- a/chrome-extension/src/injected/tron-provider.ts +++ b/chrome-extension/src/injected/tron-provider.ts @@ -30,6 +30,7 @@ */ import type { ChainType } from './types'; +import { toProviderError } from './provider-error'; type WalletRequestFn = ( method: string, @@ -122,7 +123,7 @@ class EventEmitter { function promisifyRequest(walletRequest: WalletRequestFn, method: string, params: any[]): Promise { return new Promise((resolve, reject) => { walletRequest(method, params, 'tron', (error, result) => { - if (error) reject(error); + if (error) reject(toProviderError(error, `${method} failed`)); else resolve(result); }); }); From b17f97980908b5abc4366192e75083f2d800a4e3 Mon Sep 17 00:00:00 2001 From: Highlander Date: Fri, 14 Aug 2026 23:03:14 -0500 Subject: [PATCH 5/5] fix(evm): fail over on Chrome network errors instead of claiming the vault is down (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dApp eth_sendTransaction failed with "KeepKey Vault is not running" while the vault was running. Two message-text classifiers combined to turn a dead Ethereum RPC into a false claim about the vault: isTransientRpcError("Failed to fetch") -> false => failover aborts isVaultUnreachableError("Failed to fetch") -> true => "Vault not running" Chrome throws byte-identical text for an unreachable RPC and a closed vault, so no regex can separate them. - isTransientRpcError now covers browser connection-level wording (failed to fetch / load failed / err_ / aborted). It was written against Firefox/Node wording, so the same dead RPC failed over on Firefox and hard-threw on Chrome. The loop now tries the remaining URLs. - Collapse the two copies of the classifier (ethereumHandler + rpcFailover) into one export; they had already drifted. - Log the URL before both definitive throws. That branch was silent while the transient branch logged, so the failing RPC never appeared in the console — the single biggest reason this was misdiagnosed as a vault problem. - formatUserError probes localhost:1646 before blaming the vault, instead of inferring vault state from an arbitrary error string in a catch-all. Tests: browser error strings in rpcFailover.test.ts; utils.test.ts asserts an RPC-origin "Failed to fetch" does NOT produce VAULT_REQUIRED_MESSAGE when the vault answers. Co-authored-by: Claude Opus 5 (1M context) --- .../src/background/chains/ethereumHandler.ts | 50 ++----------------- .../src/background/chains/rpcFailover.test.ts | 18 +++++++ .../src/background/chains/rpcFailover.ts | 46 ++++++++++++++++- chrome-extension/src/background/index.ts | 2 +- chrome-extension/src/background/methods.ts | 2 +- chrome-extension/src/background/utils.test.ts | 42 +++++++++++----- chrome-extension/src/background/utils.ts | 31 +++++++++--- 7 files changed, 124 insertions(+), 67 deletions(-) diff --git a/chrome-extension/src/background/chains/ethereumHandler.ts b/chrome-extension/src/background/chains/ethereumHandler.ts index 87eefdc..0807b53 100644 --- a/chrome-extension/src/background/chains/ethereumHandler.ts +++ b/chrome-extension/src/background/chains/ethereumHandler.ts @@ -20,6 +20,7 @@ import * as wallet from '../wallet'; import { buildFeeWarning, getFeeFloor, getPriorityFeeFloor, type FeeChoice, type FeeWarning } from './feeFloors'; import { openSidePanel, setApprovalBadge } from '../popup'; import { getChainInfo, makeStaticProvider } from './registry'; +import { isTransientRpcError } from './rpcFailover'; import { getLastResortRpcs } from './lastResortRpcs'; const TAG = ' | ethereumHandler | '; @@ -1610,50 +1611,6 @@ async function getCandidateRpcs(): Promise<{ return { availableRpcs, networkId, chainIdRaw: currentProvider.chainId ?? '' }; } -/** - * Heuristic: is this RPC error worth retrying against a different URL? - * Used by withRpcFailover (read calls). Broadcast has its own - * classifier because it has additional tx-level definitive cases - * (insufficient funds, nonce too low, etc.). - * - * Includes "method-rejection" patterns because narrow-purpose RPCs in - * Pioneer's catalog (Flashbots' rpc.flashbots.net is the canonical - * example — only supports eth_sendRawTransaction / eth_chainId / - * eth_blockNumber, rejects everything else with HTTP 403 + JSON-RPC - * code -32601 "rpc method is not whitelisted") would otherwise be - * sticky: their pre-flight `getBlockNumber()` test passes, so they get - * picked first on every read, and every read fails 403. Treating the - * rejection as transient lets the loop blacklist them for 60s and try - * the next URL. - */ -const isTransientRpcError = (errMsg: string): boolean => { - const m = errMsg.toLowerCase(); - return ( - m.includes('rate limit') || - m.includes('throttle') || - m.includes('429') || - m.includes('timeout') || - m.includes('econnreset') || - m.includes('etimedout') || - m.includes('network') || - m.includes('server_error') || - m.includes('exceeded maximum retry') || - /\b5\d{2}\b/.test(m) || // 5xx HTTP code - // Method-rejection: this URL doesn't support this method. Try next. - m.includes('rpc method is not whitelisted') || - m.includes('method not found') || - m.includes('method not supported') || - m.includes('method does not exist') || - m.includes('-32601') || - // Narrow to ethers' transport-level wrapper text. A bare `.includes('403')` - // would misfire on revert reasons or hex payloads that happen to - // contain "403", causing a successfully-rejected eth_call to be - // replayed across every URL and pointlessly cool them all. - m.includes('server response 403') || - m.includes('http 403') - ); -}; - /** * Run a read-style RPC call across the failover candidate list. Used * for preflight calls (nonce, gas estimate, fee data) where any working @@ -1683,7 +1640,10 @@ async function withRpcFailover( const errMsg = String(e?.message || e); if (!isTransientRpcError(errMsg)) { // Definitive (revert, invalid params, etc.) — won't help to - // try another RPC. Surface to caller. + // try another RPC. Surface to caller. Log the URL first: this + // branch used to throw silently while the transient branch below + // logged, so an RPC failure left no trace and got misdiagnosed. + console.error(tag, `RPC ${url} definitive failure, aborting failover:`, errMsg); throw e; } console.warn(tag, `RPC ${url} transient failure, trying next:`, errMsg); diff --git a/chrome-extension/src/background/chains/rpcFailover.test.ts b/chrome-extension/src/background/chains/rpcFailover.test.ts index 47d0766..fc2d411 100644 --- a/chrome-extension/src/background/chains/rpcFailover.test.ts +++ b/chrome-extension/src/background/chains/rpcFailover.test.ts @@ -41,6 +41,24 @@ describe('isTransientRpcError', () => { expect(isTransientRpcError('503 Service Unavailable')).toBe(true); }); + // Regression: written against Firefox/Node wording only, so a dead RPC + // hard-threw on Chrome instead of failing over — and the resulting error + // was mislabeled "KeepKey Vault is not running". + it('classifies browser connection-level failures as transient', () => { + expect(isTransientRpcError('Failed to fetch')).toBe(true); + expect(isTransientRpcError('TypeError: Failed to fetch')).toBe(true); + expect(isTransientRpcError('Load failed')).toBe(true); + expect(isTransientRpcError('net::ERR_NAME_NOT_RESOLVED')).toBe(true); + expect(isTransientRpcError('signal is aborted without reason')).toBe(true); + expect(isTransientRpcError('NetworkError when attempting to fetch resource.')).toBe(true); + }); + + it('classifies method-rejection (Flashbots-style narrow RPCs) as transient', () => { + expect(isTransientRpcError('rpc method is not whitelisted')).toBe(true); + expect(isTransientRpcError('server response 403 Forbidden')).toBe(true); + expect(isTransientRpcError('the method does not exist/is not available')).toBe(true); + }); + it('treats definitive RPC errors (revert / invalid params) as NOT transient', () => { expect(isTransientRpcError('execution reverted')).toBe(false); expect(isTransientRpcError('invalid params')).toBe(false); diff --git a/chrome-extension/src/background/chains/rpcFailover.ts b/chrome-extension/src/background/chains/rpcFailover.ts index ff7bb72..7f532cb 100644 --- a/chrome-extension/src/background/chains/rpcFailover.ts +++ b/chrome-extension/src/background/chains/rpcFailover.ts @@ -30,6 +30,28 @@ const FAILED_RPC_COOLDOWN_MS = 60_000; // blocking the other. const failedRpcs = new Map(); +/** + * Heuristic: is this RPC error worth retrying against a different URL? + * Shared by both failover loops (this module's by-networkId reads and + * ethereumHandler's active-provider path) so the two cannot drift. + * Broadcast keeps its own classifier — it has tx-level definitive cases + * (insufficient funds, nonce too low) that don't apply to reads. + * + * The `failed to fetch` / `load failed` / `err_` group matters more than it + * looks: those are what Chrome and Safari throw for a connection-level + * failure (TLS handshake, DNS, refused). The original list was written + * against Firefox/Node wording ("NetworkError..."), so the same dead RPC + * failed over on Firefox and hard-threw on Chrome — and that hard throw + * reached a catch-all that mislabeled it "KeepKey Vault is not running". + * + * The method-rejection patterns cover narrow-purpose RPCs in Pioneer's + * catalog (Flashbots' rpc.flashbots.net is the canonical example — supports + * only eth_sendRawTransaction / eth_chainId / eth_blockNumber, rejects the + * rest with HTTP 403 + JSON-RPC -32601). Without them such URLs are sticky: + * their pre-flight getBlockNumber() passes so they get picked first, then + * every read fails. Treating the rejection as transient blacklists them for + * 60s and moves on. + */ export const isTransientRpcError = (errMsg: string): boolean => { const m = errMsg.toLowerCase(); return ( @@ -42,7 +64,24 @@ export const isTransientRpcError = (errMsg: string): boolean => { m.includes('network') || m.includes('server_error') || m.includes('exceeded maximum retry') || - /\b5\d{2}\b/.test(m) // 5xx + /\b5\d{2}\b/.test(m) || // 5xx + // Connection-level failure, browser wording. + m.includes('failed to fetch') || // Chrome / Edge + m.includes('load failed') || // Safari + m.includes('fetch failed') || // Node / undici + m.includes('err_') || // Chrome net errors: ERR_NAME_NOT_RESOLVED, ERR_CONNECTION_REFUSED, ... + m.includes('aborted') || // per-attempt AbortSignal.timeout fired + // Method-rejection: this URL doesn't support this method. Try next. + m.includes('rpc method is not whitelisted') || + m.includes('method not found') || + m.includes('method not supported') || + m.includes('method does not exist') || + m.includes('-32601') || + // Narrow to ethers' transport-level wrapper text. A bare `.includes('403')` + // would misfire on revert reasons or hex payloads that happen to contain + // "403", replaying a successfully-rejected eth_call across every URL. + m.includes('server response 403') || + m.includes('http 403') ); }; @@ -115,7 +154,10 @@ export async function withRpcFailoverByNetworkId( } catch (e: any) { const errMsg = String(e?.message || e); if (!isTransientRpcError(errMsg)) { - // Definitive — won't help to try another RPC. + // Definitive — won't help to try another RPC. Log the URL: this + // branch used to throw silently, which made RPC failures look like + // they came from somewhere else entirely. + console.error(`[rpcFailover] ${networkId} ${url} definitive failure, aborting failover:`, errMsg); throw e; } console.warn(`[rpcFailover] ${networkId} ${url} transient failure, trying next:`, errMsg); diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index a81f100..9ef3660 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -1237,7 +1237,7 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a `[HANDOFF] BEX → content script (${chain}/${method}) ERROR\n params=${JSON.stringify(params)}\n error=`, error, ); - sendResponse({ error: formatUserError(error) }); + sendResponse({ error: await formatUserError(error) }); } } else { sendResponse({ error: 'Invalid request: missing method' }); diff --git a/chrome-extension/src/background/methods.ts b/chrome-extension/src/background/methods.ts index 3b28adf..9572804 100644 --- a/chrome-extension/src/background/methods.ts +++ b/chrome-extension/src/background/methods.ts @@ -309,7 +309,7 @@ const routeWalletRequest = async ( } // Translate "No device connected" SdkError into user-facing message - errorMessage = formatUserError({ message: errorMessage }); + errorMessage = await formatUserError({ message: errorMessage }); //push error to the popup // Forward `kind` so the side panel can render category-specific UI diff --git a/chrome-extension/src/background/utils.test.ts b/chrome-extension/src/background/utils.test.ts index 2f4252c..aef273f 100644 --- a/chrome-extension/src/background/utils.test.ts +++ b/chrome-extension/src/background/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { createProviderRpcError, createTimeoutError, @@ -61,27 +61,45 @@ describe('isVaultUnreachableError', () => { }); describe('formatUserError', () => { - it('translates a vault-unreachable network error into the launch instruction', () => { - expect(formatUserError(new Error('TypeError: Failed to fetch'))).toBe(VAULT_REQUIRED_MESSAGE); + // formatUserError probes localhost:1646 before blaming the vault, so every + // case here has to say whether the vault is up. `vaultUp(false)` = closed. + const vaultUp = (up: boolean) => + vi.stubGlobal( + 'fetch', + vi.fn(() => (up ? Promise.resolve(new Response('ok')) : Promise.reject(new TypeError('Failed to fetch')))), + ); + + afterEach(() => vi.unstubAllGlobals()); + + it('translates a vault-unreachable network error into the launch instruction', async () => { + vaultUp(false); + await expect(formatUserError(new Error('TypeError: Failed to fetch'))).resolves.toBe(VAULT_REQUIRED_MESSAGE); + }); + + // The regression this guards: Chrome throws the identical string for a dead + // Ethereum RPC, and users were told to launch a vault that was already up. + it('does NOT blame the vault when the vault answers', async () => { + vaultUp(true); + await expect(formatUserError(new Error('TypeError: Failed to fetch'))).resolves.toBe('TypeError: Failed to fetch'); }); - it('translates the vault "No device connected" error into a friendly message', () => { + it('translates the vault "No device connected" error into a friendly message', async () => { const e = new Error('SdkError: No device connected'); - expect(formatUserError(e)).toBe('Please connect your KeepKey device and try again.'); + await expect(formatUserError(e)).resolves.toBe('Please connect your KeepKey device and try again.'); }); - it('passes other error messages through unchanged', () => { - expect(formatUserError(new Error('replacement transaction underpriced'))).toBe( + it('passes other error messages through unchanged', async () => { + await expect(formatUserError(new Error('replacement transaction underpriced'))).resolves.toBe( 'replacement transaction underpriced', ); }); - it('handles non-Error values by stringifying them', () => { - expect(formatUserError('plain string failure')).toBe('plain string failure'); + it('handles non-Error values by stringifying them', async () => { + await expect(formatUserError('plain string failure')).resolves.toBe('plain string failure'); }); - it('does not throw on null/undefined input', () => { - expect(() => formatUserError(null)).not.toThrow(); - expect(() => formatUserError(undefined)).not.toThrow(); + it('does not throw on null/undefined input', async () => { + await expect(formatUserError(null)).resolves.toBeDefined(); + await expect(formatUserError(undefined)).resolves.toBeDefined(); }); }); diff --git a/chrome-extension/src/background/utils.ts b/chrome-extension/src/background/utils.ts index 79235d0..02b7c42 100644 --- a/chrome-extension/src/background/utils.ts +++ b/chrome-extension/src/background/utils.ts @@ -40,24 +40,43 @@ export const VAULT_REQUIRED_MESSAGE = export const createVaultRequiredError = (): ProviderRpcError => createProviderRpcError(4900, VAULT_REQUIRED_MESSAGE); /** - * True when an error came from the vault REST server being down. The signing - * path fetches localhost:1646; when the vault is closed that rejects with a - * network error whose message varies by browser/runtime ("Failed to fetch", - * "Load failed", "NetworkError", "ECONNREFUSED"). + * True when an error *could* have come from the vault REST server being down. + * The signing path fetches localhost:1646; when the vault is closed that + * rejects with a network error whose message varies by browser/runtime + * ("Failed to fetch", "Load failed", "NetworkError", "ECONNREFUSED"). + * + * Deliberately NOT sufficient on its own. Chrome throws the exact same + * "Failed to fetch" for a dead Ethereum RPC, so this test alone told users to + * launch a vault that was already running. `formatUserError` confirms with a + * live probe before claiming the vault is down — see `isVaultReachable`. */ export function isVaultUnreachableError(msg: string): boolean { return /failed to fetch|load failed|networkerror|econnrefused|fetch failed|err_connection_refused/i.test(msg); } +/** + * Ask the vault directly instead of guessing from error text. Cheap + * (localhost, only runs on an already-failed request) and authoritative, + * unlike the 5s-stale KEEPKEY_STATE poll. Same endpoint `checkKeepKey()` uses. + */ +export async function isVaultReachable(): Promise { + try { + await fetch('http://localhost:1646/docs', { signal: AbortSignal.timeout(1500) }); + return true; + } catch { + return false; + } +} + /** * Translate low-level errors into user-facing messages: * - vault unreachable (localhost:1646 down) → "Vault not running" instruction * - vault SdkError ("No device connected") → connect-device instruction * All other errors pass through unchanged. */ -export function formatUserError(err: unknown): string { +export async function formatUserError(err: unknown): Promise { const msg = (err as Error)?.message ?? String(err); - if (isVaultUnreachableError(msg)) { + if (isVaultUnreachableError(msg) && !(await isVaultReachable())) { return VAULT_REQUIRED_MESSAGE; } if (msg.includes('No device connected')) {