From b32056ac8d1c1ec6c54afa355fa36e639e412555 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 22 Jun 2026 17:06:17 -0500 Subject: [PATCH 01/53] fix(emulator): show Accept/Reject for Enable Policy confirm applyPolicy raised the device's ENABLE/DISABLE POLICY confirm but bypassed emuSigningOp, so on the emulator no interactive button was rendered and the op hung in confirm_helper until the 120s timeout. Route it through emuSigningOp when engine.isEmulator, matching every other device-confirm op. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/keepkey-vault/src/bun/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 2a0b9198..ed7ab5d1 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1885,7 +1885,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() From 86a791156f25190b01ba12c57be7d81b7668df11 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 22 Jun 2026 18:22:58 -0500 Subject: [PATCH 02/53] =?UTF-8?q?fix(auth):=20stop=20re-approve=20churn=20?= =?UTF-8?q?=E2=80=94=20idempotent=20pairing,=20LRU,=20sliding=20TTL,=20600?= =?UTF-8?q?s=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault-side fixes for the BEX 're-approve every time' bug (handoff from keepkey-client PR #85). Four defects in the pairing store: 1. Idempotent pairing: approvePairing()/pair() now reuse a live key for the same identity instead of minting a new UUID + row every time. Identity is a client-supplied clientId (new, optional) or the (name,url,imageUrl) triple. reloadPairings() collapses pre-existing duplicate rows on load. POST /auth/pair returns { apiKey, reused }. 2. LRU eviction: evictIfFull() drops the least-recently-used key, not the oldest-inserted — so the actively-used key is never evicted at capacity. 3. Sliding TTL: validate() measures the 30d TTL against last use and refreshes recency (persist throttled to ~1x/hour). Active keys never expire; abandoned ones still age out. 4. Timeout: pending-pair auto-reject raised 60s -> 600s to match the SDK's POST /auth/pair wait, so a slow approval isn't discarded. Also: coalesce concurrent same-identity pair requests onto one prompt (no 429); DELETE /auth/pair revokes the caller's own key; nullable client_id + last_used_on columns on paired_apps (backward-compatible migration). Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/keepkey-vault/src/bun/auth.ts | Bin 8208 -> 13211 bytes projects/keepkey-vault/src/bun/db.ts | 30 ++++++++++++++++----- projects/keepkey-vault/src/bun/rest-api.ts | 15 ++++++++--- projects/keepkey-vault/src/bun/schemas.ts | 2 ++ projects/keepkey-vault/src/shared/types.ts | 4 +++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/projects/keepkey-vault/src/bun/auth.ts b/projects/keepkey-vault/src/bun/auth.ts index 55c663eb86c7dec749d13ba1f015d66a74ed19e2..7e3e0f1cb1a6123a40ac53dada5e8afe7f252ef5 100644 GIT binary patch literal 13211 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 B;Ozha delta 1095 zcmd5*&rcIU6pn}pjr&~>$K!Zn* z#Ajj%{{UCTi#I)R@L=N23mW6igNfcesk1+-=wIL*cJtoM`{sS$`)0eed0}(z>C%%H ze0F#i_gYU4o-Kk&>H-*t3bSn{sNrx^pn0T-cKgl=j|#`G(J9b~%jRi)3WW3+FX6ck z9cvw5@JH-6j&l$T_=0WS3f6Ox+X66O~RNyXFN(1OHvOeii(B> z+>t=g$)V#?L)I^yKG;}1X%+Y46_tSCQPjunO3V1g= z+0iVV#@E^5bTe9Chd(ZW@g_We&uTcIKGp-uI2Gl*rWx?+$x1Sg41PfduN+s(f_G xw9<%)ysugC(eym0t5W@H#IDkyK)n<#Jg&o94DhQqkd5MC|6eBI-ma#L*e_b+bsGQx diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 47b5ba66..c3012b67 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 */ } @@ -885,28 +889,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/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 21c91180..95383fa8 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -1433,15 +1433,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 }) + } } // ═══════════════════════════════════════════════════════════════ diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index ad4c20d2..e2e81fef 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 */ diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index b8e514f3..7347a87d 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -427,6 +427,10 @@ export interface PairedAppInfo { url: string imageUrl: string addedOn: number + /** Stable per-install id sent by the client; preferred identity for dedup. */ + clientId?: string + /** Last successful auth — drives LRU eviction + sliding TTL. */ + lastUsedOn?: number } export interface EIP712DecodedField { From 72d6baff29642bf3a11fe950fb431da749606662 Mon Sep 17 00:00:00 2001 From: Highlander Date: Mon, 22 Jun 2026 19:37:44 -0500 Subject: [PATCH 03/53] =?UTF-8?q?fix(update):=20route=20updates=20to=20kee?= =?UTF-8?q?pkey.com/update=20page,=20no=20in-app=20"Downloading=E2=80=A6"?= =?UTF-8?q?=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(update): route updates to keepkey.com/update page, drop in-app "Downloading…" Desktop update on macOS/Windows opened the GitHub releases page and the UI optimistically showed a "Downloading…" progress bar even though nothing downloads in-app. Now: - Open https://keepkey.com/update?os=&arch= &version=¤t= so the page can serve the correct build for the user's OS/arch and guide them through updating. - Remove the optimistic 'downloading' phase in useUpdateState. Linux's native updater still drives real downloading/ready phases via status events; the open-the-page flow no longer shows a fake progress bar. - Relabel the error-fallback button "Download from GitHub" -> "How to Update". Co-Authored-By: Claude Opus 4.8 (1M context) * fix(update): quote URL for Windows cmd start; translate 'How to Update' in all locales - Windows: '&' in the query string is a cmd separator; quote the URL so 'start' opens the full keepkey.com/update link instead of splitting at the first '&'. - Update downloadManually copy in all 14 non-English locales (was stale 'Download from GitHub' while the button now opens the update page). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- projects/keepkey-vault/src/bun/index.ts | 30 ++++++++++++------- .../components/DeviceSettingsDrawer.tsx | 2 +- .../src/mainview/hooks/useUpdateState.ts | 6 +++- .../mainview/i18n/locales/de/settings.json | 2 +- .../mainview/i18n/locales/en/settings.json | 2 +- .../mainview/i18n/locales/es/settings.json | 2 +- .../mainview/i18n/locales/fr/settings.json | 2 +- .../mainview/i18n/locales/it/settings.json | 2 +- .../mainview/i18n/locales/ja/settings.json | 2 +- .../mainview/i18n/locales/ko/settings.json | 2 +- .../mainview/i18n/locales/nl/settings.json | 2 +- .../mainview/i18n/locales/pl/settings.json | 2 +- .../mainview/i18n/locales/pt/settings.json | 2 +- .../mainview/i18n/locales/ru/settings.json | 2 +- .../mainview/i18n/locales/th/settings.json | 2 +- .../mainview/i18n/locales/tr/settings.json | 2 +- .../mainview/i18n/locales/vi/settings.json | 2 +- .../mainview/i18n/locales/zh/settings.json | 2 +- 18 files changed, 40 insertions(+), 28 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index ed7ab5d1..ecea9f20 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -300,12 +300,14 @@ function mergeMetas(metas: PortfolioMeta[]): PortfolioMeta { } } -// ── Desktop update — open GitHub releases page ── +// ── 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 @@ -314,13 +316,19 @@ let pioneerSocket: PioneerSocket | null = null // "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}`) + // On Windows `&` is a cmd command separator; `start` would split the query string + // into separate commands. Quote the URL so it stays a single argument. + const cmd = process.platform === 'win32' ? ['cmd', '/c', 'start', '', `"${url}"`] : ['open', url] Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'] }) } @@ -6794,14 +6802,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() diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index bc9db391..6e6f5442 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -1197,7 +1197,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd cursor="pointer" _hover={{ bg: "rgba(255,255,255,0.1)" }} onClick={onDownloadUpdate} > - {t("downloadManually", { defaultValue: "Download from GitHub" })} + {t("downloadManually", { defaultValue: "How to Update" })} )} diff --git a/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts b/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts index af4a8e10..814070fd 100644 --- a/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts +++ b/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts @@ -106,7 +106,11 @@ export function useUpdateState() { }, []) const downloadUpdate = useCallback(async () => { - setState(prev => ({ ...prev, phase: 'downloading', progress: 0, error: undefined })) + // No optimistic 'downloading' phase: on macOS/Windows this just opens the + // keepkey.com update page in the browser (no in-app download), so we must not + // show "Downloading…". On Linux the native updater emits real status events + // that drive the downloading/ready phases. + setState(prev => ({ ...prev, error: undefined })) try { await rpcRequest('downloadUpdate', undefined, 0) } catch (e: any) { diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/de/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/de/settings.json index 91314e45..a6189164 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/de/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/de/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Alpha-Firmware", "alphaFirmwareDesc": "Frühe Firmware-Versionen aus dem Beta-Kanal aktivieren", - "downloadManually": "Von GitHub herunterladen", + "downloadManually": "So aktualisieren Sie", "downloadUpdate": "Update herunterladen", "preReleaseUpdates": "Vorab-Updates", "preReleaseUpdatesDesc": "Erhalten Sie frühzeitig Zugang zu neuen Funktionen vor der stabilen Veröffentlichung", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json index f2598761..3600fe54 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Alpha Firmware", "alphaFirmwareDesc": "Opt in to early firmware releases from the beta channel", - "downloadManually": "Download from GitHub", + "downloadManually": "How to Update", "downloadUpdate": "Download Update", "preReleaseUpdates": "Pre-release Updates", "preReleaseUpdatesDesc": "Get early access to new features before stable release", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/es/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/es/settings.json index 25abf27a..880bbfdb 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/es/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/es/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Firmware alfa", "alphaFirmwareDesc": "Suscríbete a las versiones tempranas de firmware del canal beta", - "downloadManually": "Descargar desde GitHub", + "downloadManually": "Cómo actualizar", "downloadUpdate": "Descargar actualización", "preReleaseUpdates": "Actualizaciones preliminares", "preReleaseUpdatesDesc": "Obtén acceso anticipado a nuevas funciones antes del lanzamiento estable", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/fr/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/fr/settings.json index f5e5d369..a90babdf 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/fr/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/fr/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Firmware alpha", "alphaFirmwareDesc": "Recevoir les premières versions du firmware via le canal bêta", - "downloadManually": "Télécharger depuis GitHub", + "downloadManually": "Comment mettre à jour", "downloadUpdate": "Télécharger la mise à jour", "preReleaseUpdates": "Mises à jour en préversion", "preReleaseUpdatesDesc": "Accédez en avant-première aux nouvelles fonctionnalités avant la version stable", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/it/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/it/settings.json index f10a7a36..dec81d0c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/it/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/it/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Firmware alpha", "alphaFirmwareDesc": "Aderisci alle versioni anticipate del firmware dal canale beta", - "downloadManually": "Scarica da GitHub", + "downloadManually": "Come aggiornare", "downloadUpdate": "Scarica aggiornamento", "preReleaseUpdates": "Aggiornamenti pre-release", "preReleaseUpdatesDesc": "Ottieni l'accesso anticipato alle nuove funzionalità prima della versione stabile", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ja/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/ja/settings.json index 5a5f3c2b..af8d352a 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ja/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ja/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "アルファファームウェア", "alphaFirmwareDesc": "ベータチャネルの早期ファームウェアリリースに参加します", - "downloadManually": "GitHubからダウンロード", + "downloadManually": "アップデート方法", "downloadUpdate": "アップデートをダウンロード", "preReleaseUpdates": "プレリリースアップデート", "preReleaseUpdatesDesc": "安定版リリース前に新機能への早期アクセスを取得します", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ko/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/ko/settings.json index f372ff03..e8df13f8 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ko/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ko/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "알파 펌웨어", "alphaFirmwareDesc": "베타 채널의 초기 펌웨어 릴리스를 사용 신청합니다", - "downloadManually": "GitHub에서 다운로드", + "downloadManually": "업데이트 방법", "downloadUpdate": "업데이트 다운로드", "preReleaseUpdates": "사전 릴리스 업데이트", "preReleaseUpdatesDesc": "안정 릴리스 전에 새 기능을 미리 사용해 보세요", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/nl/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/nl/settings.json index f2da5e7e..cb948342 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/nl/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/nl/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Alpha-firmware", "alphaFirmwareDesc": "Schrijf je in voor vroege firmwarereleases van het bètakanaal", - "downloadManually": "Downloaden van GitHub", + "downloadManually": "Hoe te updaten", "downloadUpdate": "Update downloaden", "preReleaseUpdates": "Pre-release-updates", "preReleaseUpdatesDesc": "Krijg vroege toegang tot nieuwe functies vóór de stabiele release", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pl/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/pl/settings.json index e91adf76..8a672abe 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pl/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pl/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Oprogramowanie alfa", "alphaFirmwareDesc": "Dołącz do wczesnych wydań oprogramowania z kanału beta", - "downloadManually": "Pobierz z GitHub", + "downloadManually": "Jak zaktualizować", "downloadUpdate": "Pobierz aktualizację", "preReleaseUpdates": "Aktualizacje przedpremierowe", "preReleaseUpdatesDesc": "Uzyskaj wczesny dostęp do nowych funkcji przed stabilnym wydaniem", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pt/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/pt/settings.json index a8973d21..2b676e53 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pt/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pt/settings.json @@ -94,7 +94,7 @@ "factoryResetQuitting": "O app será encerrado após a redefinição. Reinicie para começar do zero.", "alphaFirmware": "Firmware Alfa", "alphaFirmwareDesc": "Participe de versões antecipadas de firmware do canal beta", - "downloadManually": "Baixar do GitHub", + "downloadManually": "Como atualizar", "downloadUpdate": "Baixar atualização", "preReleaseUpdates": "Atualizações de pré-lançamento", "preReleaseUpdatesDesc": "Tenha acesso antecipado a novos recursos antes do lançamento estável", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ru/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/ru/settings.json index 4cf781d8..bee3a9e8 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ru/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ru/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Альфа-прошивка", "alphaFirmwareDesc": "Подписаться на ранние выпуски прошивки из бета-канала", - "downloadManually": "Скачать с GitHub", + "downloadManually": "Как обновить", "downloadUpdate": "Скачать обновление", "preReleaseUpdates": "Предварительные обновления", "preReleaseUpdatesDesc": "Получайте ранний доступ к новым функциям до стабильного выпуска", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/th/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/th/settings.json index 77e8d55e..5c92f967 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/th/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/th/settings.json @@ -112,7 +112,7 @@ "factoryResetQuitting": "แอปจะปิดหลังจากรีเซ็ต เปิดใหม่เพื่อเริ่มต้นใหม่", "alphaFirmware": "เฟิร์มแวร์ Alpha", "alphaFirmwareDesc": "เลือกรับเฟิร์มแวร์รุ่นแรกเริ่มจากช่องทางเบต้า", - "downloadManually": "ดาวน์โหลดจาก GitHub", + "downloadManually": "วิธีอัปเดต", "downloadUpdate": "ดาวน์โหลดการอัปเดต", "preReleaseUpdates": "การอัปเดตก่อนเผยแพร่", "preReleaseUpdatesDesc": "เข้าถึงฟีเจอร์ใหม่ก่อนการเผยแพร่รุ่นเสถียร", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/tr/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/tr/settings.json index c96ee471..2d4e6997 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/tr/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/tr/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Alpha yazılım", "alphaFirmwareDesc": "Beta kanalından erken yazılım sürümlerine katılın", - "downloadManually": "GitHub'dan indir", + "downloadManually": "Nasıl güncellenir", "downloadUpdate": "Güncellemeyi indir", "preReleaseUpdates": "Ön sürüm güncellemeleri", "preReleaseUpdatesDesc": "Kararlı sürümden önce yeni özelliklere erken erişim elde edin", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/vi/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/vi/settings.json index 69878e16..ef94f460 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/vi/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/vi/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Phần mềm Alpha", "alphaFirmwareDesc": "Tham gia nhận các bản phần mềm sớm từ kênh beta", - "downloadManually": "Tải từ GitHub", + "downloadManually": "Cách cập nhật", "downloadUpdate": "Tải bản cập nhật", "preReleaseUpdates": "Cập nhật bản phát hành trước", "preReleaseUpdatesDesc": "Truy cập sớm các tính năng mới trước bản phát hành ổn định", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/zh/settings.json b/projects/keepkey-vault/src/mainview/i18n/locales/zh/settings.json index 6652a4d7..c22dc64a 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/zh/settings.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/zh/settings.json @@ -147,7 +147,7 @@ }, "alphaFirmware": "Alpha 固件", "alphaFirmwareDesc": "加入 beta 通道,抢先体验早期固件版本", - "downloadManually": "从 GitHub 下载", + "downloadManually": "如何更新", "downloadUpdate": "下载更新", "preReleaseUpdates": "预发布更新", "preReleaseUpdatesDesc": "在稳定版发布前抢先体验新功能", From 67a9b1f52a2188015b9993b51f01bfbf5950fefe Mon Sep 17 00:00:00 2001 From: Highlander Date: Mon, 22 Jun 2026 21:03:38 -0500 Subject: [PATCH 04/53] fix(watch-only): make Refresh re-fetch balances from cached addresses (#285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(watch-only): make Refresh re-fetch balances from cached addresses Watch-only mode (viewing a cached device snapshot with no device connected) showed a Refresh button that did nothing: Dashboard.refreshBalances early- returned on `watchOnly`, and getWatchOnlyBalances only read the DB cache. Add a refreshWatchOnlyBalances RPC that reconstructs the pubkey list from cache (cached chain addresses + cached BTC xpubs), re-queries Pioneer's GetPortfolioBalances (no device), parses natives + tokens + DeFi into ChainBalance[], persists, and returns. The device getBalances path and its managers are untouched — this is a self-contained, display-only parse (no per-owner EVM token map, no addressbook sync). Frontend refreshBalances now runs this path in watch-only mode with proper loading state and error surfacing instead of returning silently. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(watch-only): don't overwrite cached balances with confirmed zeros on partial Pioneer failure Track which chunks failed and exclude their chains from the confirmed set so setCachedBalances uses the guarded upsert (keeps existing non-zero) instead of forcing a 0 — matching the device getBalances behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- projects/keepkey-vault/src/bun/index.ts | 209 ++++++++++++++++++ .../src/mainview/components/Dashboard.tsx | 28 ++- .../keepkey-vault/src/shared/rpc-schema.ts | 1 + 3 files changed, 236 insertions(+), 2 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index ecea9f20..a90c88dc 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -5803,6 +5803,215 @@ 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 (same heuristic as getBalances) + 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) + } + + // 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 contractMatch = (tok.caip || '').match(/\/(erc20|spl|trc20|token):([^\s]+)/) + 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: contractMatch?.[2] || tok.contract || undefined, + networkId: tokNetworkId || caipPrefix, + icon: tok.icon || undefined, + decimals: tok.decimals ?? tok.precision, + type: tok.type || 'token', + dataSource: tok.dataSource, + } + 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: 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 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 diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 3e9b6c4b..b99d0a0d 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -1039,7 +1039,31 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin // Manual refresh: fetch live data from Pioneer API // forceRefresh=true bypasses Pioneer's balance cache — only pass it on explicit user action const refreshBalances = useCallback(async (forceRefresh = false) => { - if (watchOnly) return + // Watch-only: no device, so re-fetch from Pioneer using cached addresses. + if (watchOnly) { + if (loadingBalancesRef.current) return + loadingBalancesRef.current = true + setLoadingBalances(true) + try { + const result = await rpcRequest('refreshWatchOnlyBalances', watchOnlyDeviceId ? { deviceId: watchOnlyDeviceId } : undefined, 200000) + if (result) { + const map = new Map() + for (const b of result) map.set(b.chainId, b) + setBalances(map) + setCacheUpdatedAt(Date.now()) + setHasEverRefreshed(true) + clearPioneerError() + } + } catch (e: any) { + const message = e?.message || 'Unable to refresh balances' + console.warn('[Dashboard] refreshWatchOnlyBalances failed:', message) + stagePioneerError({ message, url: 'the configured balance server' }) + } finally { + loadingBalancesRef.current = false + setLoadingBalances(false) + } + return + } // Non-forced calls yield to any in-progress refresh (avoids non-forced swap-complete // poll superseding an already-running forced user refresh). if (!forceRefresh && loadingBalancesRef.current) return @@ -1091,7 +1115,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin loadingBalancesRef.current = false setLoadingBalances(false) } - }, [watchOnly, clearPioneerError, stagePioneerError]) + }, [watchOnly, watchOnlyDeviceId, clearPioneerError, stagePioneerError]) // Phase 2 trigger — window focus: catch long idle periods. If the last live // fetch is older than 5 min, force-refresh on return to the window. diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 8b843654..71b56b7c 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -303,6 +303,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { // ── Watch-only mode ────────────────────────────────────────────── checkWatchOnlyCache: { params: void; response: { available: boolean; deviceLabel?: string; lastSynced?: number } } getWatchOnlyBalances: { params: { deviceId?: string } | void; response: ChainBalance[] | null } + refreshWatchOnlyBalances: { params: { deviceId?: string } | void; response: ChainBalance[] | null } getWatchOnlyPubkeys: { params: { deviceId?: string } | void; response: Array<{ chainId: string; path: string; xpub: string; address: string }> } // ── Registered devices (device history) ────────────────────────── From ae6a935217bd471e61bd047f525f88ade95df203 Mon Sep 17 00:00:00 2001 From: Highlander Date: Mon, 22 Jun 2026 22:24:12 -0500 Subject: [PATCH 05/53] fix(watch-only): show cached receive address instead of 'No device connected' (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(watch-only): show cached receive address instead of 'No device connected' In watch-only mode there is no connected device, so every *GetAddress RPC threw 'No device connected' and the Receive page rendered that error for show-address, QR, and copy — even though the address is cached from the last connection. - AssetPage.deriveAddress and the BTC account-aware effect now short-circuit in watch-only and display the cached address (balance.address) instead of calling the device RPC. - ReceiveView gains a watchOnly prop: hides Verify-on-Device, the BTC receive/change + index stepper, the TON bounceable toggle, and the edit-path pencil — all device-derive actions that can't work offline and would show a stale cached address. Path text stays visible (informational). - Empty offline state shows a plain explanation instead of device-hitting retry/derive buttons. Connected-device flow is unchanged (all new branches gate on watchOnly). * fix(watch-only): stop EVM effect clearing cached address; hide account selectors - EVM address effect now returns early in watch-only instead of setAddress(null) when the device-backed evmAddresses hook is empty (which it is offline), preserving balance.address as the watch-only source of truth. - BtcXpubSelector / EvmAddressSelector are gated behind !watchOnly — their select/add/remove handlers call device/backend account RPCs and could mix live account state with the cached receive address. Addresses PR #286 review findings 1 (high) and 2 (medium). --- .../src/mainview/components/AssetPage.tsx | 30 ++++++++++++++++--- .../src/mainview/components/ReceiveView.tsx | 28 +++++++++++++---- .../src/mainview/i18n/locales/en/receive.json | 1 + 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 71db45d4..16609b5d 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -205,6 +205,14 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi const deriveAddress = useCallback(async (path?: number[], overrideBounceable?: boolean) => { 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 { @@ -225,12 +233,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]) // 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 +317,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 +330,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) @@ -1015,7 +1033,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 && isEvm && evmAddresses.addresses.length >= 1 && ( { setTonBounceable(v); deriveAddress(undefined, v) }} + watchOnly={watchOnly} /> )} diff --git a/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx b/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx index 94eece7f..e0686c76 100644 --- a/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReceiveView.tsx @@ -29,6 +29,8 @@ interface ReceiveViewProps { isTon?: boolean tonBounceable?: boolean onTonBounceableChange?: (bounceable: boolean) => void + // Watch-only: no device — hide device-required actions (verify-on-device, derive/retry). + watchOnly?: boolean } /** Eyebrow + ink-0 mono block + copy chip on the right. @@ -144,7 +146,7 @@ function PillToggle({ export function ReceiveView({ chain, address, loading, error, currentPath, onDerive, scriptType, xpub, isBtc, btcChangeIndex = 0, btcAddressIndex = 0, onBtcChangeIndex, onBtcAddressIndex, - isTon, tonBounceable = false, onTonBounceableChange, + isTon, tonBounceable = false, onTonBounceableChange, watchOnly = false, }: ReceiveViewProps) { const { t } = useTranslation("receive") const [showing, setShowing] = useState(false) @@ -184,6 +186,15 @@ export function ReceiveView({ }, [onBtcAddressIndex, btcAddressIndex]) if (!address && !loading) { + // Watch-only with no cached address: the device isn't here to derive one, so + // the retry/derive buttons would only re-fail. Show a plain explanation instead. + if (watchOnly) { + return ( + + {t("watchOnlyNoAddress")} + + ) + } return ( {error ? ( @@ -271,6 +282,8 @@ export function ReceiveView({ sx={{ '& svg': { width: '100%', height: '100%', display: 'block' } }} /> + {/* Verify-on-device requires the device — hidden in watch-only mode. */} + {!watchOnly && ( {showing ? t("checkDevice") : t("verifyOnDevice")} + )} {/* Right column: address + xpub + path + chips */} @@ -304,8 +318,9 @@ export function ReceiveView({ {t("sendToAddress", { symbol: chain.symbol })} - {/* BTC: receive/change toggle + index stepper */} - {isBtc && onBtcChangeIndex && ( + {/* BTC: receive/change toggle + index stepper — needs the device to derive + each index, so hidden in watch-only (cached address only). */} + {!watchOnly && isBtc && onBtcChangeIndex && ( )} - {/* TON: bounceable / non-bounceable */} - {isTon && onTonBounceableChange && ( + {/* TON: bounceable / non-bounceable — re-derives via device, hidden offline */} + {!watchOnly && isTon && onTonBounceableChange && ( Address Type @@ -422,6 +437,8 @@ export function ReceiveView({ {pathToString(currentPath)} + {/* Editing the path re-derives on the device — hidden in watch-only. */} + {!watchOnly && ( + )} diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/receive.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/receive.json index e7477e2e..1302f19e 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/receive.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/receive.json @@ -12,5 +12,6 @@ "derivingAddress": "Deriving address...", "noAddressDerived": "No address derived yet", "deriveAddress": "Derive Address", + "watchOnlyNoAddress": "No cached address. Connect your device to view receive addresses.", "account": "Account {{index}}" } From ff572ccd813832c6aea3878e238e9341ee67146d Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 23 Jun 2026 01:46:56 -0500 Subject: [PATCH 06/53] fix(emulator): reload saved seed when persisted flash boots a stale/wrong seed (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emulator flash is not a trustworthy seed store — the firmware's storage wrapping key changes per kkemu_init (it can't decrypt its own sec section across restarts), so a persisted flash can boot a DIFFERENT seed than the one the user saved as a mnemonic. The connect smoke-test passes on any valid seed, so the wrong wallet was presented as ready with no warning. - Deterministic seed-identity guard: compare the live m/44'/0'/0' BTC xpub (already fetched by the smoke-test) against the xpub derived offline from the saved mnemonic; on mismatch, wipe the flash and reload the saved seed. - Blank-flash recovery: if the flash boots uninitialized but a mnemonic is saved for the wallet, restore the saved seed instead of dropping into setup. - reloadSavedSeed verifies the live xpub after reload and errors if it still does not match — no path reports success without proving the seed. - Consolidated the storage-stale recovery through reloadSavedSeed, replacing the duplicated inline reload + flaky DebugLink mnemonic verify. --- .../src/bun/engine-controller.ts | 251 ++++++++++-------- 1 file changed, 140 insertions(+), 111 deletions(-) diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index cb5dbe2a..7b0d8bd3 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -1155,21 +1155,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 +1183,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 +1202,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 +1243,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). */ From 4d121aa4accb89c90b56a4184b717722ad4681fc Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 23 Jun 2026 02:58:03 -0500 Subject: [PATCH 07/53] chore: bump version to 1.4.7 --- projects/keepkey-vault/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 3aa50433..016de8e5 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.7", "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", From 1b6da84e5ea12e22de959246092d483f651f1318 Mon Sep 17 00:00:00 2001 From: Highlander Date: Thu, 25 Jun 2026 16:14:51 -0500 Subject: [PATCH 08/53] fix(release): force module rebuild in build-signed so stale dist can't ship (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proto-tx-builder / hdwallet / device-protocol dist/ output is gitignored and copied into the app bundle via file: refs. Their build stamps track src file mtimes, but a submodule `git checkout` to a new pin does not reliably bump src mtimes past an existing stamp — so make considers the build current and skips it, shipping a STALE dist from a previous commit. This shipped a pre-fix proto-tx-builder in v1.4.6 and v1.4.7: the source had the @cosmjs/stargate Freegrant/Feegrant typo fallback (commit f12f8c3, which both releases pinned), but the bundled dist predated it, so every Cosmos transaction crashed with 'createFeegrantAminoConverters is not a function' (send, delegate, undelegate). Clear the module build stamps in build-signed (same pattern already used for the zcash-cli stamp) so the release always rebuilds modules from the pinned source before the vault install copies their dist into the bundle. --- Makefile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 ===" From 3b3f4affe26185485edc94dc9992347e5ed784d8 Mon Sep 17 00:00:00 2001 From: Highlander Date: Thu, 25 Jun 2026 17:10:54 -0500 Subject: [PATCH 09/53] fix(swap): restore localized title, remove hardcoded 'Swaps.pro' brand string (#289) The swap dialog header rendered the literal 'Swaps.pro' (an unrelated third-party brand) instead of the localized title. de610e88 replaced the t('title') i18n key with a hardcoded string inside a UI styling refactor; this also broke localization of the title in all languages. Revert to t('title') (= 'Swap'). --- projects/keepkey-vault/src/mainview/components/SwapDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index b871723b..4641cc7a 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -2362,7 +2362,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, - {phase === 'review' ? t("review") : phase === 'submitted' ? t("swapSubmitted") : phase === 'blind-signing-required' ? t("blindSignHeader", "Blind Signing") : "Swaps.pro"} + {phase === 'review' ? t("review") : phase === 'submitted' ? t("swapSubmitted") : phase === 'blind-signing-required' ? t("blindSignHeader", "Blind Signing") : t("title")} {/* Provider health — rendered as ascending wifi-style bars so the overall integration status reads at a glance (the worst From 285ba3eb64ac3d7796b1dfaced3651906fc2f45c Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 25 Jun 2026 17:12:30 -0500 Subject: [PATCH 10/53] chore: bump version to 1.4.8 --- projects/keepkey-vault/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 016de8e5..05bed94d 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.4.7", + "version": "1.4.8", "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", From 7f1e93e88292357a2bca897dc8a7b475a86d7d32 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 15:48:52 -0500 Subject: [PATCH 11/53] =?UTF-8?q?feat(hive):=20settings=20feature=20flag?= =?UTF-8?q?=20(default=20off)=20+=20fix=20firmware=20gate=207.16=E2=86=927?= =?UTF-8?q?.15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a user-facing `hiveEnabled` toggle to the Settings drawer, default OFF, mirroring the zcashPrivacyEnabled pattern. Hive is hidden from the Dashboard grid, command palette, and all backend chain lists unless the flag is on. Also corrects the Hive firmware gate from 7.16.0 to 7.15.0. The Hive handler ships in firmware 7.15.0 (alpha) — same release as Zcash — but the gate was set to 7.16.0 (the device-protocol package version, not the firmware version), which no firmware reports, so Hive could never appear even on supported devices. The new default-off settings flag now carries "is Hive ready for users" instead. - shared/types.ts, rpc-schema.ts: hiveEnabled on AppSettings + setHiveEnabled RPC - bun/index.ts: persist + getAppSettings + handler (fw-gated 7.15.0) + 4 chain-filter sites - chains.ts: minFirmware 7.16.0 → 7.15.0 - DeviceSettingsDrawer/Dashboard/CommandPalette/App: toggle UI + flag plumbing --- projects/keepkey-vault/src/bun/index.ts | 19 +++++++++ projects/keepkey-vault/src/mainview/App.tsx | 7 +++- .../mainview/components/CommandPalette.tsx | 8 ++-- .../src/mainview/components/Dashboard.tsx | 5 ++- .../components/DeviceSettingsDrawer.tsx | 39 ++++++++++++++++++- projects/keepkey-vault/src/shared/chains.ts | 2 +- .../keepkey-vault/src/shared/rpc-schema.ts | 1 + projects/keepkey-vault/src/shared/types.ts | 1 + 8 files changed, 74 insertions(+), 8 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index a90c88dc..a1dcf586 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -555,6 +555,7 @@ let restApiEnabled = false let walletConnectEnabled = false let bip85Enabled = false let zcashPrivacyEnabled = false +let hiveEnabled = false // 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 @@ -586,6 +587,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' @@ -890,6 +892,7 @@ function getAppSettings() { walletConnectEnabled, bip85Enabled, zcashPrivacyEnabled, + hiveEnabled, emulatorEnabled, preReleaseUpdates, alphaFirmware, @@ -2412,6 +2415,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') @@ -4691,6 +4695,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 }) @@ -4929,6 +4934,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 @@ -5750,6 +5767,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)) @@ -6146,6 +6164,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 })) diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index a2155fac..f1ea23f7 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -72,6 +72,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 +99,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 +835,7 @@ function App() { onJumpToVault={() => setActiveTab("vault")} balances={paletteBalances} firmwareVersion={undefined} + hiveEnabled={hiveEnabled} /> ) @@ -957,7 +959,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 +1009,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/CommandPalette.tsx b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx index e389af91..f537d8e3 100644 --- a/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx +++ b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx @@ -17,6 +17,8 @@ interface CommandPaletteProps { balances: Map /** Device firmware version — used to filter chains that require newer firmware. */ firmwareVersion?: string + /** Hive feature flag — Hive is hidden from results unless explicitly enabled in settings. */ + hiveEnabled?: boolean } type ChainResult = { kind: "chain"; chain: ChainDef; balanceUsd: number } @@ -47,7 +49,7 @@ function scoreMatch(query: string, fields: { symbol?: string; coin?: string; nam return 0 } -export function CommandPalette({ open, onClose, onJumpToVault, balances, firmwareVersion }: CommandPaletteProps) { +export function CommandPalette({ open, onClose, onJumpToVault, balances, firmwareVersion, hiveEnabled }: CommandPaletteProps) { const { privateModeEnabled } = useFiat() const [query, setQuery] = useState("") const [activeIdx, setActiveIdx] = useState(0) @@ -66,7 +68,7 @@ export function CommandPalette({ open, onClose, onJumpToVault, balances, firmwar }, [open]) const results = useMemo(() => { - const chains = CHAINS.filter(c => !c.hidden && isChainSupported(c, firmwareVersion)) + const chains = CHAINS.filter(c => !c.hidden && isChainSupported(c, firmwareVersion) && (c.id !== 'hive' || hiveEnabled)) const trimmed = query.trim() if (!trimmed) { @@ -96,7 +98,7 @@ export function CommandPalette({ open, onClose, onJumpToVault, balances, firmwar } scored.sort((a, b) => b.score - a.score) return scored.slice(0, MAX_RESULTS).map(s => s.r) - }, [query, balances]) + }, [query, balances, hiveEnabled]) // Clamp active index when results change useEffect(() => { diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index b99d0a0d..1b639610 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -708,6 +708,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin const [showBip85, setShowBip85] = useState(false) const [bip85Enabled, setBip85Enabled] = useState(false) const [zcashEnabled, setZcashEnabled] = useState(false) + const [hiveEnabled, setHiveEnabled] = useState(false) // One-time passphrase/hidden-wallet intro. `passphraseIntroSeen` starts true so // the dialog never flashes before settings load; refreshFeatureFlags sets the // real value. `introDismissed` suppresses it for the rest of this session. @@ -866,6 +867,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin .then(s => { setBip85Enabled(s.bip85Enabled) setZcashEnabled(s.zcashPrivacyEnabled) + setHiveEnabled(s.hiveEnabled) setPassphraseIntroSeen(s.passphraseIntroShown) }) .catch(() => {}) @@ -1447,8 +1449,9 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin if (!isChainSupported(c, firmwareVersion)) return false // Zcash transparent is hidden by default — show when feature flag is on if (c.id === 'zcash') return zcashEnabled + if (c.id === 'hive') return hiveEnabled return !c.hidden - }), [allChains, firmwareVersion, zcashEnabled]) + }), [allChains, firmwareVersion, zcashEnabled, hiveEnabled]) const sortedChains = useMemo(() => [...visibleChains].sort((a, b) => { const aUsd = cleanBalanceUsd.get(a.id)?.usd || 0 diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index 6e6f5442..cc020d94 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -161,13 +161,14 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd const [removePinConfirm, setRemovePinConfirm] = useState(false) const [togglingPassphrase, setTogglingPassphrase] = useState(false) const [togglingPolicy, setTogglingPolicy] = useState("") - const [appSettings, setAppSettings] = useState({ restApiEnabled: false, pioneerApiBase: '', pioneerServers: [], activePioneerServer: '', fiatCurrency: 'USD', numberLocale: 'en-US', walletConnectEnabled: false, bip85Enabled: false, zcashPrivacyEnabled: false, emulatorEnabled: false, preReleaseUpdates: false, alphaFirmware: false, privateModeEnabled: false }) + const [appSettings, setAppSettings] = useState({ restApiEnabled: false, pioneerApiBase: '', pioneerServers: [], activePioneerServer: '', fiatCurrency: 'USD', numberLocale: 'en-US', walletConnectEnabled: false, bip85Enabled: false, zcashPrivacyEnabled: false, hiveEnabled: false, emulatorEnabled: false, preReleaseUpdates: false, alphaFirmware: false, privateModeEnabled: false }) const [togglingRestApi, setTogglingRestApi] = useState(false) const [windowFocusState, setWindowFocusState] = useState<{ refs: number; alwaysOnTop: boolean } | null>(null) const [releasingWindowFocus, setReleasingWindowFocus] = useState(false) const [togglingWalletConnect, setTogglingWalletConnect] = useState(false) const [togglingBip85, setTogglingBip85] = useState(false) const [togglingZcashPrivacy, setTogglingZcashPrivacy] = useState(false) + const [togglingHive, setTogglingHive] = useState(false) const [togglingEmulator, setTogglingEmulator] = useState(false) const [togglingPreRelease, setTogglingPreRelease] = useState(false) const [togglingAlphaFirmware, setTogglingAlphaFirmware] = useState(false) @@ -331,6 +332,15 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd setTogglingZcashPrivacy(false) }, []) + const toggleHive = useCallback(async (enabled: boolean) => { + setTogglingHive(true) + try { + const result = await rpcRequest("setHiveEnabled", { enabled }, 10000) + setAppSettings(result) + } catch (e: any) { console.error("setHiveEnabled:", e) } + setTogglingHive(false) + }, []) + const toggleEmulator = useCallback(async (enabled: boolean) => { setTogglingEmulator(true) try { @@ -1387,6 +1397,33 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd ) })()} + {/* Hive toggle — requires firmware >= 7.15.0 */} + {(() => { + const hiveFwOk = !!deviceState.firmwareVersion && versionCompare(deviceState.firmwareVersion, '7.15.0') >= 0 + return ( + + + + + + + + + Hive + + {hiveFwOk ? "Enable the Hive blockchain" : "Requires firmware 7.15.0 or later"} + + + + + + ) + })()} + {/* Emulator toggle — macOS-only dev tool, default off */} {IS_MAC && ( diff --git a/projects/keepkey-vault/src/shared/chains.ts b/projects/keepkey-vault/src/shared/chains.ts index eaa002ac..751ca38d 100644 --- a/projects/keepkey-vault/src/shared/chains.ts +++ b/projects/keepkey-vault/src/shared/chains.ts @@ -290,7 +290,7 @@ const CONFIGS: ChainConfig[] = [ defaultPath: [0x8000002C, 0x800004FB, 0x80000000, 0, 0], explorerAddressUrl: 'https://hiveblocks.com/@{{address}}', explorerTxUrl: 'https://hiveblocks.com/tx/{{txid}}', - minFirmware: '7.16.0', + minFirmware: '7.15.0', // Hive handler ships in firmware 7.15.0 (alpha), same as Zcash }, ] diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 71b56b7c..afc384ff 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -196,6 +196,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { setWalletConnectEnabled: { params: { enabled: boolean }; response: AppSettings } setBip85Enabled: { params: { enabled: boolean }; response: AppSettings } setZcashPrivacyEnabled: { params: { enabled: boolean }; response: AppSettings } + setHiveEnabled: { params: { enabled: boolean }; response: AppSettings } setEmulatorEnabled: { params: { enabled: boolean }; response: AppSettings } setPreReleaseUpdates: { params: { enabled: boolean }; response: AppSettings } setAlphaFirmware: { params: { enabled: boolean }; response: AppSettings } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 7347a87d..b1ebc474 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -629,6 +629,7 @@ export interface AppSettings { walletConnectEnabled: boolean // feature flag: WalletConnect dApp support (default OFF) bip85Enabled: boolean // feature flag: BIP-85 derived seeds (default OFF) zcashPrivacyEnabled: boolean // feature flag: Zcash shielded/privacy (default OFF, locked) + hiveEnabled: boolean // feature flag: Hive blockchain (default OFF, requires firmware >= 7.16.0) emulatorEnabled: boolean // feature flag: macOS emulator surface (default OFF — dev-only) preReleaseUpdates: boolean // opt-in to pre-release auto-updates (default OFF) alphaFirmware: boolean // opt-in to alpha firmware channel (manifest.beta) (default OFF) From 54ae5b79625c8f9e08a0de453a65a03ff8ea778b Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 16:00:23 -0500 Subject: [PATCH 12/53] fix(hive): honor feature flag across REST, cached balances, activity scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings — the settings flag gated UI display sites but several backend data paths still served Hive on firmware alone: - REST /api/health no longer advertises Hive, and /addresses/hive returns 403 when hive_enabled=0 (was firmware-only) — matches the /api/zcash gate. - getCachedBalances drops stale Hive rows when the flag is off, so disabling Hive after enabling it no longer leaks cached balances into portfolio totals. - Dashboard total (cleanBalanceUsd) skips Hive when off so stale in-memory rows from a live-refresh merge can't be counted while hidden from the grid; added an auto-refresh-on-enable effect mirroring Zcash. - Background + single-chain activity scans filter Hive when off (was deriving on any firmware-eligible device). - CommandPalette results memo now depends on firmwareVersion (was stale). - types.ts comment corrected 7.16.0 → 7.15.0. --- projects/keepkey-vault/src/bun/index.ts | 10 ++++++---- projects/keepkey-vault/src/bun/rest-api.ts | 4 +++- .../src/mainview/components/CommandPalette.tsx | 2 +- .../src/mainview/components/Dashboard.tsx | 17 ++++++++++++++++- projects/keepkey-vault/src/shared/types.ts | 2 +- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index a1dcf586..9ddc3b49 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -5710,7 +5710,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 }, }) @@ -5726,7 +5726,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 }, }) @@ -5801,7 +5801,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 } }, @@ -7206,7 +7208,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`) diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 95383fa8..953f5a86 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -1336,7 +1336,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, @@ -1987,6 +1987,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) diff --git a/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx index f537d8e3..f3eeee9c 100644 --- a/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx +++ b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx @@ -98,7 +98,7 @@ export function CommandPalette({ open, onClose, onJumpToVault, balances, firmwar } scored.sort((a, b) => b.score - a.score) return scored.slice(0, MAX_RESULTS).map(s => s.r) - }, [query, balances, hiveEnabled]) + }, [query, balances, hiveEnabled, firmwareVersion]) // Clamp active index when results change useEffect(() => { diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 1b639610..38936a54 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -1179,6 +1179,19 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin } }, [zcashEnabled, refreshBalances, loadingBalances]) + // Auto-refresh balances when Hive feature flag is enabled mid-session + const prevHiveRef = useRef(hiveEnabled) + useEffect(() => { + const becameEnabled = hiveEnabled && !prevHiveRef.current + if (becameEnabled && !loadingBalances) { + console.log('[Dashboard] Hive enabled — refreshing balances') + refreshBalances() + prevHiveRef.current = true + } else if (!hiveEnabled) { + prevHiveRef.current = false + } + }, [hiveEnabled, refreshBalances, loadingBalances]) + // Auto-refresh after new seed (OOB setup) — one-shot, then clear the flag useEffect(() => { if (forceRefresh && initialLoaded && !hasEverRefreshed && !loadingBalances) { @@ -1258,6 +1271,8 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin ) const result = new Map() for (const [chainId, bal] of balances) { + // Honor the Hive feature flag — stale Hive rows in the map must not count toward totals + if (chainId === 'hive' && !hiveEnabled) continue if (bal.tokens && bal.tokens.length > 0) { const { clean } = categorizeTokens(bal.tokens, overrides) const spamUsd = (bal.tokens.length - clean.length) > 0 @@ -1272,7 +1287,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin } } return result - }, [balances, visibilityMap]) + }, [balances, visibilityMap, hiveEnabled]) const totalUsd = useMemo(() => Array.from(cleanBalanceUsd.values()).reduce((sum, b) => sum + b.usd, 0), [cleanBalanceUsd]) diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index b1ebc474..69cee02c 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -629,7 +629,7 @@ export interface AppSettings { walletConnectEnabled: boolean // feature flag: WalletConnect dApp support (default OFF) bip85Enabled: boolean // feature flag: BIP-85 derived seeds (default OFF) zcashPrivacyEnabled: boolean // feature flag: Zcash shielded/privacy (default OFF, locked) - hiveEnabled: boolean // feature flag: Hive blockchain (default OFF, requires firmware >= 7.16.0) + hiveEnabled: boolean // feature flag: Hive blockchain (default OFF, requires firmware >= 7.15.0) emulatorEnabled: boolean // feature flag: macOS emulator surface (default OFF — dev-only) preReleaseUpdates: boolean // opt-in to pre-release auto-updates (default OFF) alphaFirmware: boolean // opt-in to alpha firmware channel (manifest.beta) (default OFF) From a04c5bdafeb81766694c0f41515704b23dfa49e1 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 16:20:02 -0500 Subject: [PATCH 13/53] fix(hive): correct derivation path to SLIP-0048 (Phase 0) + onboarding plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault derived Hive keys at m/44'/1275'/0'/0/0 (BIP-44) while firmware 7.15.0, Ledger, and Hive Keychain all use SLIP-0048 m/48'/13'/role'/account'/key'. The old path produced a STM key no other Hive tool derives from the same seed — non-interoperable, and Pioneer's pubkey→account lookup could never match an account made elsewhere. Safe breaking change: Hive is flag-gated default-off (PR #293), so zero accounts exist on v1 keys. - chains.ts: hive defaultPath → m/48'/13'/1'/0'/0' (active role) - chains.ts: add hiveRolePath(role, accountIndex) SLIP-0048 helper for the upcoming multi-key / account-creation work - docs/HIVE-ONBOARDING-PLAN.md: phased next-steps for self-run sponsor onboarding (decisions: KeepKey-owners-only gating, queue+circuit-breaker, single account, active-key only) GATE before flag-on: verify the derived STM active key matches Hive Keychain output for the same device seed. --- docs/HIVE-ONBOARDING-PLAN.md | 176 ++++++++++++++++++++ projects/keepkey-vault/src/shared/chains.ts | 12 +- 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 docs/HIVE-ONBOARDING-PLAN.md 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/projects/keepkey-vault/src/shared/chains.ts b/projects/keepkey-vault/src/shared/chains.ts index 751ca38d..9d6cf370 100644 --- a/projects/keepkey-vault/src/shared/chains.ts +++ b/projects/keepkey-vault/src/shared/chains.ts @@ -62,6 +62,14 @@ export function evmAddressPath(index: number): number[] { return [0x8000002C, 0x8000003C, 0x80000000 + index, 0, 0] } +// Hive uses SLIP-0048, not BIP-44: m/48'/13'/role'/account'/0' (all 5 hardened). +// Must match Ledger / Hive Keychain / firmware (hive.h HIVE_SLIP48_*), or derived +// keys are non-interoperable and unrecoverable in other tools. +const HIVE_ROLES = { owner: 0, active: 1, memo: 3, posting: 4 } as const +export function hiveRolePath(role: keyof typeof HIVE_ROLES, accountIndex = 0): number[] { + return [0x80000030, 0x8000000D, 0x80000000 + HIVE_ROLES[role], 0x80000000 + accountIndex, 0x80000000] +} + // Minimal per-chain config — everything else derived from pioneer-caip type ChainConfig = Omit @@ -287,7 +295,9 @@ const CONFIGS: ChainConfig[] = [ id: 'hive', chain: 'HIVE' as any, coin: 'Hive', symbol: 'HIVE', chainFamily: 'hive', color: '#E31337', rpcMethod: 'hiveGetPublicKey', signMethod: 'hiveSignTx', - defaultPath: [0x8000002C, 0x800004FB, 0x80000000, 0, 0], + // SLIP-0048 active-role key: m/48'/13'/1'/0'/0' (matches Ledger / Hive Keychain). + // Was m/44'/1275'/0'/0/0 (v1, BIP-44) — non-interoperable; see hiveRolePath(). + defaultPath: [0x80000030, 0x8000000D, 0x80000001, 0x80000000, 0x80000000], explorerAddressUrl: 'https://hiveblocks.com/@{{address}}', explorerTxUrl: 'https://hiveblocks.com/tx/{{txid}}', minFirmware: '7.15.0', // Hive handler ships in firmware 7.15.0 (alpha), same as Zcash From e8705578a5278b98068bdec93b3cc8f75a30060a Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 16:29:48 -0500 Subject: [PATCH 14/53] docs(hive): Pioneer sponsor-service handoff (Phase 2) --- docs/handoff-pioneer-hive-sponsor.md | 183 +++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/handoff-pioneer-hive-sponsor.md diff --git a/docs/handoff-pioneer-hive-sponsor.md b/docs/handoff-pioneer-hive-sponsor.md new file mode 100644 index 00000000..c781bf7d --- /dev/null +++ b/docs/handoff-pioneer-hive-sponsor.md @@ -0,0 +1,183 @@ +# 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.** The create endpoint must verify a device-signed + attestation, not just rate-limit. (See §6 — confirm the exact mechanism before coding.) +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 (CONFIRM BEFORE CODING — one open item) + +Decision is "only a genuine KeepKey." The available signal: + +- **Baseline (always available):** the request carries a payload the device signed during the + on-device `account_create` confirm (firmware `HiveSignAccountCreate` → 65-byte recoverable + sig). Pioneer verifies the signature recovers to the supplied `ownerKey`. This proves the + requester controls a full device-derived key set and completed a hardware confirmation. +- **Stronger (only if it exists):** a hardware **device-attestation** key/cert baked into + KeepKey firmware, verifiable against KeepKey's manufacturing CA. **Unconfirmed that KeepKey + exposes this** — verify with the firmware team. If it exists, require it; if not, baseline + signature + rate-limit + `sponsor-info` circuit-breaker is the v1 gate. + +**Action:** confirm with firmware whether a verifiable hardware attestation exists. Default to +the baseline mechanism if not. Document whichever you implement in the endpoint. + +--- + +## 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 +- [ ] Attestation verification implemented (mechanism confirmed with firmware) +- [ ] 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. From ca143123d5a0eceb7ff54b4498d49e7e2381c8ec Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 16:37:17 -0500 Subject: [PATCH 15/53] =?UTF-8?q?docs(hive):=20resolve=20=C2=A76=20attesta?= =?UTF-8?q?tion=20=E2=80=94=20no=20hardware=20attest=20on=20fw=207.15.0,?= =?UTF-8?q?=20baseline=20sig=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff-pioneer-hive-sponsor.md | 37 +++++++++++++++++----------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/handoff-pioneer-hive-sponsor.md b/docs/handoff-pioneer-hive-sponsor.md index c781bf7d..8342aeef 100644 --- a/docs/handoff-pioneer-hive-sponsor.md +++ b/docs/handoff-pioneer-hive-sponsor.md @@ -45,8 +45,9 @@ logic, no abuse defense** today. ## 3. Locked decisions (do not re-litigate) -1. **Gating = KeepKey owners only.** The create endpoint must verify a device-signed - attestation, not just rate-limit. (See §6 — confirm the exact mechanism before coding.) +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'`. @@ -118,21 +119,27 @@ Flow: --- -## 6. Device attestation (CONFIRM BEFORE CODING — one open item) +## 6. Device attestation (RESOLVED — baseline signature is the gate) -Decision is "only a genuine KeepKey." The available signal: +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. -- **Baseline (always available):** the request carries a payload the device signed during the - on-device `account_create` confirm (firmware `HiveSignAccountCreate` → 65-byte recoverable - sig). Pioneer verifies the signature recovers to the supplied `ownerKey`. This proves the - requester controls a full device-derived key set and completed a hardware confirmation. -- **Stronger (only if it exists):** a hardware **device-attestation** key/cert baked into - KeepKey firmware, verifiable against KeepKey's manufacturing CA. **Unconfirmed that KeepKey - exposes this** — verify with the firmware team. If it exists, require it; if not, baseline - signature + rate-limit + `sponsor-info` circuit-breaker is the v1 gate. +**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 verifies the signature recovers to the supplied `ownerKey`, over the exact +serialized op being broadcast. This proves control of a full device-derived key set + a +hardware confirmation. Combine with the §7 rate-limit and §5 circuit-breaker. -**Action:** confirm with firmware whether a verifiable hardware attestation exists. Default to -the baseline mechanism if not. Document whichever you implement in the endpoint. +**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. --- @@ -162,7 +169,7 @@ the baseline mechanism if not. Document whichever you implement in the endpoint. - [ ] `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 -- [ ] Attestation verification implemented (mechanism confirmed with firmware) +- [ ] 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 From 4b35abcb6ff8a2d89cbe5e9f4aa671ad59293703 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 17:43:53 -0500 Subject: [PATCH 16/53] docs(hive): attestation-digest spec for create-account verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-exact spec derived from firmware 7.15.0 hive.c: the digest the device signs (SHA256(chain_id||serialized_tx)), 65-byte sig recovery, account_create op-9 serialization layout, and Pioneer's verification algorithm. Clears the 'blocked on firmware attestation-digest' status — no firmware change needed; device returns serialized_tx, Pioneer verifies + parses the returned bytes. --- docs/HIVE-ATTESTATION-DIGEST-SPEC.md | 182 +++++++++++++++++++++++++++ docs/handoff-pioneer-hive-sponsor.md | 13 +- 2 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 docs/HIVE-ATTESTATION-DIGEST-SPEC.md 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/handoff-pioneer-hive-sponsor.md b/docs/handoff-pioneer-hive-sponsor.md index 8342aeef..b4ff2e61 100644 --- a/docs/handoff-pioneer-hive-sponsor.md +++ b/docs/handoff-pioneer-hive-sponsor.md @@ -131,9 +131,16 @@ attestation** Pioneer can verify (confirmed against the firmware tree 2026-06-26 **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 verifies the signature recovers to the supplied `ownerKey`, over the exact -serialized op being broadcast. This proves control of a full device-derived key set + a -hardware confirmation. Combine with the §7 rate-limit and §5 circuit-breaker. +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 From f77377dbf4cb66573695fabd335073fd207a72b0 Mon Sep 17 00:00:00 2001 From: Highlander Date: Fri, 26 Jun 2026 19:13:21 -0500 Subject: [PATCH 17/53] fix(windows-build): build proto-tx-builder dist (fixes 'createFeegrantAminoConverters is not a function') (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows production build's 'Building proto-tx-builder' step only ran `bun install` — it never built the dist (tsc). proto-tx-builder/dist is gitignored, so Windows shipped whatever stale pre-pin dist was on the box. v1.4.6/1.4.7/1.4.8 thus shipped a proto-tx-builder compiled BEFORE the @cosmjs/stargate Freegrant/Feegrant typo shim (f12f8c3), so every Cosmos send/delegate crashed with 'createFeegrantAminoConverters is not a function'. macOS was fixed in #290 (clear module stamps in build-signed); the Windows pipeline is separate and was never patched. Add `bun run build` after the install (mirrors the hdwallet yarn build right below) so Windows rebuilds the dist from the pinned source. Release hotfix — not RUJI-related; fast-track / cherry-pick to a release branch. --- scripts/build-windows-production.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/build-windows-production.ps1 b/scripts/build-windows-production.ps1 index 1cf52f1b..a50069ed 100644 --- a/scripts/build-windows-production.ps1 +++ b/scripts/build-windows-production.ps1 @@ -414,6 +414,14 @@ if (-not $SkipBuild) { Push-Location (Join-Path $RepoRoot "modules\proto-tx-builder") bun install if ($LASTEXITCODE -ne 0) { throw "bun install failed for proto-tx-builder (exit $LASTEXITCODE)" } + # Build the dist (tsc -p .). WITHOUT this, the gitignored dist/ ships STALE + # from a previous pin: this step only ran `bun install` and skipped the + # build, so v1.4.6/1.4.7/1.4.8 shipped a pre-fix proto-tx-builder on Windows + # and EVERY Cosmos tx crashed with "createFeegrantAminoConverters is not a + # function". macOS rebuilds via the Makefile (+#290 stamp-clear); Windows + # must run the build too (mirrors the hdwallet `yarn build` below). + bun run build + if ($LASTEXITCODE -ne 0) { throw "build failed for proto-tx-builder (exit $LASTEXITCODE) — if tsc can't resolve osmosis codecimpl, copy modules/proto-tx-builder/osmosis-frontend/src/proto/generated/codecimpl.{js,d.ts} as in the macOS setup" } Pop-Location Write-Step "Building hdwallet" From 0ffcb19bc1f4f87c9b5f04647649c689b9909bc2 Mon Sep 17 00:00:00 2001 From: Highlander Date: Fri, 26 Jun 2026 21:38:40 -0500 Subject: [PATCH 18/53] THORChain RUJI/TCY/secured-asset custom-denom signing (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(thorchain): RUJI/TCY/secured-asset custom-denom signing + hdwallet bump Adds a keepkey-sdk regression test exercising THORChain MsgSend with arbitrary denoms (TCY, RUJI/Rujira, secured assets like btc-btc) and MsgDeposit with memo against a running vault. Bumps the hdwallet submodule to the matching allow-any-denom MsgSend fix. * chore(hdwallet): pin to merged develop (allow-any-denom MsgSend) * feat(thorchain): send + display THORChain bank tokens (TCY, RUJI, secured assets) Threads a per-asset bank denom through the cosmos tx builder so a THORChain MsgSend can move TCY (denom 'tcy'), RUJI ('x/ruji') or secured assets — the fee stays in RUNE. Denom comes from a '/bank:' caip segment, parsed greedily so denoms containing '/' survive. Token MAX sends the full token balance (native fee is a separate RUNE balance, not reserved against the token). Display: classify '/bank:' caips as tokens so they group under THORChain like ERC-20s. Unit test covers RUNE-native regression + TCY/RUJI/MAX. Caip contract with Pioneer: cosmos:thorchain-mainnet-v1/bank:. Surfacing balances still requires Pioneer to enumerate bank balances in the portfolio (currently native-only). * fix(emulator): stop WKWebView SIGTRAP crash on confirm into a not-ready webview sendToWindow injected `window.handlePacket(...)` via executeJavascript; if the webview's page hadn't loaded handlePacket yet, the JS error crashed the WKWebView process with EXC_BREAKPOINT (SIGTRAP, exit 133) and took down the app. viewReady gated this but was only reset on window close — a stale `true` (e.g. webview rebuilt without a close event, as in an ethSignMessage confirm right after reopen) let the inject fire into a fresh, unloaded webview. - Guard the injected JS: `if(window.handlePacket)...` — a not-ready inject is now a no-op, never a WebKit crash. - Reset viewReady=false at openEmulatorWindow() so a reopen can't inherit a stale-ready gate. * fix(thorchain): match Pioneer '/denom:' caip scheme + un-flag TCY/RUJI as spam Pioneer emits bank tokens as cosmos:thorchain-mainnet-v1/denom: (e.g. denom:tcy, denom:x/ruji), not /bank:. Align the send denom extraction and the display token-denom regex to /denom: (bank: kept for forward-compat). Allowlist THORChain/Maya /denom: tokens in the spam filter — they're protocol-defined (no counterfeits possible) and not yet in the discovery catalog, so TCY/RUJI were wrongly flagged SCAM. * feat(swap): make THORChain TCY and RUJI tradable in the swapper THOR.TCY and THOR.RUJI have live THORChain pools but weren't selectable. - getSwapAssets: defensive shim adds THOR.TCY/THOR.RUJI (keyed by their /denom: caip) if Pioneer's available-assets omits them, mirroring the existing RUNE/TRON shims — so they appear in the picker under THORChain. - assessAvailability: allowlist the two pooled /denom: caips as swappable via thorchain. Scoped to TCY/RUJI only — other non-pooled thorchain denom tokens correctly stay unsupported. Quotes route by caip through Pioneer.Quote (THOR.TCY/THOR.RUJI pools verified on thornode). * fix(thorchain): send/swap TCY & RUJI correctly (denom: token + MsgDeposit) Two confirmed bugs sending/swapping THORChain bank tokens: 1. SendForm.isTokenSend didn't recognize the /denom: namespace, so picking TCY left token-mode off: the amount showed RUNE and the caip never reached the builder — it built a RUNE send. Add /denom: to the check. 2. Swapping FROM TCY/RUJI failed 'Missing inbound vault address from quote'. These are on-chain bank tokens: Pioneer's quote returns txs[0].type= 'deposit' with no inbound address (MsgDeposit). isNativeDepositCaip only matched RUNE/CACAO slip44 — extend it to thorchain/maya /denom: caips. The MsgDeposit coins asset was also hardcoded THOR.RUNE; thread the real asset (THOR.TCY/THOR.RUJI) via a new depositAsset param so the network takes the right coin. cosmos.test.ts: +MsgDeposit case (asset=THOR.TCY, swap memo). 10/10. * fix(swap): TCY/RUJI quote parser threw 'missing inbound address' too The quote parser (swap-parsing.ts) had its OWN isNativeDeposit check matching only RUNE/CACAO slip44 — it threw before the build path's check (already fixed) ever ran, so from-TCY/RUJI swaps still failed. Dedupe: single exported isNativeDepositCaip in swap-parsing.ts (covers RUNE/CACAO + thorchain/maya /denom: bank tokens), imported by swap.ts. One source of truth so the parser and builder can't disagree again. Removes now-orphaned RUNE_CAIP/CACAO_CAIP constants. * fix(swap+emulator): correct deposit preview asset + don't reject confirm before webview ready - swap preview built the MsgDeposit with the default THOR.RUNE instead of THOR.TCY/THOR.RUJI (depositAsset wasn't threaded on the preview path, only execute). Preview now shows the real coin. - Emulator confirm waited only 5s for a freshly-opened webview to post /_emu/ready, then auto-rejected ('Transaction rejected by user'). A cold webview on make dev exceeds that. Bump to 20s (loop still exits the instant viewReady flips, so it only raises the ceiling) so a swap confirm isn't spuriously rejected before the window is up. * fix(windows-build): build proto-tx-builder dist (fixes 'createFeegrantAminoConverters is not a function') The Windows production build's 'Building proto-tx-builder' step only ran `bun install` — it never built the dist (tsc). proto-tx-builder/dist is gitignored, so Windows shipped whatever stale pre-pin dist was on the box. v1.4.6/1.4.7/1.4.8 thus shipped a proto-tx-builder compiled BEFORE the @cosmjs/stargate Freegrant/Feegrant typo shim (f12f8c3), so every Cosmos send/delegate crashed with 'createFeegrantAminoConverters is not a function'. macOS was fixed in #290 (clear module stamps in build-signed); the Windows pipeline is separate and was never patched. Add `bun run build` after the install (mirrors the hdwallet yarn build right below) so Windows rebuilds the dist from the pinned source. Release hotfix — not RUJI-related; fast-track / cherry-pick to a release branch. * feat(thorchain): gate TCY/RUJI behind firmware 7.15 (fund safety) A bank-token MsgSend's denom field is honored only from firmware 7.15.0; on older firmware it's ignored and the tx signs as RUNE — the user would move RUNE thinking they moved TCY. Gate the whole TCY/RUJI feature on 7.15 using the existing minFirmware/assessWithFirmware pattern, extended to the asset level (RUNE itself stays usable on older fw). Single shared helper thorchainBankTokenFirmwareOK() in swap-support-matrix, enforced at three layers: - assessWithFirmware: dims TCY/RUJI in the swap picker with an upgrade hint - headlessSwapQuote: refuses a quote when selling a bank token on old fw - buildTx handler: backend fund-safety net — refuses to build the send/swap tx, so a bad asset can never reach signing 11/11 gate tests; assessWithFirmware verified (7.14→unsupported, 7.15→ok). * fix(hdwallet): pin to e39ff8cb (master + RUJI denom fix), not the develop squash PR review caught that the prior pin 8c8e5b01 was a develop squash-merge that does NOT have c5a4d79b (hdwallet master) as an ancestor — it dropped master-only work (WebUSB clearHalt, Zcash, TON/TRON, BIP-85, message-type registry, EVM clear-signing). e39ff8cb is c5a4d79b + ONLY the denom fix, so the bump is now a fast-forward that preserves all master history. * fix(swap): TCY/RUJI token identity + enforce firmware gate at execute time Two PR-review release blockers: - Token identity (was falling through to RUNE balance): the getSwapAssets shim injected THOR.TCY/THOR.RUJI without a contractAddress, so SwapDialog's contractAddress-keyed token lookup missed them and used the native RUNE balance for max/validation/price. Set contractAddress to the bank denom ('tcy' / 'x/ruji') — matches the portfolio token's contractAddress (the denom, set by getBalances), so balance now resolves to the actual token. - Execute-time firmware gate: the 7.15 bank-token gate was quote-only, but /api/v2/swap/execute (rest-swap.ts) calls headlessExecuteSwap directly, bypassing the quote check. A stale/crafted execute payload could reach signing on old firmware that signs the wrong asset. Re-check the invariant at execute time too (mirrors headlessSwapQuote + buildTx). * chore(deps): bump pioneer-discovery 10.0.9 → 10.3.1 (RUJI/TCY caips + icons) 10.3.1 carries the correct THORChain bank-token caips (cosmos:thorchain-mainnet-v1/denom:{tcy,x/ruji}) WITH icons and zero stale thorchain-1/slip44:ruji — so RUJI/TCY now resolve names+icons from the discovery catalog (fixes the broken portfolio icon) and are no longer flagged spam at the source. 10.3.1 is the clean republish of 10.3.0 (which shipped a broken @pioneer-platform/pioneer-caip: workspace:* that no external consumer could install). Drop the direct pioneer-caip dep — it resolves transitively via discovery's ^9.27.10. bun.lock is gitignored; release does a fresh resolve. NOTE: re-validate the Windows MAX_PATH 260-assertion on the release build — discovery's nested ethers/@ethersproject chain is the recent (v1.4.5) long-path offender; PR #280's dedup should absorb it but confirm. * chore(hdwallet): bump to 25db4467 (RUJI denom fix + EIP-712 eth-sig-util→eip-712) Submodule now carries both fixes on fix/thorchain-any-denom (ff-safe descendant of hdwallet master c5a4d79b): the THORChain bank-token denom support and the EIP-712 struct-hash swap that drops @metamask/eth-sig-util + its nested @ethereumjs@4 tree (Windows MAX_PATH cleanup). * chore(hdwallet): re-pin to 91a9e9d6 (EIP-712 commit amended: prettier lint fix) Same two ff-safe commits (RUJI denom + EIP-712 swap); the EIP-712 commit was amended to satisfy the hdwallet prettier/lint CI on the regression test. No logic change. Old 25db4467 superseded. * fix(swap): import pioneer-discovery chains via ./chains export (10.3.1 exports map) pioneer-discovery 10.2.0+ added a strict package exports map; the raw deep path @pioneer-platform/pioneer-discovery/lib/chains.json is no longer resolvable (vite: Missing "./lib/chains.json" specifier), breaking the vault build after the 10.3.1 bump. Use the exposed ./chains subpath (same lib/chains.json, with types). Only deep import in the tree; the other three discovery imports use the main entry. * chore(device-protocol): pin fork master 98ca1e2 (ThorchainMsgSend.denom) Bumps modules/device-protocol f2c3c005 -> 98ca1e2 (BitHighlander/device-protocol origin/master). Adds the denom field to ThorchainMsgSend (proto commit c1dea44), which hdwallet's send.setDenom() needs for TCY/RUJI/secured-asset MsgSend. lib/ is a gitignored build artifact; 'make' regenerates it via 'npm run build' (build:js + postprocess; the broken build:json is not in that path), so the shipped lib gets setDenom from a clean checkout. Verified on-device: ruji.js 8/8 (rune/tcy/rujira/btc-btc/eth-usdc MsgSend + MsgDeposit + charset-guard reject). --- modules/device-protocol | 2 +- modules/hdwallet | 2 +- projects/keepkey-sdk/tests/thorchain/ruji.js | 101 ++++++++++++++++++ projects/keepkey-vault/package.json | 3 +- .../keepkey-vault/src/bun/emulator-window.ts | 21 +++- projects/keepkey-vault/src/bun/index.ts | 40 ++++++- .../keepkey-vault/src/bun/swap-parsing.ts | 19 +++- projects/keepkey-vault/src/bun/swap.ts | 52 ++++++--- .../src/bun/txbuilder/cosmos.test.ts | 65 +++++++++++ .../keepkey-vault/src/bun/txbuilder/cosmos.ts | 44 ++++++-- .../keepkey-vault/src/bun/txbuilder/index.ts | 6 +- .../src/mainview/components/SendForm.tsx | 2 +- .../keepkey-vault/src/shared/spamFilter.ts | 8 ++ .../src/shared/swap-discovery.ts | 17 ++- .../src/shared/swap-support-matrix.test.ts | 39 +++++++ .../src/shared/swap-support-matrix.ts | 39 +++++++ 16 files changed, 421 insertions(+), 39 deletions(-) create mode 100644 projects/keepkey-sdk/tests/thorchain/ruji.js create mode 100644 projects/keepkey-vault/src/bun/txbuilder/cosmos.test.ts create mode 100644 projects/keepkey-vault/src/shared/swap-support-matrix.test.ts 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..91a9e9d6 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit c5a4d79b16de1231926c3270ffa75a1fb092c28e +Subproject commit 91a9e9d6796a3ad4c6b09744660412c31c3aa76d 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/package.json b/projects/keepkey-vault/package.json index 05bed94d..0faea461 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -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/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/index.ts b/projects/keepkey-vault/src/bun/index.ts index 9ddc3b49..7c9ddeae 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1203,6 +1203,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 ────────────────────────────────────────── @@ -1454,6 +1455,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. @@ -1573,6 +1585,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) @@ -2813,7 +2837,8 @@ const rpc = BrowserView.defineRPC({ // 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]+)/) + // denom: "cosmos:thorchain-mainnet-v1/denom:tcy" → "tcy" (cosmos bank denom) + const contractMatch = (tok.caip || '').match(/\/(erc20|spl|trc20|token|denom|bank):([^\s]+)/) const contractAddress = contractMatch?.[2] || tok.contract || undefined const rawValueUsd = tok.valueUsd @@ -3700,6 +3725,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, 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/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..979af643 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 } diff --git a/projects/keepkey-vault/src/mainview/components/SendForm.tsx b/projects/keepkey-vault/src/mainview/components/SendForm.tsx index 23074f0d..f454fcd7 100644 --- a/projects/keepkey-vault/src/mainview/components/SendForm.tsx +++ b/projects/keepkey-vault/src/mainview/components/SendForm.tsx @@ -124,7 +124,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve }, [chain.chainFamily, tokenCaip]) // Derived display values — token mode vs native mode - const isTokenSend = !!(token && token.caip && !token.caip.endsWith('/slip44:501') && (token.caip.includes('erc20') || token.caip.includes('/token:') || token.caip.includes('/spl:') || token.caip.includes('/trc20:'))) + const isTokenSend = !!(token && token.caip && !token.caip.endsWith('/slip44:501') && (token.caip.includes('erc20') || token.caip.includes('/token:') || token.caip.includes('/spl:') || token.caip.includes('/trc20:') || token.caip.includes('/denom:'))) const displaySymbol = isTokenSend ? token!.symbol : chain.symbol const displayBalance = isTokenSend ? token!.balance : (balance?.balance || '0') // Fee controls: presets where a builder honors feeLevel; free-form custom only where diff --git a/projects/keepkey-vault/src/shared/spamFilter.ts b/projects/keepkey-vault/src/shared/spamFilter.ts index 08b765d2..6fed06f8 100644 --- a/projects/keepkey-vault/src/shared/spamFilter.ts +++ b/projects/keepkey-vault/src/shared/spamFilter.ts @@ -39,6 +39,14 @@ export function detectSpamToken( // Synthetic tokens injected by the vault that will never appear in the discovery catalog. if (caip.includes('/orchard:')) return { isSpam: false, level: null, reason: 'Synthetic shielded token' } + // THORChain/Maya bank-module tokens (TCY, RUJI, secured assets) use a + // `/denom:` caip. They're protocol-defined assets — not arbitrarily + // deployable like ERC-20s, so a counterfeit "TCY" can't exist. The discovery + // catalog doesn't list them yet; allowlist them so they aren't flagged spam. + if (caip.includes('/denom:') && (caip.startsWith('cosmos:thorchain-') || caip.startsWith('cosmos:mayachain-'))) { + return { isSpam: false, level: null, reason: 'THORChain/Maya native bank token' } + } + // Discovery emits BSC tokens as /bep20:; Pioneer portfolio returns /erc20: for the same assets. const lookupCaip = caip.replace(/^eip155:56\/erc20:/, 'eip155:56/bep20:') if (DISCOVERY_SET.has(lookupCaip)) return { isSpam: false, level: null, reason: 'In discovery catalog' } diff --git a/projects/keepkey-vault/src/shared/swap-discovery.ts b/projects/keepkey-vault/src/shared/swap-discovery.ts index 59e1ae56..6e423bd3 100644 --- a/projects/keepkey-vault/src/shared/swap-discovery.ts +++ b/projects/keepkey-vault/src/shared/swap-discovery.ts @@ -13,12 +13,15 @@ import type { SwapAsset, ChainBalance, CustomToken } from './types' import { CHAINS, isChainSupported, type ChainDef } from './chains' import { COIN_MAP_LONG } from '@pioneer-platform/pioneer-coins' -import { assessAvailability, normalizeChainCaip2, CHAIN_CAIP2_ALIASES, type AvailabilityAssessment } from './swap-support-matrix' +import { assessAvailability, normalizeChainCaip2, CHAIN_CAIP2_ALIASES, isThorchainBankToken, thorchainBankTokenFirmwareOK, THORCHAIN_BANK_TOKEN_MIN_FW, type AvailabilityAssessment } from './swap-support-matrix' // Static-imported chains metadata — ~218KB, used synchronously by // networkDisplayName + chainMetaForCaip2 from both bun and frontend. Vite // inlines as a JSON module. The bigger generatedAssetData.json (~10MB) stays // lazy because the picker is the only consumer and the user may never open it. -import discoveryChainsJson from '@pioneer-platform/pioneer-discovery/lib/chains.json' +// pioneer-discovery 10.2.0+ ships a strict package `exports` map: the chains +// catalog is exposed only via the `./chains` subpath (→ lib/chains.json), not +// the raw `/lib/...` deep path, which vite/Node now reject. +import discoveryChainsJson from '@pioneer-platform/pioneer-discovery/chains' /** Vault chain id → canonical THORChain short prefix. Built from * `pioneer-coins` `COIN_MAP_LONG` (which maps THOR prefix → chain id) plus @@ -414,6 +417,16 @@ function chainDefForCaip(caip: string): ChainDef | undefined { export function assessWithFirmware(caip: string, firmwareVersion?: string): AvailabilityAssessment { const base = assessAvailability(caip) if (base.status !== 'swappable' && base.status !== 'unknown') return base + // Asset-level gate: THORChain bank tokens (TCY, RUJI) require firmware 7.15+ + // even though the THORChain *chain* has no minFirmware (RUNE works on older + // fw). Older firmware would sign a TCY MsgSend as RUNE — fund-safety gate. + if (isThorchainBankToken(caip) && !thorchainBankTokenFirmwareOK(caip, firmwareVersion)) { + return { + status: 'unsupported_firmware', + providers: [], + reason: `Update your KeepKey to firmware ${THORCHAIN_BANK_TOKEN_MIN_FW}+ to trade TCY / RUJI`, + } + } const chain = chainDefForCaip(caip) if (!chain?.minFirmware) return base if (isChainSupported(chain, firmwareVersion)) return base diff --git a/projects/keepkey-vault/src/shared/swap-support-matrix.test.ts b/projects/keepkey-vault/src/shared/swap-support-matrix.test.ts new file mode 100644 index 00000000..8f07d226 --- /dev/null +++ b/projects/keepkey-vault/src/shared/swap-support-matrix.test.ts @@ -0,0 +1,39 @@ +/** + * Routability of THORChain bank tokens (TCY, RUJI) in the swap support matrix. + * Run: bun src/shared/swap-support-matrix.test.ts + */ +import { assessAvailability, thorchainBankTokenFirmwareOK, isThorchainBankToken } from './swap-support-matrix' + +let pass = 0, fail = 0 +function eq(label: string, got: unknown, want: unknown) { + if (JSON.stringify(got) === JSON.stringify(want)) { console.log(` ✅ ${label}`); pass++ } + else { console.error(` ❌ ${label}: got ${JSON.stringify(got)} want ${JSON.stringify(want)}`); fail++ } +} + +const tcy = assessAvailability('cosmos:thorchain-mainnet-v1/denom:tcy') +eq('TCY swappable via thorchain', [tcy.status, tcy.providers], ['swappable', ['thorchain']]) + +const ruji = assessAvailability('cosmos:thorchain-mainnet-v1/denom:x/ruji') +eq('RUJI swappable via thorchain', [ruji.status, ruji.providers], ['swappable', ['thorchain']]) + +const rune = assessAvailability('cosmos:thorchain-mainnet-v1/slip44:931') +eq('RUNE native still swappable', rune.status, 'swappable') + +// Only the two pooled bank tokens are allowlisted — a random thorchain denom +// (pool/vault token, no THOR pool) must NOT be claimed swappable. +const rand = assessAvailability('cosmos:thorchain-mainnet-v1/denom:x/bow-xyk-x/ruji-rune') +eq('random non-pooled denom NOT auto-swappable', rand.status, 'unsupported_token') + +// ── Firmware gate (7.15+ for bank tokens) ── +const TCY = 'cosmos:thorchain-mainnet-v1/denom:tcy' +const RUNE = 'cosmos:thorchain-mainnet-v1/slip44:931' +eq('TCY is a bank token', isThorchainBankToken(TCY), true) +eq('RUNE is NOT a bank token', isThorchainBankToken(RUNE), false) +eq('TCY blocked on fw 7.14.0', thorchainBankTokenFirmwareOK(TCY, '7.14.0'), false) +eq('TCY blocked on unknown fw', thorchainBankTokenFirmwareOK(TCY, undefined), false) +eq('TCY allowed on fw 7.15.0', thorchainBankTokenFirmwareOK(TCY, '7.15.0'), true) +eq('TCY allowed on fw 7.16.2', thorchainBankTokenFirmwareOK(TCY, '7.16.2'), true) +eq('RUNE never firmware-gated (old fw OK)', thorchainBankTokenFirmwareOK(RUNE, '7.10.0'), true) + +console.log(`\n Result: ${pass} passed, ${fail} failed\n`) +process.exit(fail > 0 ? 1 : 0) diff --git a/projects/keepkey-vault/src/shared/swap-support-matrix.ts b/projects/keepkey-vault/src/shared/swap-support-matrix.ts index 24dfee92..30014b01 100644 --- a/projects/keepkey-vault/src/shared/swap-support-matrix.ts +++ b/projects/keepkey-vault/src/shared/swap-support-matrix.ts @@ -17,6 +17,8 @@ * is purely a *predictive* hint, not a contract. */ +import { versionCompare } from './firmware-versions' + export type SwapProvider = | 'thorchain' | 'mayachain' @@ -223,6 +225,38 @@ const THORCHAIN_TOKEN_PREFIXES = [ 'tron:0x2b6653dc/token:', ] +// THORChain bank-module tokens with live THORChain pools (THOR.TCY, THOR.RUJI). +// They route via THORChain like any pooled asset but carry a `/denom:` caip, so +// they don't match the token prefixes above. Other thorchain `/denom:` assets +// (random x/ pool/vault tokens) are NOT pooled, so allowlist explicitly. +const THORCHAIN_DENOM_ASSETS = new Set([ + 'cosmos:thorchain-mainnet-v1/denom:tcy', + 'cosmos:thorchain-mainnet-v1/denom:x/ruji', +]) + +// ── Firmware gate for THORChain/Maya bank tokens (TCY, RUJI) ────────────── +// +// Sending a bank token is a MsgSend whose `denom` field the firmware honors +// only from 7.15.0. On older firmware that field is ignored and the tx signs +// as RUNE — the user thinks they're moving TCY but they'd move RUNE (or it +// fails). That's a fund-safety gap, so the whole TCY/RUJI feature (send + +// swap) is gated to 7.15+. Single source of truth used by the swap matrix and +// the backend build/sign path. +export const THORCHAIN_BANK_TOKEN_MIN_FW = '7.15.0' + +/** A THORChain/Maya bank-module token (TCY, RUJI, secured assets) — `/denom:` caip. */ +export function isThorchainBankToken(caip: string): boolean { + return (caip.startsWith('cosmos:thorchain-') || caip.startsWith('cosmos:mayachain-')) + && caip.includes('/denom:') +} + +/** True if the connected firmware can safely sign this asset. Non-bank-tokens + * are always OK; bank tokens require firmware ≥ 7.15.0 (unknown fw → not OK). */ +export function thorchainBankTokenFirmwareOK(caip: string, firmwareVersion?: string): boolean { + if (!isThorchainBankToken(caip)) return true + return !!firmwareVersion && versionCompare(firmwareVersion, THORCHAIN_BANK_TOKEN_MIN_FW) >= 0 +} + // ── Public API ────────────────────────────────────────────────────────── /** Given a CAIP-19 asset id, decide which providers route it (if any) and @@ -265,6 +299,11 @@ export function assessAvailability(caip: string): AvailabilityAssessment { } } + // THORChain bank tokens (TCY, RUJI) — pooled, routable via THORChain. + if (THORCHAIN_DENOM_ASSETS.has(normalizedCaip) && has('thorchain', chainId)) { + return { status: 'swappable', providers: ['thorchain'] } + } + // Token path. Token-specific providers first, then fall through. Use the // chain-normalized CAIP for the well-known stablecoin lookup so TRON USDT // matches whether the input encoded TRON as base58 or hex. From 85c0d1cd88d347f98853dd7de31fbfffa8d88c40 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 26 Jun 2026 22:09:25 -0500 Subject: [PATCH 19/53] chore: bump version to 1.4.9 --- projects/keepkey-vault/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 0faea461..f119820c 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.4.8", + "version": "1.4.9", "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", From e9e5235cbda57958bd50c57a3b024655b931665b Mon Sep 17 00:00:00 2001 From: Highlander Date: Sun, 28 Jun 2026 21:47:15 -0500 Subject: [PATCH 20/53] feat(hive): sponsor-backed account onboarding + ETH anti-drain gate (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hive): sponsor-backed account onboarding + ETH anti-drain gate Hive receive/onboarding panel (HiveAccountPanel) on the Hive asset page: resolves the device's active key to an account via Pioneer, or runs the in-app sponsor onboarding wizard (live username availability, device-derived SLIP-0048 role keys, @keepkey-sponsored on-chain creation; keys never leave the device). Backend RPCs: hiveGetRoleKeys, hiveGetAccount, hiveUsernameAvailable, hiveCreateAccount (device owner-signed account_create attestation -> Pioneer sponsor endpoint). hiveSignTx/hiveSignAccountCreate route through emuSigningOp on the emulator for the interactive confirm gate. ETH anti-drain gate: hiveCreateAccount signs a fixed EIP-191 message bound to username+ownerKey with the device ETH key (m/44'/60'/0'/0/0) and sends ethAddress+ethSignature. The server requires the address to hold mainnet ETH (one sponsored account per funded address) to keep the free service from being drained. 403 surfaces a dedicated 'fund your ETH address' screen. Bumps hdwallet 91a9e9d -> d51727e4 (master) for the Hive account-lifecycle wallet methods (keepkey/hdwallet#50). * fix(hive): address PR review — success poll, derive-error surfacing, username race - P1: after a successful create, poll hiveGetAccount (backing off, 90s cap) and only hand off to the parent once the account resolves, instead of a single 4.5s refresh that could bounce a real success back to the wizard when the pubkey->account lookup lagged. Poll halts on unmount. - P2: surface address-derivation failures instead of an infinite spinner — the panel now takes loading/deriveError/onRetryDerive; a null activeKey with an error (or no in-flight derive) shows an actionable Retry. - P2: ignore stale username-availability responses (tag avail with its name + latest-name guard) so a late 'available' can't enable creation for a different, unchecked name. create() guard hardened to match. * fix(hive): guard the View account button against the success bounce too The auto-poll already waited for hiveGetAccount to resolve before handing off, but the manual 'View account' button still called onCreated blind — a click before Pioneer resolved the pubkey would refresh()->noAccount->wizard, unmounting the celebration. Both paths now share a guarded resolveToAccount(): the button does the same lookup, keeps the success screen on noAccount, and shows a 'still finalizing' hint instead of bouncing. --- modules/hdwallet | 2 +- projects/keepkey-vault/src/bun/index.ts | 121 ++++++- .../src/mainview/components/AssetPage.tsx | 4 + .../mainview/components/HiveAccountPanel.tsx | 308 ++++++++++++++++++ .../keepkey-vault/src/shared/rpc-schema.ts | 4 + 5 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 projects/keepkey-vault/src/mainview/components/HiveAccountPanel.tsx diff --git a/modules/hdwallet b/modules/hdwallet index 91a9e9d6..d51727e4 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 91a9e9d6796a3ad4c6b09744660412c31c3aa76d +Subproject commit d51727e4a5047c1676f73cf671e6269ba6163175 diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 7c9ddeae..18713983 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -121,7 +121,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" @@ -2383,9 +2383,126 @@ 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). + 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 }) + const ethSigned = engine.isEmulator + ? await emuSigningOp(signEthGate, { operation: 'ethSignMessage', chain: 'Ethereum', memo: gateMessage.slice(0, 64) }) + : await signEthGate() + // hdwallet returns { address: eip55 0x…, signature: 0x…+65-byte r||s||v } — + // exactly the form ethers.verifyMessage expects. + 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) }, + ethAddress: ethSigned.address, + ethSignature: ethSigned.signature, + }), + }) + const body = await resp.json().catch(() => ({})) + 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 diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 16609b5d..36e1f002 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -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 @@ -200,6 +201,7 @@ 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) => { @@ -1127,6 +1129,8 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi }> + ) : isHive ? ( + ) : ( + {pieces.map((_, i) => { + const left = Math.round((i * 137.5) % 100) + const delay = (i % 10) * 0.06 + const dur = 1.6 + (i % 7) * 0.18 + const color = colors[i % colors.length] + const size = 6 + (i % 3) * 3 + return ( + + ) + })} + + + ) +} +type Avail = { success: boolean; available: boolean; reason?: string } +type CreateResp = { status: number; success?: boolean; txid?: string; username?: string; error?: string; retryAfter?: number } + +function KeyRow({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false) + return ( + + {label} + {value} + { navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1200) }}> + + + + ) +} + +/** + * Hive receive / onboarding panel. Hive is account-based (you receive to an + * @username, not a key). Resolves the device's active key to an account via + * Pioneer and shows account view, or the in-app sponsor onboarding wizard. + */ +export function HiveAccountPanel({ activeKey, color, loading, deriveError, onRetryDerive }: { + activeKey: string | null; color: string + loading?: boolean; deriveError?: string | null; onRetryDerive?: () => void +}) { + const [state, setState] = useState<"loading" | "has" | "none" | "error">("loading") + const [account, setAccount] = useState(null) + + const refresh = () => { + if (!activeKey) return + setState("loading") + rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000) + .then(r => { if (r.account) { setAccount(r.account); setState("has") } else setState("none") }) + .catch(() => setState("error")) + } + useEffect(() => { let c = false; if (activeKey) { rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000).then(r => { if (c) return; if (r.account) { setAccount(r.account); setState("has") } else setState("none") }).catch(() => { if (!c) setState("error") }) } return () => { c = true } }, [activeKey]) + + // No active key yet: distinguish a failed/absent derivation (actionable) from + // one still in flight (transient spinner) — otherwise a null key spins forever. + if (!activeKey) { + if (deriveError || !loading) return ( + + {deriveError || "Couldn't derive your Hive key from the device."} + {onRetryDerive && } + + ) + return + } + + if (state === "loading") return + + if (state === "error") return ( + + Couldn't reach the Hive account service. Try again shortly. + + + ) + + if (state === "has" && account) return ( + + Hive Account + @{account.name} + + HIVE{account.hive} + HBD{account.hbd} + {account.hp != null && HP{account.hp}} + {account.rcPercent != null && RC{account.rcPercent}%} + + + ) + + return +} + +function HiveOnboardWizard({ color, activeKey, onCreated }: { color: string; activeKey: string; onCreated: () => void }) { + const [keys, setKeys] = useState(null) + const [showKeys, setShowKeys] = useState(false) + const [username, setUsername] = useState("") + // avail is tagged with the name it describes, so a late/out-of-order response + // for an old input can't enable creation for the current (unchecked) name. + const [avail, setAvail] = useState<(Avail & { name: string }) | null>(null) + const [checking, setChecking] = useState(false) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + const [needsFunding, setNeedsFunding] = useState(null) // 0x ETH address to fund, or null + const [created, setCreated] = useState<{ name: string; txid?: string } | null>(null) + const [viewing, setViewing] = useState(false) // "View account" lookup in flight + const [viewMsg, setViewMsg] = useState(null) + const debounce = useRef | null>(null) + const latestName = useRef("") // current input; guards stale availability responses + const stopped = useRef(false) // set on unmount to halt the success poll + + useEffect(() => { rpcRequest("hiveGetRoleKeys", {}, 30000).then(setKeys).catch(() => {}) }, []) + useEffect(() => () => { stopped.current = true }, []) + + // Debounced availability check as the user types. + useEffect(() => { + setAvail(null); setError(null) + if (debounce.current) clearTimeout(debounce.current) + const name = username.trim().toLowerCase() + latestName.current = name + if (!name) { setChecking(false); return } + setChecking(true) + debounce.current = setTimeout(() => { + rpcRequest("hiveUsernameAvailable", { name }, 10000) + // Only apply if the input hasn't changed since this request fired. + .then(r => { if (latestName.current === name) setAvail({ ...r, name }) }) + .catch(() => { if (latestName.current === name) setAvail(null) }) + .finally(() => { if (latestName.current === name) setChecking(false) }) + }, 400) + return () => { if (debounce.current) clearTimeout(debounce.current) } + }, [username]) + + // Guarded hand-off: only leave the success screen once Pioneer actually + // resolves the new account. Shared by the auto-poll and the "View account" + // button so neither can bounce the celebration back to the wizard. + const resolveToAccount = async (): Promise => { + try { + const rr = await rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000) + if (rr.account) { onCreated(); return true } + } catch { /* not resolved yet */ } + return false + } + + const onViewAccount = async () => { + if (viewing) return + setViewing(true); setViewMsg(null) + const ok = await resolveToAccount() + if (stopped.current) return + setViewing(false) + if (!ok) setViewMsg("Still finalizing on-chain — give it a few seconds and try again.") + } + + const create = async () => { + const name = username.trim().toLowerCase() + if (!name || avail?.available !== true || avail.name !== name || creating) return + setCreating(true); setError(null); setNeedsFunding(null) + try { + const r = await rpcRequest("hiveCreateAccount", { username: name }, 120000) + if (r.status === 200 && (r.success || r.txid)) { + setCreated({ name, txid: r.txid }) + // Account is on-chain but the pubkey→account lookup may lag a block or two. + // Poll (backing off) and only hand off to the parent once it resolves — + // otherwise the parent would see noAccount and bounce back to this wizard. + // If it never resolves, the celebration stays with its "View account" button. + const deadline = Date.now() + 90000 + const poll = (delay: number) => setTimeout(async () => { + if (stopped.current) return + const ok = await resolveToAccount() + if (!ok && !stopped.current && Date.now() < deadline) poll(Math.min(delay * 1.5, 8000)) + }, delay) + poll(2500) + return + } + // 403: ETH gate — the device's ETH address must hold mainnet ETH (one + // sponsored account per funded address). Show a dedicated fund-me screen. + if (r.status === 403) { + rpcRequest<{ addresses: { addressIndex: number; address: string }[] }>("getEvmAddresses", undefined, 10000) + .then(s => setNeedsFunding(s.addresses.find(a => a.addressIndex === 0)?.address ?? "")) + .catch(() => setNeedsFunding("")) + } + // 409 is overloaded: either the username was just taken, or this ETH + // address already claimed its one sponsored account. Server error text + // disambiguates; reformat-fail back to name-taken. + else if (r.status === 409) { + if (/eth|address/i.test(r.error ?? "")) setError("This device already has a sponsored Hive account.") + else { setError("That name was just taken — pick another."); setAvail({ success: true, available: false, reason: "taken", name }) } + } + else if (r.status === 401) setError("Device confirmation didn't verify. Try again.") + else if (r.status === 503) setError(`Sponsor is busy. Try again in ${r.retryAfter ?? 60}s.`) + else if (r.status === 400) setError("Request rejected (invalid). This is a client bug — don't retry.") + else setError(r.error || "Account creation failed. Try again later.") + } catch { + setError("Account creation failed — device or network error.") + } finally { setCreating(false) } + } + + const name = username.trim().toLowerCase() + // Gate on avail belonging to the CURRENT name — never sign for an unchecked name. + const canCreate = !!name && avail?.available === true && avail.name === name && !creating + + if (needsFunding !== null) return ( + + Fund your ETH address first + + Sponsored Hive accounts are free, but each one needs a funded Ethereum address so the + service can't be drained. Send any amount of mainnet ETH to your KeepKey ETH + address below, then retry. One sponsored account per ETH address. + + {needsFunding ? ( + + {needsFunding} + navigator.clipboard.writeText(needsFunding)}> + + + + ) : Open the Ethereum asset to view your receive address.} + + + ) + + if (created) return ( + + + 🎉 + Account created! + @{created.name} + + Secured by KeepKey — sponsored by @keepkey, no fee. Loading your account… + + {created.txid && ( + rpcRequest("openUrl", { url: `https://hiveblocks.com/tx/${created.txid}` }).catch(() => {})}> + tx {created.txid.slice(0, 16)}… + + )} + + {viewMsg && {viewMsg}} + + ) + + return ( + + Create your Hive account + + Hive funds go to a username, not a key. KeepKey sponsors the on-chain creation — you pay nothing + and your keys never leave the device. Pick a name and confirm on your device. + + + + setUsername(e.target.value)} placeholder="username" + autoCapitalize="none" autoCorrect="off" spellCheck={false} + bg="var(--surface-1)" border="1px solid" borderColor="var(--border)" fontSize="14px" pr="9" /> + + {checking ? + : avail?.available ? + : avail && !avail.available ? : null} + + + {avail && !avail.available && avail.reason && ( + {avail.reason} + )} + {avail?.available && @{name} is available} + + + {error && {error}} + + setShowKeys(s => !s)}> + + Device keys for this account + + {showKeys && ( + + {keys ? (<> + + + + + ) : } + + )} + + ) +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index afc384ff..a9f3c962 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -83,6 +83,10 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { tonSignTx: { params: any; response: any } tonSignMessage: { params: any; response: any } hiveGetPublicKey: { params: any; response: any } + hiveGetRoleKeys: { params: { accountIndex?: number }; response: { owner: string; active: string; posting: string; memo: string } } + hiveGetAccount: { params: { pubkey: string }; response: { success?: boolean; noAccount?: boolean; account?: { name: string; hive: string; hbd: string; hp?: string; rcPercent?: number } } } + hiveUsernameAvailable: { params: { name: string }; response: { success: boolean; available: boolean; reason?: string } } + hiveCreateAccount: { params: { username: string; accountIndex?: number }; response: { status: number; success?: boolean; txid?: string; username?: string; error?: string; retryAfter?: number } } hiveSignTx: { params: any; response: any } // ── Pioneer integration ───────────────────────────────────────── From d2e17b46d15430a453436ceafee042bc914144e5 Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 18:16:29 -0500 Subject: [PATCH 21/53] feat(recovery): expose cipher-recovery character entry over REST + SDK (+ #272 wrong-word test) (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(recovery): expose cipher-recovery character entry over REST + SDK + #272 test Adds REST endpoints so an SDK/host can drive on-device cipher recovery (the engine already had the machinery — sendCharacter/Delete/Done — wired only to the in-app UI): POST /system/recovery/character {character} POST /system/recovery/character/delete POST /system/recovery/character/done GET /system/recovery/state -> { word_pos, character_pos, seq } Mirrors /system/recovery/pin. engine-controller tracks the last CharacterRequest (transport event 80) + a seq counter so a caller can sync sends with the device. SDK: sdk.system.recovery.sendCharacter/sendCharacterDelete/sendCharacterDone/getRecoveryState. Test tests/recovery/wrong-word.js validates firmware #272 (per-word BIP-39 validation): drives recovery and enters a guaranteed-invalid word (5 identical ciphered chars -> never a wordlist word, dodges auto-complete regardless of the unreadable scramble), asserts recoverDevice() rejects with "Word not found". Note: lib/ is a build artifact — run `npm run build` (or test:device) before the test. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(recovery): address PR #306 review — namespace, lib, state reset, timeout - SDK namespace: move sendCharacter/Delete/Done/getRecoveryState into a new sdk.system.recovery.* namespace (matches the test + docs; sendPin stays in device for back-compat). Fixes the test throwing on sdk.system.recovery.*. - Stale lib: commit rebuilt lib/index.{js,d.ts}(+maps) so published main/types match src (this also catches up pre-existing un-built methods e.g. solana offchain / TIP-191 that were already in src but never built). - State leak: reset lastCharacterRequest + characterRequestSeq + recoveryActive in clearWallet() (disconnect/re-pair) so /system/recovery/state can't return stale progress. Add an explicit marker; recover-device wraps the call in try/finally so state is active only during entry. seq stays monotonic. - Timeout: wipe/resetDevice/recoverDevice/loadDevice now use the SDK signing timeout (600s) instead of the 30s default, since they block on human confirm. Follow-up (noted): bind /system/recovery/state to the initiating session/bearer token if it becomes public API (it exposes only word/char position, no secrets). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- projects/keepkey-sdk/lib/index.d.ts | 96 ++++++++++++++++++- projects/keepkey-sdk/lib/index.d.ts.map | 2 +- projects/keepkey-sdk/lib/index.js | 89 ++++++++++++++++- projects/keepkey-sdk/lib/index.js.map | 2 +- projects/keepkey-sdk/src/index.ts | 34 ++++++- .../keepkey-sdk/tests/recovery/wrong-word.js | 72 ++++++++++++++ .../src/bun/engine-controller.ts | 37 ++++++- projects/keepkey-vault/src/bun/rest-api.ts | 52 ++++++++-- projects/keepkey-vault/src/bun/schemas.ts | 5 + 9 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 projects/keepkey-sdk/tests/recovery/wrong-word.js 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/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/recovery/wrong-word.js b/projects/keepkey-sdk/tests/recovery/wrong-word.js new file mode 100644 index 00000000..d2689c12 --- /dev/null +++ b/projects/keepkey-sdk/tests/recovery/wrong-word.js @@ -0,0 +1,72 @@ +/** + * recovery/wrong-word.js — firmware #272: during cipher recovery, a word that + * isn't in the BIP-39 wordlist must be rejected (per-word validation). + * + * We can't enter a SPECIFIC seed without reading the device's scrambled cipher + * (OLED-only on production firmware) — but we don't need to. Sending the same + * ciphered character 5x de-ciphers to 5 identical letters, which is never a + * BIP-39 word (and dodges the 4-char auto-complete) whatever the scramble is. + * The firmware then rejects the word and recoverDevice() fails with + * "Word not found in BIP39 wordlist". + * + * DESTRUCTIVE: wipes the device. Use a TEST device only. + * HUMAN-IN-LOOP: press Confirm to start recovery (+ approve pairing on first run). + * + * Requires the REST recovery-character 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 word rejected during cipher recovery', async (getSdk, assert, assertThrows) => { + const sdk = await getSdk() + + // Start from a clean, uninitialized device. + try { await sdk.system.device.wipe() } catch (_) { /* wipe reboots -> call may drop */ } + assert('device uninitialized before recovery', await waitUninitialized(sdk)) + + const baseSeq = (await sdk.system.recovery.getRecoveryState()).seq + + // Begin cipher recovery (do NOT await — it resolves/rejects when entry finishes). + const recovery = sdk.system.device.recoverDevice({ + word_count: 12, pin_protection: false, passphrase_protection: false, + }) + + // Wait (generously — this blocks on the human Confirm) for the device to enter + // the cipher and emit the first CharacterRequest. >>> PRESS CONFIRM on the device. + let seq = await waitSeqAdvance(sdk, baseSeq, 60000) + assert('device entered cipher recovery (CharacterRequest received)', seq !== null) + + // Enter a guaranteed-invalid first word: 5 identical letters, then a separator. + 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 the word -> per-word validation + + // recoverDevice() must reject — the firmware aborts on the unknown word. + let recErr = null + try { await recovery } catch (e) { recErr = e } + console.log(` recoverDevice() -> ${recErr ? String(recErr.message || recErr).slice(0, 110) : 'RESOLVED (unexpected!)'}`) + assertThrows('invalid word rejected (recoverDevice fails with "Word not found")', recErr, 'word') +}) diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index 7b0d8bd3..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) } }) } @@ -1965,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/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 953f5a86..7f6aceb6 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -3597,13 +3597,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 }) } @@ -3625,6 +3630,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 e2e81fef..6786e40c 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -329,6 +329,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 */ From c2e3eafc82700dab0a61d84390973bd3f22f755f Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 30 Jun 2026 18:30:04 -0500 Subject: [PATCH 22/53] chore(sdk-tests): commit untracked device tests for the test suite These were created in prior sessions but never committed: - tests/evm-firmware/ (6): on-device clear-sign verifiers for firmware #255/#260/#261 (eip1559 chainid/recover, sellToUniswap offset, thorchain router pin, transformERC20 clear-sign, uniswap liquidity recipient). - tests/recovery/load-verify.js: wipe + loadDevice(random seed) + verify the device derives the same ETH address ethers does (seed-derivation check; distinct from the #272 cipher-recovery wrong-word.js already on develop). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../evm-firmware/eip1559-chainid-required.js | 16 ++++ .../tests/evm-firmware/eip1559-recover.js | 44 +++++++++ .../evm-firmware/selltouniswap-offset.js | 51 ++++++++++ .../evm-firmware/thorchain-router-pin.js | 53 +++++++++++ .../evm-firmware/transformerc20-clearsign.js | 49 ++++++++++ .../uniswap-liquidity-recipient.js | 55 +++++++++++ .../keepkey-sdk/tests/recovery/load-verify.js | 92 +++++++++++++++++++ 7 files changed, 360 insertions(+) create mode 100644 projects/keepkey-sdk/tests/evm-firmware/eip1559-chainid-required.js create mode 100644 projects/keepkey-sdk/tests/evm-firmware/eip1559-recover.js create mode 100644 projects/keepkey-sdk/tests/evm-firmware/selltouniswap-offset.js create mode 100644 projects/keepkey-sdk/tests/evm-firmware/thorchain-router-pin.js create mode 100644 projects/keepkey-sdk/tests/evm-firmware/transformerc20-clearsign.js create mode 100644 projects/keepkey-sdk/tests/evm-firmware/uniswap-liquidity-recipient.js create mode 100644 projects/keepkey-sdk/tests/recovery/load-verify.js 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) +}) From 3691b53f519399cd017a11de4cc776feb548ccde Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 18:46:17 -0500 Subject: [PATCH 23/53] fix(dashboard): let new users Receive on a freshly-added zero-balance chain (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh wallet, picking a chain from the "Add a blockchain" grid drilled into it but the Receive/Send/Swap action row was gated behind hasAnyBalance. With no funds anywhere, hasAnyBalance is false, so the row never rendered and new users could not press Receive — completely blocked from getting a deposit address. Ungate the drilled-chain action row from hasAnyBalance (the point of picking a chain is to receive on it), and suppress the generic "pick a chain" welcome empty-state while a chain is drilled so it doesn't sit above the action row. Co-authored-by: Claude Opus 4.8 (1M context) --- .../keepkey-vault/src/mainview/components/Dashboard.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 38936a54..a5e74831 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -2434,7 +2434,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin ) })() : (loadingBalances || !initialLoaded) && !pioneerError ? ( - ) : !loadingBalances && initialLoaded && !pioneerError ? ( + ) : !loadingBalances && initialLoaded && !pioneerError && !drilledChainId ? ( { + {/* Drilled-chain action row (Receive/Send/Swap). NOT gated on + hasAnyBalance: a fresh wallet holds nothing, but the whole + point of picking a chain is to RECEIVE on it — gating this + on balance left new users unable to press Receive. */} + {drilledChainId && (() => { const dchain = visibleChains.find(c => c.id === drilledChainId) if (!dchain) return null const bal = getEffectiveBalance(dchain.id) From cb03bf82094f1bff2434c7b94bcaaa84265733d7 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 30 Jun 2026 18:56:48 -0500 Subject: [PATCH 24/53] test(recovery): match real on-device rejection in wrong-word #272 test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated live on a 7.15.0 device via the new REST recovery endpoints: recovery enters the cipher, and 5 identical chars are rejected on-device with "Words were not entered correctly. Make sure you are using the substitution cipher." Updated the test accordingly: - skip the wipe when the device is already uninitialized (a wipe reboots the device and wedges the USB transport — start empty when possible) - catch the rejection on either the sendCharacter() that finalizes the word OR the in-flight recoverDevice() promise - accept the firmware's actual rejection messages (substitution-cipher guard OR 'Word not found in BIP39 wordlist') Co-Authored-By: Claude Opus 4.8 (1M context) --- .../keepkey-sdk/tests/recovery/wrong-word.js | 85 ++++++++++++------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/projects/keepkey-sdk/tests/recovery/wrong-word.js b/projects/keepkey-sdk/tests/recovery/wrong-word.js index d2689c12..913ffb88 100644 --- a/projects/keepkey-sdk/tests/recovery/wrong-word.js +++ b/projects/keepkey-sdk/tests/recovery/wrong-word.js @@ -1,19 +1,27 @@ /** - * recovery/wrong-word.js — firmware #272: during cipher recovery, a word that - * isn't in the BIP-39 wordlist must be rejected (per-word validation). + * 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) — but we don't need to. Sending the same - * ciphered character 5x de-ciphers to 5 identical letters, which is never a - * BIP-39 word (and dodges the 4-char auto-complete) whatever the scramble is. - * The firmware then rejects the word and recoverDevice() fails with - * "Word not found in BIP39 wordlist". + * 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. * - * DESTRUCTIVE: wipes the device. Use a TEST device only. - * HUMAN-IN-LOOP: press Confirm to start recovery (+ approve pairing on first run). + * The rejection can surface on the sendCharacter() that finalizes the word OR on + * the in-flight recoverDevice() promise, so we catch both. * - * Requires the REST recovery-character endpoints (POST /system/recovery/character - * {,/delete,/done}, GET /system/recovery/state). Run: node tests/run-all.js recovery + * 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)) @@ -38,35 +46,48 @@ async function waitSeqAdvance(sdk, fromSeq, timeoutMs) { return null } -run('Recovery #272: invalid word rejected during cipher recovery', async (getSdk, assert, assertThrows) => { +run('Recovery #272: invalid input rejected during cipher recovery', async (getSdk, assert) => { const sdk = await getSdk() - // Start from a clean, uninitialized device. - try { await sdk.system.device.wipe() } catch (_) { /* wipe reboots -> call may drop */ } - assert('device uninitialized before recovery', await waitUninitialized(sdk)) + // 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 — it resolves/rejects when entry finishes). + // 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 }) - // Wait (generously — this blocks on the human Confirm) for the device to enter - // the cipher and emit the first CharacterRequest. >>> PRESS CONFIRM on the device. - let seq = await waitSeqAdvance(sdk, baseSeq, 60000) - assert('device entered cipher recovery (CharacterRequest received)', seq !== null) + // 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 first word: 5 identical letters, then a separator. - 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 the word -> per-word validation + // 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 } - // recoverDevice() must reject — the firmware aborts on the unknown word. - let recErr = null - try { await recovery } catch (e) { recErr = e } - console.log(` recoverDevice() -> ${recErr ? String(recErr.message || recErr).slice(0, 110) : 'RESOLVED (unexpected!)'}`) - assertThrows('invalid word rejected (recoverDevice fails with "Word not found")', recErr, 'word') + 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)) }) From 63d2485224afa72fef20563c01e4e6d486312c25 Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 19:05:13 -0500 Subject: [PATCH 25/53] feat(hive): list Hive Support as a v7.15.0 upgrade-preview feature (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Hive entry to the 7.15.0 features in firmware-versions.ts so the firmware upgrade preview advertises Hive (chain logo + brand color from chains.ts). Vault-side Hive support already shipped (#300). Consistent with the other deferred-firmware 7.15.0 entries (Zcash, Transaction Insight) — the firmware Hive batch stages separately. Co-authored-by: Claude Opus 4.8 (1M context) --- projects/keepkey-vault/src/shared/firmware-versions.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/projects/keepkey-vault/src/shared/firmware-versions.ts b/projects/keepkey-vault/src/shared/firmware-versions.ts index 17d577bc..5473334f 100644 --- a/projects/keepkey-vault/src/shared/firmware-versions.ts +++ b/projects/keepkey-vault/src/shared/firmware-versions.ts @@ -56,6 +56,13 @@ export const FIRMWARE_VERSION_MAP: FirmwareVersionInfo[] = [ color: '#805AD5', icon: 'feature', }, + { + title: 'Hive Support', + description: 'Send, receive, and sign Hive transactions with sponsor-backed account onboarding.', + chains: ['hive'], + color: '#E31337', + icon: 'chain', + }, ], }, { From 34b5c44c2fa57a06abdeb2a0056170ceb1dfce26 Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 19:05:16 -0500 Subject: [PATCH 26/53] =?UTF-8?q?docs:=20firmware-release=20SOP=20?= =?UTF-8?q?=E2=80=94=20upstream-first=20dependency=20gating=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: firmware-release SOP — upstream-first dependency gating Documents the rule that firmware releases pin UPSTREAM device-protocol + python-keepkey masters, and nothing merges to fork develop until those proto/ test deps are merged upstream first (peer-review gated, days). Corrects the Phase-5-last ordering in the 7.14.0 release plan, which had it backwards. * docs: clarify mergability is in-order (sequential pipeline), not per-branch isolation --- docs/firmware-release-sop.md | 101 +++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/firmware-release-sop.md 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. From afd4cd984deba6507408dee1becdd7d98a5cbd3b Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 19:05:19 -0500 Subject: [PATCH 27/53] fix(windows-build): replace em-dashes with ASCII so PowerShell 5.1 can parse (#297) build-windows-production.ps1 contained UTF-8 em-dashes (U+2014). The file has no BOM, so PowerShell 5.1 reads it in the system ANSI codepage; on a non-UTF-8 codepage the em-dash bytes (E2 80 94) mis-decode and, inside the line 424 "build failed for proto-tx-builder ..." string, produce a stray quote that closes the string early -> the whole script fails to parse and the Windows build never starts. (Introduced with the proto-tx-builder dist build in #295.) Replace every em-dash with ASCII "--". Comments and the one in-string use are now codepage-independent. Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/build-windows-production.ps1 | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/scripts/build-windows-production.ps1 b/scripts/build-windows-production.ps1 index a50069ed..1af32cf7 100644 --- a/scripts/build-windows-production.ps1 +++ b/scripts/build-windows-production.ps1 @@ -164,14 +164,14 @@ function Assert-Command { # # Why this exists: rcedit (and some upstream Electrobun-built launcher.exe # binaries) leave a non-zero Security Directory RVA/Size pointing at garbage -# data — the file isn't actually signed (Get-AuthenticodeSignature reports +# data -- the file isn't actually signed (Get-AuthenticodeSignature reports # NotSigned), but a stale ~10 KB cert-table-shaped chunk lives inside the PE. # signtool then refuses to sign the file with the misleading error # `0x800700C1 / ERROR_BAD_EXE_FORMAT` because it can't safely overwrite the # malformed cert table. `signtool remove /s` also fails (`0x00000057`) # because there's no valid signature to strip. # -# The fix is purely a 8-byte zero write in the PE Optional Header — the +# The fix is purely a 8-byte zero write in the PE Optional Header -- the # orphan cert blob at the end of the file is harmless (signtool overwrites # or appends past it). No section table, no checksum, no relocation needs # to change. @@ -185,7 +185,7 @@ function Clear-PECertTableEntry { if ($bytes[$peOff] -ne 0x50 -or $bytes[$peOff + 1] -ne 0x45) { return $false } # PE $optOff = $peOff + 24 $magic = [BitConverter]::ToUInt16($bytes, $optOff) - # NumberOfRvaAndSizes lives at +108 for PE32+, +92 for PE32 — pick the + # NumberOfRvaAndSizes lives at +108 for PE32+, +92 for PE32 -- pick the # right offset so we land on the actual DataDirectories array. $rvaCountOff = if ($magic -eq 0x20B) { $optOff + 108 } elseif ($magic -eq 0x10B) { $optOff + 92 } else { return $false } if ($rvaCountOff + 4 + (5 * 8) -gt $bytes.Length) { return $false } @@ -240,9 +240,9 @@ function Sign-File { } # Try each timestamp URL in order. signtool failures with a timestamp - # server are transient (network blip, server rotation) — retry against the + # server are transient (network blip, server rotation) -- retry against the # next URL before declaring failure. Sign-without-timestamp is NOT a - # fallback: an untimestamped sig is valid only while the cert is — once + # fallback: an untimestamped sig is valid only while the cert is -- once # the cert expires, every signed binary becomes "publisher unknown". $lastResult = "" $exitCode = 1 @@ -315,7 +315,7 @@ function Sign-File { $resultStr = $retryResult -join ' ' } - # Genuinely-unsignable formats — bun shims (handled above by path) and + # Genuinely-unsignable formats -- bun shims (handled above by path) and # native .node addons (handled above by extension) are the only files # that should land here. If we still match "not recognized", it's a # signtool-side rejection we can't fix; log and skip. @@ -421,7 +421,7 @@ if (-not $SkipBuild) { # function". macOS rebuilds via the Makefile (+#290 stamp-clear); Windows # must run the build too (mirrors the hdwallet `yarn build` below). bun run build - if ($LASTEXITCODE -ne 0) { throw "build failed for proto-tx-builder (exit $LASTEXITCODE) — if tsc can't resolve osmosis codecimpl, copy modules/proto-tx-builder/osmosis-frontend/src/proto/generated/codecimpl.{js,d.ts} as in the macOS setup" } + if ($LASTEXITCODE -ne 0) { throw "build failed for proto-tx-builder (exit $LASTEXITCODE) -- if tsc can't resolve osmosis codecimpl, copy modules/proto-tx-builder/osmosis-frontend/src/proto/generated/codecimpl.{js,d.ts} as in the macOS setup" } Pop-Location Write-Step "Building hdwallet" @@ -491,7 +491,7 @@ if (-not $SkipBuild) { } # Patch version.json: stable channel + force version to match package.json. - # Electrobun's `bun run build` is an incremental build — when a stale _build/ + # Electrobun's `bun run build` is an incremental build -- when a stale _build/ # exists from a different branch, it can leave version.json with the wrong # version. We had a current-version-named installer shipped with stale # previous-version bits inside because @@ -734,7 +734,7 @@ if (Test-Path $ManifestSrc) { # BeginUpdateResource API which invalidates Authenticode signatures # (https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-beginupdateresourcea). # The bulk sign loop above runs before the wrapper EXE exists, and we touch -# launcher.exe here too — so both get re-signed in the next step. +# launcher.exe here too -- so both get re-signed in the next step. $RceditExe = Join-Path $ProjectDir "node_modules\rcedit\bin\rcedit-x64.exe" $rceditTouched = @() if ((Test-Path $IconIco) -and (Test-Path $RceditExe)) { @@ -829,14 +829,14 @@ if (Test-Path $ShortStage) { Remove-Item -Recurse -Force $ShortStage } Write-Host " Copying build to $ShortStage ..." # robocopy can handle source paths >260 chars when long-path support remains enabled. # Critical flags (learned the hard way): -# /MT:16 — 16-thread copy. Single-threaded robocopy + Defender real-time +# /MT:16 -- 16-thread copy. Single-threaded robocopy + Defender real-time # scan = ~30 min for 14k files. Multi-threaded = ~1-2 min. -# /R:1 /W:1 — retry ONCE with a 1-sec wait. Defaults are /R:1000000 /W:30 +# /R:1 /W:1 -- retry ONCE with a 1-sec wait. Defaults are /R:1000000 /W:30 # (one million retries, 30-sec wait), which means a single # Defender-locked file hangs the entire copy for hours. -# /XJ — skip junction points / reparse points. Without this, symlink +# /XJ -- skip junction points / reparse points. Without this, symlink # loops inside nested node_modules can trap robocopy forever. -# robocopy exits 0-7 on success (0=no files, 1=copied, 2=extra, etc.) — normalize to 0 +# robocopy exits 0-7 on success (0=no files, 1=copied, 2=extra, etc.) -- normalize to 0 $rcStart = Get-Date robocopy $BuildDir $ShortStage /E /MT:16 /R:1 /W:1 /XJ /NFL /NDL /NJH /NJS /NP /NS | Out-Null $stageCopyExit = $LASTEXITCODE From 3156966a482ac2ad6c42307fefb8e5688cf5136b Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 19:05:22 -0500 Subject: [PATCH 28/53] =?UTF-8?q?docs(zcash):=20real-device=20shielded=20s?= =?UTF-8?q?moke=20checklist=20=E2=80=94=20v1.4.6=20stable-promotion=20gate?= =?UTF-8?q?=20(#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.6 shipped as a prerelease validated only by local/static checks. Local Orchard proof-verify can't distinguish a good tx from a doomed one (the NU6.2 incident proved only a node broadcast can), and the new fail-closed validation could also false-abort a legitimate send. This checklist captures the real-device send/shield/deshield + anti-bleed smoke that must pass before promoting v1.4.6 from prerelease to latest/stable. --- .../docs/zcash-shielded-device-smoke.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 projects/keepkey-vault/docs/zcash-shielded-device-smoke.md 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). From edc9d31b01a19fde64c3a0e07408e9639cbb5ba0 Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 19:05:25 -0500 Subject: [PATCH 29/53] test(zcash): witness recomputes root for note in lower completed shard (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins that the real-spend tree/witness layer is consensus-sound: a note in a COMPLETED shard below other (insert()ed-root) shards + a frontier shard produces a witness that recomputes the tree root. Uses the existing assert_witness_recomputes_root helper (whose failure is exactly the chain's 'could not validate orchard proof'). This rules the witness/anchor OUT as the cause of that error on real z→z spends — narrowing it to the Orchard proof's other public inputs. Co-authored-by: Claude Opus 4.8 (1M context) --- .../zcash-cli/src/pczt_builder.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs index 0c628521..79785550 100644 --- a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs +++ b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs @@ -3671,6 +3671,52 @@ mod tests { ); } + /// THE real-spend case: the note sits in a COMPLETED shard that is BELOW + /// other completed shards (whose roots are insert()ed) and an incomplete + /// frontier shard. Mirrors build_pczt's ordered construction exactly. The + /// anchor (root) can be correct while the note's WITNESS still fails to + /// recompute it — which the chain reports as "could not validate orchard + /// proof" on a real shielded spend. + #[test] + fn test_witness_recomputes_root_note_in_lower_completed_shard() { + use incrementalmerkletree::{Address, Position}; + + let shard_size: u64 = 1 << 4; // 16 + let n_complete = 3u64; // shards 0,1,2 complete + let note_shard = 1u64; // note in a completed shard BELOW shard 2 + let note_pos = note_shard * shard_size + 5; + let incomplete = 7u64; // frontier leaves in shard 3 + + let shard_root = |s: u64| -> [u8; 32] { + let mut st: ShardTree, 4, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + for j in 0..shard_size { st.append(test_leaf(s * shard_size + j), Retention::Ephemeral).unwrap(); } + st.checkpoint(0u32).unwrap(); + st.root_at_checkpoint_id(&0u32).unwrap().unwrap().to_bytes() + }; + + // Ordered build, exactly as build_pczt does it. + let mut tree: ShardTree, 8, 4> = + ShardTree::new(MemoryShardStore::empty(), 100); + for s in 0..n_complete { + if s == note_shard { + for j in 0..shard_size { + let i = s * shard_size + j; + let r = if i == note_pos { Retention::Marked } else { Retention::Ephemeral }; + tree.append(test_leaf(i), r).unwrap(); + } + } else { + let root = MerkleHashOrchard::from_bytes(&shard_root(s)).unwrap(); + tree.insert(Address::above_position(4.into(), Position::from(s * shard_size)), root).unwrap(); + } + } + for j in 0..incomplete { tree.append(test_leaf(n_complete * shard_size + j), Retention::Ephemeral).unwrap(); } + + let ckpt = u32::MAX; + tree.checkpoint(ckpt).unwrap(); + assert_witness_recomputes_root(&mut tree, note_pos, test_leaf(note_pos), ckpt, "note_in_lower_completed_shard"); + } + /// Mirrors the deshield builder's tree shape: insert N-1 completed shard /// roots, walk shard N from leaves marking the note, no frontier. This /// is the case where notes live in the latest incomplete shard. From f601bde8c0d84f03a5d1c9d9c6ac969f97b95032 Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 20:47:47 -0500 Subject: [PATCH 30/53] fix(clear-sign): stop over-gating firmware-handled contracts into blind-signing (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signing-approval overlay flags any tx the Vault's calldata decoder can't read (source:'none') as needsBlindSigning, which forces AdvancedMode ON. But AdvancedMode is the firmware's single blind-sign switch, so enabling it disables the device's own blind-sign gate globally — re-opening the drain vector the firmware router-pin fix (#261) closed, for any contract the firmware clear-signs natively but the Vault decoder doesn't recognize (e.g. a THORChain plain deposit forced the user to enable AdvancedMode). Add the missing firmware-handled selectors to the local calldata decoder so they decode (source:'local') and don't trip the gate: - THORChain deposit() 0x1fece7b4 (only depositWithExpiry was covered) - 0x sellToUniswap 0xd9627aa4 - Uniswap addLiquidityETH 0xf305d719 The device stays authoritative — it re-checks router/offset/recipient and rejects spoofed variants regardless of what the overlay displays. Also add tests/evm-firmware/ SDK tests that drive the live Vault REST API to a real device and verify the firmware EVM fixes via signer-recovery (#255/#260/ #261): EIP-1559/RLP pre-image, transformERC20 clear-sign, THORChain router pin, sellToUniswap offset validation, addLiquidity recipient guard. Verified on-device against firmware 7.15.0 (da02a7b7). Co-authored-by: Claude Opus 4.8 (1M context) --- projects/keepkey-sdk/package.json | 1 + .../keepkey-vault/src/bun/calldata-decoder.ts | 68 +++++++++++++++---- 2 files changed, 57 insertions(+), 12 deletions(-) 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-vault/src/bun/calldata-decoder.ts b/projects/keepkey-vault/src/bun/calldata-decoder.ts index b24ee9f1..05cb6f14 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 }, ] /** From bb62142bbdfe7c9dd16ff4d0d733bad9de81a662 Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 20:49:34 -0500 Subject: [PATCH 31/53] fix(multichain): get addresses for non-BTC chains, not just Bitcoin (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects made the wallet look Bitcoin-only when a user added another chain (reported by multiple users on v1.4.2 — 'the only thing I can click is Get BTC address' after Add a blockchain → Dash): 1. Dashboard: the 'Add a blockchain' picker only drilled the dashboard viz (setDrilledChainId) and never opened the chosen chain's page, so the hardcoded 'Get BTC address' welcome CTA stayed the only obvious action. It now opens the picked chain's Receive page. 2. index.ts: btcGetAddress is shared by every UTXO chain, but cached the derived address under the literal 'bitcoin' key — so a Dash/LTC/DOGE address overwrote Bitcoin's cache and never persisted under its own chain. The cache key now resolves from params.coin. Co-authored-by: Claude Opus 4.8 (1M context) --- projects/keepkey-vault/src/bun/index.ts | 9 +++++++-- .../keepkey-vault/src/mainview/components/Dashboard.tsx | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 18713983..b72e5107 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1998,11 +1998,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) => { diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index a5e74831..54bd4224 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -2943,8 +2943,13 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin next.add(chainId) return next }) - setDrilledChainId(chainId) setShowChainPicker(false) + // Open the picked chain's Receive page so the user gets its address. + // Without this the picker only drilled the dashboard viz, leaving the + // hardcoded "Get BTC address" CTA as the only obvious action. + const picked = allChains.find(c => c.id === chainId) + if (picked) openChainPage(picked, 'receive') + else setDrilledChainId(chainId) }} onClose={() => setShowChainPicker(false)} /> From aa77c582cf40f2a7dc977f96c179fb4d5baa05b1 Mon Sep 17 00:00:00 2001 From: Highlander Date: Tue, 30 Jun 2026 21:11:26 -0500 Subject: [PATCH 32/53] fix(wc): use live-camera QR scanner for WalletConnect pairing (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WalletConnect pairing used a macOS-only one-shot `screencapture` ("Scan screen") that required Screen Recording TCC permission and decoded a single static PNG — flaky when the send/address page's live-camera scanner (QrScannerOverlay, jsqr at ~10fps with retry) already worked reliably. Point WalletConnect at the same QrScannerOverlay component: a "Scan QR" button opens the live camera, validates the decoded string is a `wc:` URI, and pairs. Drop the dead `wcScanScreen` backend RPC, its schema entry, and the now-unused PNG-decode helper. Co-authored-by: Claude Opus 4.8 (1M context) --- projects/keepkey-vault/src/bun/index.ts | 70 ------------ .../components/WalletConnectPanel.tsx | 100 ++++++------------ .../keepkey-vault/src/shared/rpc-schema.ts | 4 - 3 files changed, 33 insertions(+), 141 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index b72e5107..046722c3 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -6976,76 +6976,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) => { diff --git a/projects/keepkey-vault/src/mainview/components/WalletConnectPanel.tsx b/projects/keepkey-vault/src/mainview/components/WalletConnectPanel.tsx index 7a16d44f..2e57f24c 100644 --- a/projects/keepkey-vault/src/mainview/components/WalletConnectPanel.tsx +++ b/projects/keepkey-vault/src/mainview/components/WalletConnectPanel.tsx @@ -5,6 +5,7 @@ import jsQR from "jsqr" import { Z } from "../lib/z-index" import { rpcRequest, onRpcMessage } from "../lib/rpc" import type { WcSessionInfo } from "../../shared/types" +import { QrScannerOverlay } from "./QrScannerOverlay" function decodeQrFromImageSrc(src: string): Promise { return new Promise((resolve) => { @@ -25,10 +26,6 @@ function decodeQrFromImageSrc(src: string): Promise { }) } -function decodePngBase64ToQrString(pngBase64: string): Promise { - return decodeQrFromImageSrc(`data:image/png;base64,${pngBase64}`) -} - async function decodeQrFromFile(file: File | Blob): Promise { const url = URL.createObjectURL(file) try { @@ -89,7 +86,7 @@ export function WalletConnectPanel({ open, wcUri, onClose, nativeEnabled }: Wall const [pairInput, setPairInput] = useState("") const [pairing, setPairing] = useState(false) const [pairError, setPairError] = useState("") - const [scanning, setScanning] = useState(false) + const [showScanner, setShowScanner] = useState(false) const [dragOver, setDragOver] = useState(false) const fileInputRef = useRef(null) const [pairRequest, setPairRequest] = useState(null) @@ -170,71 +167,37 @@ export function WalletConnectPanel({ open, wcUri, onClose, nativeEnabled }: Wall setPairing(false) }, [pairInput]) - const handleScanScreen = useCallback(async () => { - setScanning(true) - setPairError("") + // Pair from a decoded QR string (live camera scan or uploaded/dropped image). + // WC URIs are case-sensitive `wc:` per spec, but be tolerant just in case. + const pairScanned = useCallback(async (decoded: string | null) => { + if (!decoded) { + setPairError("No QR code found in image") + return + } + if (!decoded.toLowerCase().startsWith("wc:")) { + setPairError(`QR is not a WalletConnect URI (got "${decoded.slice(0, 30)}…")`) + return + } + setPairing(true) try { - console.log('[wc-scan] requesting screencapture') - const result = await rpcRequest<{ pngBase64: string } | null>("wcScanScreen") - if (!result) { - console.log('[wc-scan] user canceled screencapture') - setScanning(false) - return - } - console.log('[wc-scan] got PNG bytes:', result.pngBase64.length, 'b64 chars') - const decoded = await decodePngBase64ToQrString(result.pngBase64) - console.log('[wc-scan] jsqr decoded:', decoded ? `len=${decoded.length} prefix=${decoded.slice(0, 16)}` : 'null') - if (!decoded) { - setPairError("No QR code found in selection") - setScanning(false) - return - } - // WC URIs are case-sensitive `wc:` per spec, but be tolerant just in case - if (!decoded.toLowerCase().startsWith("wc:")) { - setPairError(`QR is not a WalletConnect URI (got "${decoded.slice(0, 30)}…")`) - setScanning(false) - return - } - setPairing(true) - setScanning(false) await rpcRequest("wcPair", { uri: decoded }) setPairInput("") - setPairing(false) } catch (e: any) { - console.error('[wc-scan] failed:', e) - if (e.message === 'SCREEN_RECORDING_PERMISSION_PROMPTED' || e.message === 'SCREEN_RECORDING_PERMISSION_REQUIRED') { - setPairError("Screen Recording permission required. Approve the macOS prompt or add the app via the + button in the Settings window that just opened, then quit (⌘Q) and reopen.") - } else { - setPairError(e.message || "Scan failed") - } - setScanning(false) - setPairing(false) + setPairError(e.message || "Pairing failed") } + setPairing(false) }, []) + const handleScanned = useCallback((data: string) => { + setShowScanner(false) + setPairError("") + pairScanned(data) + }, [pairScanned]) + const handleQrFile = useCallback(async (file: File | Blob) => { setPairError("") - try { - const decoded = await decodeQrFromFile(file) - console.log('[wc-scan] file decoded:', decoded ? `len=${decoded.length} prefix=${decoded.slice(0, 16)}` : 'null') - if (!decoded) { - setPairError("No QR code found in image") - return - } - if (!decoded.toLowerCase().startsWith("wc:")) { - setPairError(`QR is not a WalletConnect URI (got "${decoded.slice(0, 30)}…")`) - return - } - setPairing(true) - await rpcRequest("wcPair", { uri: decoded }) - setPairInput("") - setPairing(false) - } catch (e: any) { - console.error('[wc-scan] file pair failed:', e) - setPairError(e.message || "Pairing failed") - setPairing(false) - } - }, []) + pairScanned(await decodeQrFromFile(file)) + }, [pairScanned]) const handleFileChange = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -309,7 +272,7 @@ export function WalletConnectPanel({ open, wcUri, onClose, nativeEnabled }: Wall borderColor="kk.border" color="kk.textPrimary" _placeholder={{ color: "kk.textSecondary" }} - disabled={pairing || scanning} + disabled={pairing} />