From e04480cf1209e6cb9b39c595b90c9e6b37f936c9 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 4 Mar 2026 12:13:27 -0700 Subject: [PATCH 1/7] ci: retrigger with warm cache From e594b5baa3e292e48fbfd53fe56ffdd5c9b39c1d Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 5 Mar 2026 23:14:34 -0700 Subject: [PATCH 2/7] feat: add full report system with PDF export and fix BTC balance - Add report generator with 7 sections: device info, portfolio overview, chain balances, cached pubkeys, token details, BTC detailed report (tx history + address flow analysis) - Add PDF export with pie chart dashboard on first page using pdf-lib - Fix BTC balance missing from dashboard: GetPortfolio (charts/portfolio) returns empty for BTC xpubs, now falls back to GetPortfolioBalances (/portfolio) which correctly returns BTC data - Fix BTC balance missing from report pie chart: pre-fetch BTC balance from Pioneer direct API when cached balances show $0 - Add DB fallback for BTC xpubs when BtcAccountManager init fails - Add ReportDialog UI with JSON/CSV/PDF download buttons - BTC report uses /utxo/pubkey-info + /tx/history endpoints (not the broken /reports/bitcoin endpoint) Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- modules/hdwallet | 2 +- projects/keepkey-vault/src/bun/db.ts | 84 +- projects/keepkey-vault/src/bun/index.ts | 201 ++- projects/keepkey-vault/src/bun/reports.ts | 1081 +++++++++++++++++ .../src/mainview/components/Dashboard.tsx | 35 +- .../src/mainview/components/ReportDialog.tsx | 302 +++++ .../keepkey-vault/src/shared/rpc-schema.ts | 10 +- projects/keepkey-vault/src/shared/types.ts | 25 + 9 files changed, 1733 insertions(+), 9 deletions(-) create mode 100644 projects/keepkey-vault/src/bun/reports.ts create mode 100644 projects/keepkey-vault/src/mainview/components/ReportDialog.tsx diff --git a/Makefile b/Makefile index a7f3237..ee5b47a 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ modules-install: submodules cd modules/hdwallet && yarn install modules-build: modules-install - cd modules/hdwallet && yarn build + cd modules/hdwallet && yarn tsc --build --force modules-clean: cd modules/proto-tx-builder && rm -rf dist node_modules diff --git a/modules/hdwallet b/modules/hdwallet index fd141c7..15b6b23 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit fd141c7f6e346117c07ccad68e3d8b2892b33de9 +Subproject commit 15b6b23e4dd96b780b71a2382dde30bc369968ab diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 5d2d739..d394a2b 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -8,7 +8,7 @@ import { Database } from 'bun:sqlite' import { Utils } from 'electrobun/bun' import { join } from 'node:path' import { mkdirSync } from 'node:fs' -import type { ChainBalance, CustomToken, CustomChain, PairedAppInfo, ApiLogEntry } from '../shared/types' +import type { ChainBalance, CustomToken, CustomChain, PairedAppInfo, ApiLogEntry, ReportMeta, ReportData } from '../shared/types' const SCHEMA_VERSION = '7' @@ -145,6 +145,21 @@ export function initDb() { `) + db.exec(` + CREATE TABLE IF NOT EXISTS reports ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + chain TEXT NOT NULL DEFAULT 'all', + lod INTEGER NOT NULL DEFAULT 0, + total_usd REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'complete', + error TEXT, + data_json TEXT NOT NULL + ) + `) + db.exec(`CREATE INDEX IF NOT EXISTS idx_reports_created ON reports(created_at DESC)`) + // Migrations: add columns to existing tables (safe to re-run) for (const col of ['explorer_address_link TEXT', 'explorer_tx_link TEXT']) { try { db.exec(`ALTER TABLE custom_chains ADD COLUMN ${col}`) } catch { /* already exists */ } @@ -566,3 +581,70 @@ export function getCachedPubkeys(deviceId: string): Array<{ chainId: string; pat } } +// ── Reports ────────────────────────────────────────────────────────── + +export function saveReport(deviceId: string, id: string, chain: string, lod: number, totalUsd: number, status: string, dataJson: string, error?: string) { + try { + if (!db) return + db.run( + `INSERT OR REPLACE INTO reports (id, device_id, created_at, chain, lod, total_usd, status, error, data_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, deviceId, Date.now(), chain, lod, totalUsd, status, error || null, dataJson] + ) + } catch (e: any) { + console.warn('[db] saveReport failed:', e.message) + } +} + +export function getReportsList(deviceId: string): ReportMeta[] { + try { + if (!db) return [] + const rows = db.query( + 'SELECT id, created_at, chain, lod, total_usd, status, error FROM reports WHERE device_id = ? ORDER BY created_at DESC' + ).all(deviceId) as Array<{ id: string; created_at: number; chain: string; lod: number; total_usd: number; status: string; error: string | null }> + return rows.map(r => ({ + id: r.id, + createdAt: r.created_at, + chain: r.chain, + totalUsd: r.total_usd, + status: r.status as ReportMeta['status'], + error: r.error || undefined, + })) + } catch (e: any) { + console.warn('[db] getReportsList failed:', e.message) + return [] + } +} + +export function getReportById(id: string): { meta: ReportMeta; data: ReportData } | null { + try { + if (!db) return null + const row = db.query( + 'SELECT id, created_at, chain, lod, total_usd, status, error, data_json FROM reports WHERE id = ?' + ).get(id) as { id: string; created_at: number; chain: string; lod: number; total_usd: number; status: string; error: string | null; data_json: string } | null + if (!row) return null + const meta: ReportMeta = { + id: row.id, + createdAt: row.created_at, + chain: row.chain, + totalUsd: row.total_usd, + status: row.status as ReportMeta['status'], + error: row.error || undefined, + } + const data: ReportData = JSON.parse(row.data_json) + return { meta, data } + } catch (e: any) { + console.warn('[db] getReportById failed:', e.message) + return null + } +} + +export function deleteReport(id: string) { + try { + if (!db) return + db.run('DELETE FROM reports WHERE id = ?', [id]) + } catch (e: any) { + console.warn('[db] deleteReport failed:', e.message) + } +} + diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 9e0dda3..a960a3f 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -17,7 +17,8 @@ import { CHAINS, customChainToChainDef } from "../shared/chains" import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" import { EvmAddressManager, evmAddressPath } from "./evm-addresses" -import { initDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys } from "./db" +import { initDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport } from "./db" +import { generateReport, reportToCsv, reportToPdfBuffer } from "./reports" import { EVM_RPC_URLS, getTokenMetadata, broadcastEvmTx } from "./evm-rpc" import { startCamera, stopCamera } from "./camera" import type { ChainBalance, TokenBalance, CustomToken, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, EvmAddressSet } from "../shared/types" @@ -34,7 +35,7 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise ]).finally(() => clearTimeout(timer!)) } -const PIONEER_TIMEOUT_MS = 30_000 +const PIONEER_TIMEOUT_MS = 60_000 // ── Pioneer chain discovery catalog (lazy-loaded, 30-min cache) ────── function getDiscoveryUrl(): string { @@ -515,7 +516,21 @@ const rpc = BrowserView.defineRPC({ // 3. Add ALL BTC xpubs from multi-account manager const btcChain = allChains.find(c => c.id === 'bitcoin')! - const btcPubkeyEntries = btcAccounts.getAllPubkeyEntries(btcChain.caip) + let btcPubkeyEntries = btcAccounts.getAllPubkeyEntries(btcChain.caip) + + // Fallback: if btcAccounts didn't initialize, try cached pubkeys from DB + if (btcPubkeyEntries.length === 0) { + const devId = engine.getDeviceState().deviceId + if (devId) { + const cachedPks = getCachedPubkeys(devId) + const btcPks = cachedPks.filter(p => p.chainId === 'bitcoin' && p.xpub) + if (btcPks.length > 0) { + btcPubkeyEntries = btcPks.map(p => ({ caip: btcChain.caip, pubkey: p.xpub })) + console.log(`[getBalances] BTC xpubs from cached_pubkeys DB fallback: ${btcPubkeyEntries.length}`) + } + } + } + // Track BTC entries separately for per-xpub balance update const btcPubkeySet = new Set(btcPubkeyEntries.map(e => e.pubkey)) for (const entry of btcPubkeyEntries) { @@ -548,6 +563,12 @@ const rpc = BrowserView.defineRPC({ const portfolioTokens: any[] = portfolio.tokens || [] console.log(`[getBalances] GetPortfolio response: ${nativeEntries.length} balances, ${portfolioTokens.length} tokens`) + // Log BTC-specific entries for debugging + const btcNatives = nativeEntries.filter((d: any) => d.caip?.includes('bip122') || d.pubkey?.startsWith('xpub') || d.pubkey?.startsWith('ypub') || d.pubkey?.startsWith('zpub')) + console.log(`[getBalances] BTC native entries from Pioneer: ${btcNatives.length}`) + for (const b of btcNatives) { + console.log(` BTC: caip=${b.caip}, pubkey=${String(b.pubkey).substring(0, 24)}..., balance=${b.balance}, valueUsd=${b.valueUsd}, address=${b.address}`) + } // Convert portfolio.tokens (different shape) into the same format as native entries // so our existing token grouping logic works on them @@ -652,6 +673,12 @@ const rpc = BrowserView.defineRPC({ } catch { /* custom tokens lookup failed, non-fatal */ } // Aggregate BTC entries into one ChainBalance + update per-xpub balances + console.log(`[getBalances] pureNatives count: ${pureNatives.length}`) + for (const n of pureNatives) { + if (n.caip?.includes('bip122') || n.pubkey?.startsWith('xpub') || n.pubkey?.startsWith('ypub') || n.pubkey?.startsWith('zpub')) { + console.log(`[getBalances] BTC native entry: caip=${n.caip}, pubkey=${n.pubkey?.substring(0, 20)}..., balance=${n.balance}, usd=${n.valueUsd}`) + } + } let btcTotalBalance = 0 let btcTotalUsd = 0 let btcAddress = '' @@ -664,6 +691,7 @@ const rpc = BrowserView.defineRPC({ // Find the Pioneer response for this xpub const match = pureNatives.find((d: any) => d.pubkey === entry.pubkey) || pureNatives.find((d: any) => d.caip === entry.caip && d.address === entry.pubkey) + console.log(`[getBalances] BTC match for ${entry.pubkey?.substring(0, 20)}...: ${match ? `balance=${match.balance}, usd=${match.valueUsd}` : 'NO MATCH'}`) const bal = parseFloat(String(match?.balance ?? '0')) const usd = Number(match?.valueUsd ?? 0) btcTotalBalance += bal @@ -727,6 +755,33 @@ const rpc = BrowserView.defineRPC({ }) } + // BTC fallback: GetPortfolio (charts/portfolio) returns empty for BTC xpubs. + // Use GetPortfolioBalances (/portfolio) which correctly returns BTC data. + if (btcTotalBalance === 0 && btcPubkeyEntries.length > 0 && pioneer) { + console.log('[getBalances] BTC zero from GetPortfolio (charts) — trying GetPortfolioBalances fallback') + try { + const btcResp = await withTimeout( + pioneer.GetPortfolioBalances({ + pubkeys: btcPubkeyEntries.map(e => ({ caip: e.caip, pubkey: e.pubkey })) + }), + PIONEER_TIMEOUT_MS, + 'GetPortfolioBalances-BTC' + ) + const btcBalances = btcResp?.data?.balances || btcResp?.data || [] + for (const b of (Array.isArray(btcBalances) ? btcBalances : [])) { + const bal = parseFloat(String(b.balance ?? '0')) + const usd = Number(b.valueUsd ?? 0) + btcTotalBalance += bal + btcTotalUsd += usd + if (!btcAddress && b.address) btcAddress = b.address + if (b.pubkey) btcAccounts.updateXpubBalance(b.pubkey, String(b.balance ?? '0'), usd) + } + console.log(`[getBalances] BTC fallback result: ${btcTotalBalance.toFixed(8)} BTC, $${btcTotalUsd.toFixed(2)}`) + } catch (e: any) { + console.warn('[getBalances] BTC GetPortfolioBalances fallback failed:', e.message) + } + } + // Push one aggregated BTC entry if (btcPubkeyEntries.length > 0) { const selectedXpub = btcAccounts.getSelectedXpub() @@ -1154,6 +1209,146 @@ const rpc = BrowserView.defineRPC({ clearApiLogs() }, + // ── Reports ───────────────────────────────────────────── + generateReport: async () => { + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) throw new Error('No device connected') + + const reportId = `rpt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + + // Get cached balances for report data + const cached = getCachedBalances(deviceId) + const balances = cached?.balances || [] + if (balances.length === 0) { + throw new Error('No cached balances available. Please refresh your portfolio first.') + } + + // Gather BTC xpubs from BtcAccountManager (try init if needed) + let btcXpubs: Array<{ xpub: string; scriptType: string; path: number[] }> | undefined + console.log(`[generateReport] btcAccounts.isInitialized=${btcAccounts.isInitialized}`) + if (!btcAccounts.isInitialized && engine.wallet) { + try { + console.log('[generateReport] Initializing BTC accounts for report...') + await btcAccounts.initialize(engine.wallet as any) + } catch (e: any) { + console.warn('[generateReport] BTC accounts init failed:', e.message) + } + } + if (btcAccounts.isInitialized) { + const btcSet = btcAccounts.toAccountSet() + btcXpubs = [] + for (const acct of btcSet.accounts) { + for (const x of acct.xpubs) { + if (x.xpub) btcXpubs.push({ xpub: x.xpub, scriptType: x.scriptType, path: x.path }) + } + } + console.log(`[generateReport] btcXpubs from BtcAccountManager: ${btcXpubs.length}`) + } + // Fallback: check cached_pubkeys DB for BTC xpubs + if (!btcXpubs || btcXpubs.length === 0) { + const cachedPks = getCachedPubkeys(deviceId) + const btcPks = cachedPks.filter(p => p.chainId === 'bitcoin' && p.xpub) + if (btcPks.length > 0) { + btcXpubs = btcPks.map(p => ({ + xpub: p.xpub, + scriptType: p.scriptType || 'p2wpkh', + path: p.path ? p.path.split('/').filter(Boolean).map(s => parseInt(s.replace("'", ''), 10)) : [], + })) + console.log(`[generateReport] btcXpubs from cached_pubkeys DB: ${btcXpubs.length}`) + } else { + console.warn('[generateReport] No BTC xpubs found anywhere — BTC sections will be skipped') + } + } + + const deviceLabel = engine.getDeviceState().label || 'KeepKey' + + // Save placeholder (lod=5 always) + saveReport(deviceId, reportId, 'all', 5, 0, 'generating', '{}') + + // Send initial progress + try { rpc.send['report-progress']({ id: reportId, message: 'Starting...', percent: 0 }) } catch {} + + try { + const reportData = await generateReport({ + balances, + btcXpubs, + deviceId, + deviceLabel, + onProgress: (message, percent) => { + try { rpc.send['report-progress']({ id: reportId, message, percent }) } catch {} + }, + }) + + const totalUsd = balances.reduce((s, b) => s + (b.balanceUsd || 0), 0) + saveReport(deviceId, reportId, 'all', 5, totalUsd, 'complete', JSON.stringify(reportData)) + + try { rpc.send['report-progress']({ id: reportId, message: 'Complete', percent: 100 }) } catch {} + + return { + id: reportId, + createdAt: Date.now(), + chain: 'all', + totalUsd, + status: 'complete' as const, + } + } catch (e: any) { + saveReport(deviceId, reportId, 'all', 5, 0, 'error', '{}', e.message) + try { rpc.send['report-progress']({ id: reportId, message: `Error: ${e.message}`, percent: 100 }) } catch {} + throw e + } + }, + + listReports: async () => { + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) return [] + return getReportsList(deviceId) + }, + + getReport: async (params) => { + return getReportById(params.id) + }, + + deleteReport: async (params) => { + deleteReport(params.id) + }, + + saveReportFile: async (params) => { + const report = getReportById(params.id) + if (!report) throw new Error('Report not found') + + const homedir = require('os').homedir() + const downloadsDir = require('path').join(homedir, 'Downloads') + const dateSuffix = new Date(report.meta.createdAt).toISOString().split('T')[0] + const baseName = `keepkey-report-${report.meta.chain}-${dateSuffix}` + + console.log(`[reports] saveReportFile: format=${params.format}, id=${params.id}`) + + let filePath: string + if (params.format === 'csv') { + filePath = require('path').join(downloadsDir, `${baseName}.csv`) + const csv = reportToCsv(report.data) + await Bun.write(filePath, csv) + } else if (params.format === 'pdf') { + filePath = require('path').join(downloadsDir, `${baseName}.pdf`) + console.log(`[reports] Generating PDF to ${filePath}...`) + const pdfBuffer = await reportToPdfBuffer(report.data) + console.log(`[reports] PDF buffer ready: ${pdfBuffer.length} bytes`) + await Bun.write(filePath, pdfBuffer) + console.log(`[reports] PDF written to disk`) + } else { + filePath = require('path').join(downloadsDir, `${baseName}.json`) + await Bun.write(filePath, JSON.stringify(report.data, null, 2)) + } + + // Reveal in Finder / file manager + const cmd = process.platform === 'win32' ? 'explorer' : process.platform === 'linux' ? 'xdg-open' : 'open' + const args = process.platform === 'darwin' ? ['-R', filePath] : [filePath] + Bun.spawn([cmd, ...args]) + + console.log(`[reports] File saved: ${filePath}`) + return { filePath } + }, + // ── Balance cache (instant portfolio) ──────────────────── getCachedBalances: async () => { const deviceId = engine.getDeviceState().deviceId diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts new file mode 100644 index 0000000..56559f3 --- /dev/null +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -0,0 +1,1081 @@ +/** + * Report generator for KeepKey Vault v11. + * + * Always generates LOD 5 (maximum detail). Includes: + * 1. Device Information (from device_snapshot DB) + * 2. Portfolio Overview (from cached balances) + * 3. Chain Balances Table + * 4. Cached Pubkeys / XPUBs (from cached_pubkeys DB) + * 5. Token Details (per-chain) + * 6. BTC Detailed Report (from Pioneer server) + * 7. Address Flow Analysis (computed from BTC TX data) + */ + +import type { ReportData, ReportSection, ChainBalance } from '../shared/types' +import { getLatestDeviceSnapshot, getCachedPubkeys } from './db' +import { getPioneerApiBase } from './pioneer' + +const REPORT_TIMEOUT_MS = 60_000 +const PIONEER_QUERY_KEY = process.env.PIONEER_API_KEY || `key:public-${Date.now()}` + +function getPioneerBase(): string { + return getPioneerApiBase() +} + +// ── Pioneer API Helpers ────────────────────────────────────────────── + +async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +async function fetchPubkeyInfo(baseUrl: string, xpub: string): Promise { + const resp = await fetchWithTimeout( + `${baseUrl}/api/v1/utxo/pubkey-info/BTC/${xpub}`, + { method: 'GET', headers: { 'Authorization': PIONEER_QUERY_KEY } }, + REPORT_TIMEOUT_MS, + ) + if (!resp.ok) throw new Error(`PubkeyInfo ${resp.status}`) + const json = await resp.json() + return json.data || json +} + +async function fetchTxHistory(baseUrl: string, xpub: string, caip: string): Promise { + const resp = await fetchWithTimeout( + `${baseUrl}/api/v1/tx/history`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': PIONEER_QUERY_KEY }, + body: JSON.stringify({ queries: [{ pubkey: xpub, caip }] }), + }, + REPORT_TIMEOUT_MS, + ) + if (!resp.ok) throw new Error(`TxHistory ${resp.status}`) + const json = await resp.json() + const histories = json.histories || json.data?.histories || [] + return histories[0]?.transactions || [] +} + +// ── Section Builders ───────────────────────────────────────────────── + +function buildDeviceFeaturesSection(): ReportSection { + const snapshot = getLatestDeviceSnapshot() + if (!snapshot) { + return { title: '1. Device Information', type: 'text', data: 'No device snapshot available.' } + } + + let features: any = {} + try { features = JSON.parse(snapshot.featuresJson) } catch {} + + const items: string[] = [ + `Label: ${snapshot.label || 'KeepKey'}`, + `Firmware Version: ${snapshot.firmwareVer || 'Unknown'}`, + `Device ID: ${snapshot.deviceId}`, + ] + + if (features.bootloaderHash) items.push(`Bootloader Hash: ${features.bootloaderHash}`) + if (features.initialized !== undefined) items.push(`Initialized: ${features.initialized ? 'Yes' : 'No'}`) + if (features.pinProtection !== undefined) items.push(`PIN Protection: ${features.pinProtection ? 'Enabled' : 'Disabled'}`) + if (features.passphraseProtection !== undefined) items.push(`Passphrase Protection: ${features.passphraseProtection ? 'Enabled' : 'Disabled'}`) + if (features.model) items.push(`Model: ${features.model}`) + if (features.deviceId) items.push(`Hardware ID: ${features.deviceId}`) + items.push(`Snapshot Date: ${new Date(snapshot.updatedAt).toISOString()}`) + + return { title: '1. Device Information', type: 'summary', data: items } +} + +function buildPortfolioOverviewSection(balances: ChainBalance[]): ReportSection { + const totalUsd = balances.reduce((s, b) => s + (b.balanceUsd || 0), 0) + const totalTokens = balances.reduce((s, b) => s + (b.tokens?.length || 0), 0) + return { + title: '2. Portfolio Overview', + type: 'summary', + data: [ + `Total Chains: ${balances.length}`, + `Total USD Value: $${totalUsd.toFixed(2)}`, + `Total Tokens: ${totalTokens}`, + `Generated: ${new Date().toISOString()}`, + ], + } +} + +function buildChainBalancesSection(balances: ChainBalance[]): ReportSection { + const sorted = [...balances].sort((a, b) => (b.balanceUsd || 0) - (a.balanceUsd || 0)) + return { + title: '3. Chain Balances', + type: 'table', + data: { + headers: ['Chain', 'Symbol', 'Balance', 'USD Value', 'Address', 'Tokens'], + widths: ['15%', '10%', '20%', '15%', '30%', '10%'], + rows: sorted.map(b => [ + b.chainId, b.symbol, b.balance, + `$${(b.balanceUsd || 0).toFixed(2)}`, + b.address, (b.tokens?.length || 0).toString(), + ]), + }, + } +} + +function buildCachedPubkeysSection( + deviceId: string, + btcXpubs?: Array<{ xpub: string; scriptType: string; path: number[] }>, +): ReportSection[] { + const sections: ReportSection[] = [] + + // BTC xpubs from BtcAccountManager (always have full xpub + scriptType) + if (btcXpubs && btcXpubs.length > 0) { + const scriptTypeMap: Record = { + 'p2pkh': 'Legacy (P2PKH)', 'p2sh-p2wpkh': 'SegWit (P2SH-P2WPKH)', 'p2wpkh': 'Native SegWit (P2WPKH)', + } + sections.push({ + title: `4. BTC XPUBs (${btcXpubs.length})`, + type: 'table', + data: { + headers: ['Script Type', 'Path', 'XPUB'], + widths: ['20%', '20%', '60%'], + rows: btcXpubs.map(x => [ + scriptTypeMap[x.scriptType] || x.scriptType, + `m/${x.path.map((p, j) => j < 3 ? `${p & 0x7FFFFFFF}'` : String(p)).join('/')}`, + x.xpub, + ]), + }, + }) + } + + // Cached addresses from DB (all chains) + const pubkeys = getCachedPubkeys(deviceId) + if (pubkeys.length > 0) { + // Group by chain + const byChain = new Map() + for (const pk of pubkeys) { + const list = byChain.get(pk.chainId) || [] + list.push(pk) + byChain.set(pk.chainId, list) + } + + for (const [chainId, pks] of byChain) { + sections.push({ + title: `Cached Addresses — ${chainId} (${pks.length})`, + type: 'table', + data: { + headers: ['Path', 'Address'], + widths: ['30%', '70%'], + rows: pks.map(pk => [ + pk.path || 'N/A', + pk.address || 'N/A', + ]), + }, + }) + } + } + + if (sections.length === 0) { + return [{ title: '4. Cached Pubkeys / Addresses', type: 'text', data: 'No cached pubkeys or addresses found.' }] + } + + return sections +} + +function buildTokenDetailsSections(balances: ChainBalance[]): ReportSection[] { + const sections: ReportSection[] = [] + let first = true + for (const b of balances) { + if (!b.tokens || b.tokens.length === 0) continue + const sorted = [...b.tokens].sort((a, c) => (c.balanceUsd || 0) - (a.balanceUsd || 0)) + sections.push({ + title: first ? `5. Token Details — ${b.symbol} (${sorted.length})` : `Token Details — ${b.symbol} (${sorted.length})`, + type: 'table', + data: { + headers: ['Symbol', 'Name', 'Balance', 'USD Value', 'Price', 'Contract'], + widths: ['10%', '20%', '20%', '15%', '15%', '20%'], + rows: sorted.map(t => [ + t.symbol, t.name, t.balance, + `$${(t.balanceUsd || 0).toFixed(2)}`, + `$${(t.priceUsd || 0).toFixed(4)}`, + t.contractAddress || 'N/A', + ]), + }, + }) + first = false + } + return sections +} + +// ── BTC Report Builder (uses /utxo/pubkey-info + /tx/history) ──────── + +const BTC_CAIP = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + +interface BtcReportXpub { + xpub: string + scriptType: string + label: string + balance: number // satoshis + totalReceived: number // satoshis + totalSent: number // satoshis + txCount: number + usedAddresses: Array<{ name: string; path: string; transfers: number }> +} + +interface BtcTx { + txid: string + direction: string // 'sent' | 'received' + blockHeight: number + timestamp: number + confirmations: number + from: string[] + to: string[] + value: number // satoshis + fee: number // satoshis + status: string +} + +async function buildBtcSections( + baseUrl: string, + btcXpubs: Array<{ xpub: string; scriptType: string; path: number[] }>, + onProgress?: (msg: string, pct: number) => void, +): Promise { + const sections: ReportSection[] = [] + + // 1. Fetch pubkey info for each xpub + onProgress?.('Fetching BTC xpub info...', 20) + const xpubInfos: BtcReportXpub[] = [] + for (const x of btcXpubs) { + if (!x.xpub) continue + try { + const info = await fetchPubkeyInfo(baseUrl, x.xpub) + const tokens = info.tokens || [] + const used = tokens.filter((t: any) => (t.transfers || 0) > 0) + xpubInfos.push({ + xpub: x.xpub, + scriptType: x.scriptType, + label: `${x.scriptType}`, + balance: parseInt(String(info.balance || '0'), 10), + totalReceived: parseInt(String(info.totalReceived || '0'), 10), + totalSent: parseInt(String(info.totalSent || '0'), 10), + txCount: info.txs || 0, + usedAddresses: used.map((t: any) => ({ + name: t.name, path: t.path, transfers: t.transfers, + })), + }) + console.log(`[Report] PubkeyInfo ${x.scriptType}: balance=${info.balance} sats, txs=${info.txs}, used addrs=${used.length}`) + } catch (e: any) { + console.warn(`[Report] PubkeyInfo failed for ${x.scriptType}:`, e.message) + } + } + + // BTC Overview section + const totalBalSats = xpubInfos.reduce((s, x) => s + x.balance, 0) + const totalRecvSats = xpubInfos.reduce((s, x) => s + x.totalReceived, 0) + const totalSentSats = xpubInfos.reduce((s, x) => s + x.totalSent, 0) + const totalTxCount = xpubInfos.reduce((s, x) => s + x.txCount, 0) + + sections.push({ + title: '6. BTC Overview', + type: 'summary', + data: [ + `Total Balance: ${(totalBalSats / 1e8).toFixed(8)} BTC`, + `Total Received: ${(totalRecvSats / 1e8).toFixed(8)} BTC`, + `Total Sent: ${(totalSentSats / 1e8).toFixed(8)} BTC`, + `Total Transactions: ${totalTxCount}`, + `XPUBs Queried: ${xpubInfos.length}`, + ], + }) + + // XPUB summaries table + if (xpubInfos.length > 0) { + sections.push({ + title: 'XPUB Summaries', + type: 'table', + data: { + headers: ['Script Type', 'XPUB', 'Balance (BTC)', 'Received (BTC)', 'Sent (BTC)', 'TXs', 'Used Addrs'], + widths: ['10%', '30%', '14%', '14%', '14%', '8%', '10%'], + rows: xpubInfos.map(x => [ + x.scriptType, + x.xpub, + (x.balance / 1e8).toFixed(8), + (x.totalReceived / 1e8).toFixed(8), + (x.totalSent / 1e8).toFixed(8), + x.txCount.toString(), + x.usedAddresses.length.toString(), + ]), + }, + }) + } + + // Used addresses per xpub + for (const x of xpubInfos) { + if (x.usedAddresses.length > 0) { + sections.push({ + title: `${x.label} -- Used Addresses (${x.usedAddresses.length})`, + type: 'table', + data: { + headers: ['Address', 'Path', 'Transfers'], + widths: ['50%', '30%', '20%'], + rows: x.usedAddresses.map(a => [a.name, a.path, a.transfers.toString()]), + }, + }) + } + } + + // 2. Fetch transaction history + onProgress?.('Fetching BTC transaction history...', 40) + const allTxs: BtcTx[] = [] + const seenTxids = new Set() + + for (const x of btcXpubs) { + if (!x.xpub) continue + try { + const txs = await fetchTxHistory(baseUrl, x.xpub, BTC_CAIP) + for (const tx of txs) { + if (!seenTxids.has(tx.txid)) { + seenTxids.add(tx.txid) + allTxs.push({ + txid: tx.txid, + direction: tx.direction || 'unknown', + blockHeight: tx.blockHeight || 0, + timestamp: tx.timestamp || 0, + confirmations: tx.confirmations || 0, + from: tx.from || [], + to: tx.to || [], + value: parseInt(String(tx.value || '0'), 10), + fee: parseInt(String(tx.fee || '0'), 10), + status: tx.status || 'confirmed', + }) + } + } + console.log(`[Report] TxHistory ${x.scriptType}: ${txs.length} txs (${allTxs.length} unique total)`) + } catch (e: any) { + console.warn(`[Report] TxHistory failed for ${x.scriptType}:`, e.message) + } + } + + // Sort newest first + allTxs.sort((a, b) => b.blockHeight - a.blockHeight) + + onProgress?.('Building transaction sections...', 60) + + // Transaction History table + if (allTxs.length > 0) { + const rows = allTxs.map((tx, idx) => { + const date = tx.timestamp + ? new Date(tx.timestamp * 1000).toISOString().replace('T', ' ').substring(0, 19) + : 'Pending' + return [ + (idx + 1).toString(), + tx.direction.toUpperCase(), + `${tx.txid.substring(0, 16)}...`, + tx.blockHeight.toString(), + date, + (tx.value / 1e8).toFixed(8), + (tx.fee / 1e8).toFixed(8), + ] + }) + + sections.push({ + title: `Transaction History (${allTxs.length})`, + type: 'table', + data: { + headers: ['#', 'Dir', 'TXID', 'Block', 'Date', 'Value (BTC)', 'Fee (BTC)'], + widths: ['5%', '8%', '17%', '10%', '20%', '20%', '20%'], + rows, + }, + }) + + // Transaction Statistics + const blocks = allTxs.map(t => t.blockHeight).filter(b => b > 0) + const totalValueIn = allTxs.filter(t => t.direction === 'received').reduce((s, t) => s + t.value, 0) + const totalValueOut = allTxs.filter(t => t.direction === 'sent').reduce((s, t) => s + t.value, 0) + const totalFees = allTxs.reduce((s, t) => s + t.fee, 0) + + sections.push({ + title: 'Transaction Statistics', + type: 'summary', + data: [ + `Total Transactions: ${allTxs.length}`, + `Received: ${allTxs.filter(t => t.direction === 'received').length} txs (${(totalValueIn / 1e8).toFixed(8)} BTC)`, + `Sent: ${allTxs.filter(t => t.direction === 'sent').length} txs (${(totalValueOut / 1e8).toFixed(8)} BTC)`, + `Total Fees Paid: ${(totalFees / 1e8).toFixed(8)} BTC`, + blocks.length > 0 ? `Block Range: ${Math.min(...blocks)} - ${Math.max(...blocks)}` : '', + ].filter(Boolean), + }) + + // Per-transaction details (first 50) + for (const tx of allTxs.slice(0, 50)) { + const date = tx.timestamp + ? new Date(tx.timestamp * 1000).toISOString().replace('T', ' ').substring(0, 19) + : 'Pending' + sections.push({ + title: `TX: ${tx.txid.substring(0, 20)}... (${tx.direction.toUpperCase()})`, + type: 'summary', + data: [ + `Block: ${tx.blockHeight}`, + `Date: ${date}`, + `Confirmations: ${tx.confirmations}`, + `Value: ${(tx.value / 1e8).toFixed(8)} BTC`, + `Fee: ${(tx.fee / 1e8).toFixed(8)} BTC`, + `From: ${tx.from.join(', ') || 'N/A'}`, + `To: ${tx.to.join(', ') || 'N/A'}`, + ], + }) + } + + if (allTxs.length > 50) { + sections.push({ title: 'Note', type: 'text', data: `Showing 50 of ${allTxs.length} transactions.` }) + } + } else { + sections.push({ title: 'Transaction History', type: 'text', data: 'No transactions found' }) + } + + // 3. Address flow analysis from tx data + onProgress?.('Computing address flow...', 70) + sections.push(...buildAddressFlowFromTxs(allTxs)) + + return sections +} + +// ── Address Flow Analysis ──────────────────────────────────────────── + +function buildAddressFlowFromTxs(txs: BtcTx[]): ReportSection[] { + const sections: ReportSection[] = [] + + // Collect unique external addresses we sent to / received from + const sentToMap = new Map() + const recvFromMap = new Map() + + for (const tx of txs) { + if (tx.direction === 'sent') { + for (const addr of tx.to) { + const e = sentToMap.get(addr) || { amount: 0, count: 0 } + e.amount += tx.value + e.count += 1 + sentToMap.set(addr, e) + } + } else if (tx.direction === 'received') { + for (const addr of tx.from) { + const e = recvFromMap.get(addr) || { amount: 0, count: 0 } + e.amount += tx.value + e.count += 1 + recvFromMap.set(addr, e) + } + } + } + + const sentTo = Array.from(sentToMap.entries()) + .map(([addr, d]) => ({ address: addr, amount: d.amount, count: d.count })) + .sort((a, b) => b.amount - a.amount) + + const recvFrom = Array.from(recvFromMap.entries()) + .map(([addr, d]) => ({ address: addr, amount: d.amount, count: d.count })) + .sort((a, b) => b.amount - a.amount) + + const totalSent = sentTo.reduce((s, a) => s + a.amount, 0) + const totalRecv = recvFrom.reduce((s, a) => s + a.amount, 0) + + sections.push({ + title: '7. Address Flow Analysis', + type: 'summary', + data: [ + `BTC Sent to: ${(totalSent / 1e8).toFixed(8)} BTC (${sentTo.length} unique addresses)`, + `BTC Received from: ${(totalRecv / 1e8).toFixed(8)} BTC (${recvFrom.length} unique addresses)`, + ], + }) + + if (sentTo.length > 0) { + sections.push({ + title: `Sent To (${sentTo.length})`, + type: 'table', + data: { + headers: ['#', 'Address', 'Amount (BTC)', 'TX Count'], + widths: ['6%', '54%', '25%', '15%'], + rows: sentTo.slice(0, 100).map((a, i) => [ + (i + 1).toString(), a.address, + (a.amount / 1e8).toFixed(8), a.count.toString(), + ]), + }, + }) + } + + if (recvFrom.length > 0) { + sections.push({ + title: `Received From (${recvFrom.length})`, + type: 'table', + data: { + headers: ['#', 'Address', 'Amount (BTC)', 'TX Count'], + widths: ['6%', '54%', '25%', '15%'], + rows: recvFrom.slice(0, 100).map((a, i) => [ + (i + 1).toString(), a.address, + (a.amount / 1e8).toFixed(8), a.count.toString(), + ]), + }, + }) + } + + return sections +} + +// ── Main Report Generator ──────────────────────────────────────────── + +export interface GenerateReportOptions { + balances: ChainBalance[] + btcXpubs?: Array<{ xpub: string; scriptType: string; path: number[] }> + deviceId?: string + deviceLabel?: string + onProgress?: (message: string, percent: number) => void +} + +export async function generateReport(opts: GenerateReportOptions): Promise { + const { balances, btcXpubs, deviceId, deviceLabel, onProgress } = opts + const baseUrl = getPioneerBase() + const sections: ReportSection[] = [] + const now = new Date() + + onProgress?.('Starting report generation...', 5) + + // ── Pre-flight: Ensure BTC balance is in the balances array ── + // The cached balances may have BTC at $0 because GetPortfolio (charts/portfolio) + // doesn't return BTC data. Fetch it directly before building overview sections. + let btcSectionsResult: ReportSection[] = [] + if (btcXpubs && btcXpubs.length > 0) { + const btcEntry = balances.find(b => b.chainId === 'bitcoin' || b.symbol === 'BTC') + const btcHasBalance = btcEntry && parseFloat(btcEntry.balance) > 0 + + if (!btcHasBalance) { + console.log('[reports] BTC missing/zero in cached balances — fetching from Pioneer directly') + try { + let totalSats = 0 + for (const x of btcXpubs) { + if (!x.xpub) continue + const info = await fetchPubkeyInfo(baseUrl, x.xpub) + totalSats += parseInt(String(info.balance || '0'), 10) + } + if (totalSats > 0) { + const btcBalance = totalSats / 1e8 + // Fetch BTC price from Pioneer market endpoint + let btcUsd = 0 + try { + const priceResp = await fetchWithTimeout( + `${baseUrl}/api/v1/market/info`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': PIONEER_QUERY_KEY }, + body: JSON.stringify([BTC_CAIP]), + }, + 10_000, + ) + if (priceResp.ok) { + const priceData = await priceResp.json() + const price = priceData?.data?.[0] || priceData?.[0] || 0 + btcUsd = btcBalance * (typeof price === 'number' ? price : parseFloat(String(price)) || 0) + } + } catch (e: any) { + console.warn('[reports] BTC price fetch failed:', e.message) + } + + // Inject or update BTC in balances array + if (btcEntry) { + btcEntry.balance = btcBalance.toFixed(8) + btcEntry.balanceUsd = btcUsd + console.log(`[reports] Updated BTC in balances: ${btcBalance.toFixed(8)} BTC, $${btcUsd.toFixed(2)}`) + } else { + balances.push({ + chainId: 'bitcoin', symbol: 'BTC', + balance: btcBalance.toFixed(8), + balanceUsd: btcUsd, + address: btcXpubs[0]?.xpub || '', + }) + console.log(`[reports] Injected BTC into balances: ${btcBalance.toFixed(8)} BTC, $${btcUsd.toFixed(2)}`) + } + } + } catch (e: any) { + console.warn('[reports] BTC balance pre-fetch failed:', e.message) + } + } + } + + // 1. Device Information + sections.push(buildDeviceFeaturesSection()) + + // 2. Portfolio Overview (now includes corrected BTC balance) + sections.push(buildPortfolioOverviewSection(balances)) + + // 3. Chain Balances (now includes corrected BTC balance) + sections.push(buildChainBalancesSection(balances)) + + // 4. Cached Pubkeys & XPUBs + if (deviceId) { + sections.push(...buildCachedPubkeysSection(deviceId, btcXpubs)) + } else if (btcXpubs && btcXpubs.length > 0) { + // No deviceId but have xpubs — still include them + sections.push(...buildCachedPubkeysSection('unknown', btcXpubs)) + } + + // 5. Token Details + sections.push(...buildTokenDetailsSections(balances)) + + // 6 & 7. BTC Detailed Report + Address Flow (via /utxo/pubkey-info + /tx/history) + console.log(`[reports] BTC section check: btcXpubs=${btcXpubs?.length ?? 0}`) + if (btcXpubs && btcXpubs.length > 0) { + try { + const btcSections = await buildBtcSections(baseUrl, btcXpubs, onProgress) + sections.push(...btcSections) + onProgress?.('BTC report complete', 80) + } catch (e: any) { + console.warn('[reports] BTC report failed:', e.message) + sections.push({ + title: '6. BTC Detailed Report', + type: 'text', + data: `Failed to fetch BTC data: ${e.message}. Cached balance data is included above.`, + }) + onProgress?.('BTC report failed, using cached data', 80) + } + } + + onProgress?.('Finalizing report...', 90) + + console.log(`[reports] Report generated: ${sections.length} sections`) + for (const s of sections) { + const count = s.type === 'table' ? ` (${s.data?.rows?.length || 0} rows)` : '' + console.log(` - [${s.type}] ${s.title}${count}`) + } + + return { + title: `${deviceLabel || 'KeepKey'} Portfolio Report`, + subtitle: `Full Wallet Analysis — ${balances.length} Chains`, + generatedDate: now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), + chain: 'all', + sections, + } +} + +// ── CSV Export ──────────────────────────────────────────────────────── + +export function reportToCsv(data: ReportData): string { + const lines: string[] = [] + + lines.push(`"${data.title}"`) + lines.push(`"${data.subtitle}"`) + lines.push(`"Generated: ${data.generatedDate}"`) + lines.push('') + + for (const section of data.sections) { + lines.push(`"${section.title}"`) + + switch (section.type) { + case 'table': { + const headers = section.data.headers || [] + const rows = section.data.rows || [] + lines.push(headers.map((h: string) => `"${h}"`).join(',')) + for (const row of rows) { + lines.push(row.map((cell: any) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + } + break + } + case 'summary': + case 'list': { + const items = Array.isArray(section.data) ? section.data : [section.data] + for (const item of items) { + lines.push(`"${String(item).replace(/"/g, '""')}"`) + } + break + } + case 'text': { + lines.push(`"${String(section.data).replace(/"/g, '""')}"`) + break + } + } + + lines.push('') + } + + return lines.join('\n') +} + +// ── PDF Export (pdf-lib — Bun-compatible) ──────────────────────────── + +const MARGIN_LEFT = 40 +const MARGIN_RIGHT = 40 +const MARGIN_TOP = 50 +const MARGIN_BOTTOM = 50 +const ROW_HEIGHT = 14 +const SECTION_GAP = 20 +const GOLD = { r: 1, g: 0.843, b: 0 } +const ALT_ROW = { r: 0.94, g: 0.94, b: 0.94 } +const TEXT_COLOR = { r: 0.2, g: 0.2, b: 0.2 } +const MUTED_COLOR = { r: 0.6, g: 0.6, b: 0.6 } + +/** + * Sanitize text for pdf-lib StandardFonts (WinAnsi encoding only). + * Strips characters outside the printable ASCII + Latin-1 Supplement range. + */ +function sanitize(text: string): string { + if (!text) return '' + // Replace common unicode with ASCII equivalents, then strip anything outside WinAnsi + return text + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201C\u201D]/g, '"') + .replace(/\u2026/g, '...') + .replace(/\u2014/g, '--') + .replace(/\u2013/g, '-') + .replace(/[^\x20-\x7E\xA0-\xFF]/g, '?') +} + +export async function reportToPdfBuffer(data: ReportData): Promise { + const { PDFDocument, StandardFonts, rgb, degrees } = await import('pdf-lib') + + console.log('[reports] Starting PDF generation...') + + const doc = await PDFDocument.create() + const font = await doc.embedFont(StandardFonts.Helvetica) + const bold = await doc.embedFont(StandardFonts.HelveticaBold) + + // Landscape Letter: 792 x 612 + const pageW = 792 + const pageH = 612 + const contentW = pageW - MARGIN_LEFT - MARGIN_RIGHT + + let page = doc.addPage([pageW, pageH]) + let pageNum = 1 + let y = pageH - MARGIN_TOP + + function newPage() { + page.drawText(`Page ${pageNum}`, { + x: pageW / 2 - 20, y: 25, + font, size: 9, color: rgb(MUTED_COLOR.r, MUTED_COLOR.g, MUTED_COLOR.b), + }) + page = doc.addPage([pageW, pageH]) + pageNum++ + y = pageH - MARGIN_TOP + } + + function needSpace(needed: number) { + if (y - needed < MARGIN_BOTTOM) newPage() + } + + function safeDrawText(text: string, x: number, yPos: number, f: any, s: number, c: { r: number; g: number; b: number }, maxW?: number) { + let t = sanitize(text) + if (maxW && maxW > 0) { + while (t.length > 1 && f.widthOfTextAtSize(t, s) > maxW) { + t = t.slice(0, -1) + } + if (t.length < sanitize(text).length && t.length > 0) { + t = t.slice(0, -1) + '..' + } + } + if (!t) return + try { + page.drawText(t, { x, y: yPos, font: f, size: s, color: rgb(c.r, c.g, c.b) }) + } catch (e: any) { + console.warn('[reports] PDF drawText failed for:', JSON.stringify(t.slice(0, 40)), e.message) + } + } + + // ── Page 1: Dashboard + Pie Chart ──────────────────────────── + + // Extract chain balance data for pie chart + const balancesSection = data.sections.find(s => s.title?.includes('Chain Balances') && s.type === 'table') + const overviewSection = data.sections.find(s => s.title?.includes('Portfolio Overview') && s.type === 'summary') + + // Parse chain balances for pie chart + interface ChainSlice { symbol: string; usd: number; color: { r: number; g: number; b: number } } + const slices: ChainSlice[] = [] + let totalPortfolioUsd = 0 + + if (balancesSection?.data?.rows) { + const PIE_COLORS = [ + { r: 1.0, g: 0.843, b: 0.0 }, // Gold (BTC) + { r: 0.29, g: 0.33, b: 0.91 }, // Blue (ETH) + { r: 0.15, g: 0.78, b: 0.47 }, // Green + { r: 0.91, g: 0.30, b: 0.24 }, // Red + { r: 0.58, g: 0.29, b: 0.91 }, // Purple + { r: 0.95, g: 0.61, b: 0.07 }, // Orange + { r: 0.20, g: 0.71, b: 0.83 }, // Cyan + { r: 0.83, g: 0.21, b: 0.51 }, // Pink + { r: 0.44, g: 0.73, b: 0.27 }, // Lime + { r: 0.60, g: 0.60, b: 0.60 }, // Gray (Other) + ] + // balancesSection rows: [Chain, Symbol, Balance, USD, Address, Tokens] + for (const row of balancesSection.data.rows) { + const usdStr = String(row[3] || '0').replace(/[$,]/g, '') + const usd = parseFloat(usdStr) || 0 + if (usd > 0) { + slices.push({ symbol: String(row[1] || '?'), usd, color: PIE_COLORS[Math.min(slices.length, PIE_COLORS.length - 1)] }) + totalPortfolioUsd += usd + } + } + } + + // Title + const title = sanitize(data.title) + const titleW = bold.widthOfTextAtSize(title, 22) + safeDrawText(title, (pageW - titleW) / 2, y, bold, 22, { r: 0.15, g: 0.15, b: 0.15 }) + y -= 26 + + const subtitle = sanitize(data.subtitle) + const subW = font.widthOfTextAtSize(subtitle, 13) + safeDrawText(subtitle, (pageW - subW) / 2, y, font, 13, { r: 0.4, g: 0.4, b: 0.4 }) + y -= 18 + + const dateStr = sanitize(`Generated on ${data.generatedDate}`) + const dateW = font.widthOfTextAtSize(dateStr, 10) + safeDrawText(dateStr, (pageW - dateW) / 2, y, font, 10, MUTED_COLOR) + y -= 30 + + // Gold divider line + page.drawRectangle({ + x: MARGIN_LEFT, y: y, + width: contentW, height: 2, + color: rgb(GOLD.r, GOLD.g, GOLD.b), + }) + y -= 30 + + // ── Total Portfolio Value (big number) ── + const totalStr = `$${totalPortfolioUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + const totalLabel = 'Total Portfolio Value' + const totalLabelW = font.widthOfTextAtSize(totalLabel, 11) + const totalValW = bold.widthOfTextAtSize(totalStr, 28) + safeDrawText(totalLabel, (pageW - totalLabelW) / 2, y, font, 11, MUTED_COLOR) + y -= 34 + safeDrawText(totalStr, (pageW - totalValW) / 2, y, bold, 28, { r: 0.15, g: 0.15, b: 0.15 }) + y -= 40 + + // ── Layout: Pie chart (left), Legend (right) ── + if (slices.length > 0) { + const pieRadius = 110 + const pieCenterX = MARGIN_LEFT + pieRadius + 40 + const pieCenterY = y - pieRadius - 10 + const legendX = pieCenterX + pieRadius + 60 + + // Draw pie chart using filled triangle fan approximation + let startAngle = -Math.PI / 2 // start from top + for (const slice of slices) { + const fraction = slice.usd / totalPortfolioUsd + const sweepAngle = fraction * 2 * Math.PI + const steps = Math.max(2, Math.ceil(sweepAngle / 0.05)) + const angleStep = sweepAngle / steps + + for (let i = 0; i < steps; i++) { + const a1 = startAngle + i * angleStep + const a2 = startAngle + (i + 1) * angleStep + // Draw a thin triangle from center to arc segment + const x1 = pieCenterX + pieRadius * Math.cos(a1) + const y1 = pieCenterY + pieRadius * Math.sin(a1) + const x2 = pieCenterX + pieRadius * Math.cos(a2) + const y2 = pieCenterY + pieRadius * Math.sin(a2) + + // Use drawLine for the two radii and arc + // Actually use filled rectangles rotated — pdf-lib doesn't have drawTriangle + // Instead, draw many thin lines from center + page.drawLine({ + start: { x: pieCenterX, y: pieCenterY }, + end: { x: x1, y: y1 }, + thickness: 3, + color: rgb(slice.color.r, slice.color.g, slice.color.b), + }) + } + + // Fill the area with closely-spaced lines from center to arc + const fillSteps = Math.max(10, Math.ceil(sweepAngle / 0.015)) + const fillStep = sweepAngle / fillSteps + for (let i = 0; i <= fillSteps; i++) { + const a = startAngle + i * fillStep + const ex = pieCenterX + pieRadius * Math.cos(a) + const ey = pieCenterY + pieRadius * Math.sin(a) + page.drawLine({ + start: { x: pieCenterX, y: pieCenterY }, + end: { x: ex, y: ey }, + thickness: 2.5, + color: rgb(slice.color.r, slice.color.g, slice.color.b), + }) + } + + startAngle += sweepAngle + } + + // Draw white circle in center for donut effect + const innerR = pieRadius * 0.45 + for (let a = 0; a < Math.PI * 2; a += 0.01) { + page.drawLine({ + start: { x: pieCenterX, y: pieCenterY }, + end: { x: pieCenterX + innerR * Math.cos(a), y: pieCenterY + innerR * Math.sin(a) }, + thickness: 2.5, + color: rgb(1, 1, 1), + }) + } + + // Center text in donut hole + const chainCountStr = `${slices.length} Chains` + const ccW = bold.widthOfTextAtSize(chainCountStr, 12) + safeDrawText(chainCountStr, pieCenterX - ccW / 2, pieCenterY - 4, bold, 12, { r: 0.3, g: 0.3, b: 0.3 }) + + // ── Legend (right side) ── + let legendY = y - 10 + safeDrawText('Asset Allocation', legendX, legendY, bold, 13, { r: 0.2, g: 0.2, b: 0.2 }) + legendY -= 24 + + for (const slice of slices.slice(0, 12)) { + const pct = ((slice.usd / totalPortfolioUsd) * 100).toFixed(1) + const usdStr = `$${slice.usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + + // Color swatch + page.drawRectangle({ + x: legendX, y: legendY - 2, + width: 12, height: 12, + color: rgb(slice.color.r, slice.color.g, slice.color.b), + }) + + safeDrawText(slice.symbol, legendX + 18, legendY, bold, 10, { r: 0.2, g: 0.2, b: 0.2 }) + safeDrawText(`${pct}%`, legendX + 70, legendY, font, 10, MUTED_COLOR) + safeDrawText(usdStr, legendX + 110, legendY, font, 10, TEXT_COLOR) + legendY -= 18 + } + + if (slices.length > 12) { + safeDrawText(`... and ${slices.length - 12} more`, legendX + 18, legendY, font, 9, MUTED_COLOR) + } + + y = pieCenterY - pieRadius - 30 + } + + // ── Dashboard Summary Stats ── + if (overviewSection && Array.isArray(overviewSection.data)) { + needSpace(100) + y -= 10 + page.drawRectangle({ + x: MARGIN_LEFT, y: y - 70, + width: contentW, height: 70, + color: rgb(0.96, 0.96, 0.96), + }) + // Render overview items in a row + const items = overviewSection.data.filter((s: string) => typeof s === 'string') + const colW = contentW / Math.min(items.length, 4) + let statX = MARGIN_LEFT + 15 + for (let i = 0; i < Math.min(items.length, 4); i++) { + const parts = String(items[i]).split(':') + const label = sanitize(parts[0]?.trim() || '') + const value = sanitize(parts.slice(1).join(':').trim() || '') + safeDrawText(label, statX, y - 22, font, 9, MUTED_COLOR) + safeDrawText(value, statX, y - 40, bold, 13, { r: 0.15, g: 0.15, b: 0.15 }) + statX += colW + } + y -= 90 + } + + // Page 1 footer + page.drawText(`Page ${pageNum}`, { + x: pageW / 2 - 20, y: 25, + font, size: 9, color: rgb(MUTED_COLOR.r, MUTED_COLOR.g, MUTED_COLOR.b), + }) + + // ── Page 2+: Detail Sections ───────────────────────────────── + newPage() + + // ── Sections ───────────────────────────────────────────────── + for (const section of data.sections) { + needSpace(ROW_HEIGHT * 3) + safeDrawText(sanitize(section.title), MARGIN_LEFT, y, bold, 13, { r: 0.2, g: 0.2, b: 0.2 }) + y -= ROW_HEIGHT + 4 + + switch (section.type) { + case 'table': { + const headers: string[] = section.data.headers || [] + const rows: string[][] = section.data.rows || [] + const widthPcts: string[] = section.data.widths || [] + if (headers.length === 0 || rows.length === 0) break + + const colWidths = widthPcts.map((w: string) => { + const pct = parseFloat(w) + return isNaN(pct) ? contentW / headers.length : (pct / 100) * contentW + }) + while (colWidths.length < headers.length) colWidths.push(contentW / headers.length) + + // Gold header row + needSpace(ROW_HEIGHT + 4) + page.drawRectangle({ + x: MARGIN_LEFT - 2, y: y - 4, + width: contentW + 4, height: ROW_HEIGHT + 2, + color: rgb(GOLD.r, GOLD.g, GOLD.b), + }) + let colX = MARGIN_LEFT + for (let i = 0; i < headers.length; i++) { + safeDrawText(headers[i], colX + 2, y, bold, 8, { r: 0, g: 0, b: 0 }, colWidths[i] - 4) + colX += colWidths[i] + } + y -= ROW_HEIGHT + 2 + + // Data rows + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + needSpace(ROW_HEIGHT) + if (rowIdx % 2 === 0) { + page.drawRectangle({ + x: MARGIN_LEFT - 2, y: y - 4, + width: contentW + 4, height: ROW_HEIGHT, + color: rgb(ALT_ROW.r, ALT_ROW.g, ALT_ROW.b), + }) + } + colX = MARGIN_LEFT + const row = rows[rowIdx] + for (let i = 0; i < row.length && i < headers.length; i++) { + safeDrawText(String(row[i] ?? ''), colX + 2, y, font, 7, TEXT_COLOR, colWidths[i] - 4) + colX += colWidths[i] + } + y -= ROW_HEIGHT + } + y -= 6 + break + } + + case 'summary': + case 'list': { + const items: string[] = Array.isArray(section.data) ? section.data : [section.data] + for (const item of items) { + needSpace(ROW_HEIGHT) + safeDrawText('- ' + sanitize(String(item)), MARGIN_LEFT + 10, y, font, 9, TEXT_COLOR) + y -= ROW_HEIGHT + } + y -= 4 + break + } + + case 'text': { + const fullText = sanitize(String(section.data)) + const words = fullText.split(' ') + let line = '' + for (const word of words) { + const test = line ? `${line} ${word}` : word + if (font.widthOfTextAtSize(test, 9) > contentW - 20) { + needSpace(ROW_HEIGHT) + safeDrawText(line, MARGIN_LEFT + 10, y, font, 9, TEXT_COLOR) + y -= ROW_HEIGHT + line = word + } else { + line = test + } + } + if (line) { + needSpace(ROW_HEIGHT) + safeDrawText(line, MARGIN_LEFT + 10, y, font, 9, TEXT_COLOR) + y -= ROW_HEIGHT + } + y -= 4 + break + } + } + + y -= SECTION_GAP / 2 + } + + // Footer on last page + page.drawText(`Page ${pageNum}`, { + x: pageW / 2 - 20, y: 25, + font, size: 9, color: rgb(MUTED_COLOR.r, MUTED_COLOR.g, MUTED_COLOR.b), + }) + + const bytes = await doc.save() + console.log(`[reports] PDF generated: ${bytes.length} bytes, ${pageNum} pages`) + return Buffer.from(bytes) +} diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index f3f088c..0265d83 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -8,6 +8,7 @@ import { getAssetIcon, registerCustomAsset } from "../../shared/assetLookup" import { AssetPage } from "./AssetPage" import { DonutChart, ChartLegend, type DonutChartItem } from "./DonutChart" import { AddChainDialog } from "./AddChainDialog" +import { ReportDialog } from "./ReportDialog" import { rpcRequest, onRpcMessage } from "../lib/rpc" import type { ChainBalance, CustomChain } from "../../shared/types" @@ -50,6 +51,7 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp const [activeSliceIndex, setActiveSliceIndex] = useState(0) const [customChainDefs, setCustomChainDefs] = useState([]) const [showAddChain, setShowAddChain] = useState(false) + const [showReports, setShowReports] = useState(false) const [pioneerError, setPioneerError] = useState(null) const [cacheUpdatedAt, setCacheUpdatedAt] = useState(null) const [tokenWarning, setTokenWarning] = useState(false) @@ -423,9 +425,34 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp )} - {/* Refresh button — small, highlighted, below chart */} + {/* Refresh + Reports buttons — below chart */} {!watchOnly && ( - + + setShowReports(true)} + > + + + + + + + + + Reports + + )} + + {showReports && ( + setShowReports(false)} /> + )} ) } diff --git a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx new file mode 100644 index 0000000..8fc95c6 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -0,0 +1,302 @@ +import { useState, useEffect, useCallback, useRef } from "react" +import { Box, Flex, Text, Spinner } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" +import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { Z } from "../lib/z-index" +import type { ReportMeta } from "../../shared/types" + +interface ReportDialogProps { + onClose: () => void +} + +export function ReportDialog({ onClose }: ReportDialogProps) { + const { t } = useTranslation("common") + const [generating, setGenerating] = useState(false) + const [progress, setProgress] = useState<{ message: string; percent: number } | null>(null) + const [reports, setReports] = useState([]) + const [error, setError] = useState(null) + const [loadingReports, setLoadingReports] = useState(true) + const activeReportId = useRef(null) + + // Load report list on mount + useEffect(() => { + rpcRequest("listReports", undefined, 5000) + .then(setReports) + .catch(() => {}) + .finally(() => setLoadingReports(false)) + }, []) + + // Listen for progress messages + useEffect(() => { + return onRpcMessage("report-progress", (payload: { id: string; message: string; percent: number }) => { + if (activeReportId.current && payload.id === activeReportId.current) { + setProgress({ message: payload.message, percent: payload.percent }) + } + }) + }, []) + + const handleGenerate = useCallback(async () => { + setGenerating(true) + setError(null) + setProgress({ message: "Starting...", percent: 0 }) + + try { + const meta = await rpcRequest("generateReport", undefined, 300_000) + activeReportId.current = meta.id + setReports(prev => [meta, ...prev]) + } catch (e: any) { + setError(e.message || "Report generation failed") + } finally { + setGenerating(false) + setProgress(null) + activeReportId.current = null + } + }, []) + + const handleDelete = useCallback(async (id: string) => { + try { + await rpcRequest("deleteReport", { id }, 5000) + setReports(prev => prev.filter(r => r.id !== id)) + } catch {} + }, []) + + const [saving, setSaving] = useState(null) + + const handleDownload = useCallback(async (id: string, format: "json" | "csv" | "pdf") => { + try { + setSaving(`${id}-${format}`) + await rpcRequest<{ filePath: string }>("saveReportFile", { id, format }, 30000) + } catch {} finally { + setSaving(null) + } + }, []) + + return ( + <> + {/* Backdrop */} + + + {/* Dialog */} + + {/* Header */} + + + Portfolio Reports + + {!generating && ( + + × + + )} + + + {/* Scrollable body */} + + {/* Security notice */} + + + Full Detail Report — includes device info, all chain balances, cached pubkeys, + token details, BTC transaction history, and address flow analysis. + Store securely and never share with untrusted parties. + + + + {/* Generate button */} + + {generating ? ( + + + {progress?.message || "Generating..."} + + ) : ( + "Generate Report" + )} + + + {/* Progress bar */} + {generating && progress && ( + + + + + + {progress.percent}% + + + )} + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Previous reports */} + {loadingReports ? ( + + + + ) : reports.length > 0 ? ( + + + Previous Reports + + + {reports.map(r => ( + + + + Full Detail Report + + + {r.status === "error" ? "Failed" : `$${r.totalUsd.toFixed(2)}`} + + + + {new Date(r.createdAt).toLocaleString()} + + {r.error && ( + {r.error} + )} + + {r.status === "complete" && ( + <> + {(["JSON", "CSV", "PDF"] as const).map(fmt => { + const key = fmt.toLowerCase() as "json" | "csv" | "pdf" + const savingKey = `${r.id}-${key}` + return ( + !saving && handleDownload(r.id, key)} + > + {saving === savingKey ? "Saving..." : fmt} + + ) + })} + + )} + handleDelete(r.id)} + > + Delete + + + + ))} + + + ) : ( + + No reports generated yet. + + )} + + + + ) +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 6b0ac42..7e6a302 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -1,5 +1,5 @@ import type { ElectrobunRPCSchema } from 'electrobun/bun' -import type { DeviceStateInfo, FirmwareProgress, FirmwareAnalysis, PinRequest, CharacterRequest, ChainBalance, BuildTxParams, BuildTxResult, BroadcastResult, BtcAccountSet, BtcScriptType, EvmAddressSet, CustomToken, CustomChain, AppSettings, BtcGetAddressParams, EthGetAddressParams, EthSignTxParams, BtcSignTxParams, GetPublicKeysParams, UpdateInfo, UpdateStatus, TokenVisibilityStatus, PairingRequestInfo, PairedAppInfo, SigningRequestInfo, ApiLogEntry, PioneerChainInfo } from './types' +import type { DeviceStateInfo, FirmwareProgress, FirmwareAnalysis, PinRequest, CharacterRequest, ChainBalance, BuildTxParams, BuildTxResult, BroadcastResult, BtcAccountSet, BtcScriptType, EvmAddressSet, CustomToken, CustomChain, AppSettings, BtcGetAddressParams, EthGetAddressParams, EthSignTxParams, BtcSignTxParams, GetPublicKeysParams, UpdateInfo, UpdateStatus, TokenVisibilityStatus, PairingRequestInfo, PairedAppInfo, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, ReportMeta, ReportData } from './types' /** * RPC Schema for Bun ↔ WebView communication. @@ -119,6 +119,13 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { setRestApiEnabled: { params: { enabled: boolean }; response: AppSettings } setPioneerApiBase: { params: { url: string }; response: AppSettings } + // ── Reports ────────────────────────────────────────────────────── + generateReport: { params: void; response: ReportMeta } + listReports: { params: void; response: ReportMeta[] } + getReport: { params: { id: string }; response: { meta: ReportMeta; data: ReportData } | null } + deleteReport: { params: { id: string }; response: void } + saveReportFile: { params: { id: string; format: 'json' | 'csv' | 'pdf' }; response: { filePath: string } } + // ── Balance cache (instant portfolio) ───────────────────────────── getCachedBalances: { params: void; response: { balances: ChainBalance[]; updatedAt: number } | null } @@ -162,6 +169,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { 'signing-request': SigningRequestInfo 'signing-dismissed': { id: string } 'api-log': ApiLogEntry + 'report-progress': { id: string; message: string; percent: number } 'walletconnect-uri': string } } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index b088bd6..248d711 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -336,6 +336,31 @@ export interface UpdateStatus { errorMessage?: string } +// ── Report types ──────────────────────────────────────────────────── + +export interface ReportMeta { + id: string + createdAt: number + chain: string + totalUsd: number + status: 'generating' | 'complete' | 'error' + error?: string +} + +export interface ReportData { + title: string + subtitle: string + generatedDate: string + chain?: string + sections: ReportSection[] +} + +export interface ReportSection { + title: string + type: 'table' | 'summary' | 'list' | 'text' | 'transactions' | 'xpub_details' | 'address_details' + data: any +} + // RPC types — derived from the single source of truth in rpc-schema.ts // Import VaultRPCSchema from './rpc-schema' if you need the full Electrobun schema. // These aliases are for convenience in frontend code that doesn't need Electrobun types. From 9b317b996c2007610ee387223e10d21c1823e5d4 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 5 Mar 2026 23:24:56 -0700 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20address=20code=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20progress=20bar=20race,=20CSV=20injection,=20muta?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix progress bar race condition: capture report ID from first progress message instead of waiting for await to resolve (ReportDialog.tsx) - Prevent CSV formula injection by prefixing dangerous chars in csvCell() - Clone balances array in generateReport to avoid caller mutation Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/src/bun/reports.ts | 28 +++++++++++++------ .../src/mainview/components/ReportDialog.tsx | 8 ++++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index 56559f3..1c49713 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -529,7 +529,9 @@ export interface GenerateReportOptions { } export async function generateReport(opts: GenerateReportOptions): Promise { - const { balances, btcXpubs, deviceId, deviceLabel, onProgress } = opts + const { btcXpubs, deviceId, deviceLabel, onProgress } = opts + // Clone balances to avoid mutating the caller's data + const balances = opts.balances.map(b => ({ ...b })) const baseUrl = getPioneerBase() const sections: ReportSection[] = [] const now = new Date() @@ -654,24 +656,32 @@ export async function generateReport(opts: GenerateReportOptions): Promise `"${h}"`).join(',')) + lines.push(headers.map((h: string) => csvCell(h)).join(',')) for (const row of rows) { - lines.push(row.map((cell: any) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + lines.push(row.map((cell: any) => csvCell(cell)).join(',')) } break } @@ -679,12 +689,12 @@ export function reportToCsv(data: ReportData): string { case 'list': { const items = Array.isArray(section.data) ? section.data : [section.data] for (const item of items) { - lines.push(`"${String(item).replace(/"/g, '""')}"`) + lines.push(csvCell(item)) } break } case 'text': { - lines.push(`"${String(section.data).replace(/"/g, '""')}"`) + lines.push(csvCell(section.data)) break } } diff --git a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx index 8fc95c6..06fa080 100644 --- a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -26,10 +26,14 @@ export function ReportDialog({ onClose }: ReportDialogProps) { .finally(() => setLoadingReports(false)) }, []) - // Listen for progress messages + // Listen for progress messages — accept any progress while generating useEffect(() => { return onRpcMessage("report-progress", (payload: { id: string; message: string; percent: number }) => { - if (activeReportId.current && payload.id === activeReportId.current) { + // Capture report ID from first progress message (set before await resolves) + if (!activeReportId.current && payload.id) { + activeReportId.current = payload.id + } + if (payload.id === activeReportId.current) { setProgress({ message: payload.message, percent: payload.percent }) } }) From 66e928344d83e47a3f783eb1f5de565aa9e288d3 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 5 Mar 2026 23:48:22 -0700 Subject: [PATCH 4/7] fix: address all code review findings (H1-H3, M1-M10, L1-L8) HIGH: - H1: Scope getReport/deleteReport to current device ID - H2: Sanitize chain in filenames, append report ID for uniqueness MEDIUM: - M1: Generate Pioneer auth key lazily per request (not stale at module load) - M2: Use Number()+Math.round() instead of parseInt for satoshi values - M3: Consolidate per-tx detail sections into single table (was 50 sections) - M4: Optimize pie chart rendering (~4x fewer draw operations) - M5: Add report pruning (max 50 per device) and LIMIT on list queries - M6: Handle JSON parse corruption gracefully instead of returning null - M7: Check reportExists before final save (prevents delete+save race) - M9: Sanitize error messages to strip auth keys and URLs - M10: Strengthen CSV formula injection (handle whitespace + newlines) LOW: - L1: Discriminated union type for ReportSection (compile-time safety) - L2: Remove unused type variants from ReportSection - L3: Wrap Bun.spawn file reveal in try/catch - L4: Move os/path to top-level imports - L5: Add LIMIT 20 to report list DB query - L6: Per-button saving state (not global disable) - L7: Remove unused useTranslation import - L8: Add delete confirmation step Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/src/bun/db.ts | 53 ++++++-- projects/keepkey-vault/src/bun/index.ts | 56 +++++--- projects/keepkey-vault/src/bun/reports.ts | 127 +++++++++--------- .../src/mainview/components/ReportDialog.tsx | 85 ++++++++---- projects/keepkey-vault/src/shared/types.ts | 10 +- 5 files changed, 208 insertions(+), 123 deletions(-) diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index d394a2b..9fe60e6 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -583,6 +583,8 @@ export function getCachedPubkeys(deviceId: string): Array<{ chainId: string; pat // ── Reports ────────────────────────────────────────────────────────── +const MAX_REPORTS = 50 + export function saveReport(deviceId: string, id: string, chain: string, lod: number, totalUsd: number, status: string, dataJson: string, error?: string) { try { if (!db) return @@ -591,17 +593,26 @@ export function saveReport(deviceId: string, id: string, chain: string, lod: num VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, deviceId, Date.now(), chain, lod, totalUsd, status, error || null, dataJson] ) + // Prune old reports beyond MAX_REPORTS per device + try { + db.run( + `DELETE FROM reports WHERE device_id = ? AND id NOT IN ( + SELECT id FROM reports WHERE device_id = ? ORDER BY created_at DESC LIMIT ? + )`, + [deviceId, deviceId, MAX_REPORTS] + ) + } catch { /* pruning is best-effort */ } } catch (e: any) { console.warn('[db] saveReport failed:', e.message) } } -export function getReportsList(deviceId: string): ReportMeta[] { +export function getReportsList(deviceId: string, limit = 20): ReportMeta[] { try { if (!db) return [] const rows = db.query( - 'SELECT id, created_at, chain, lod, total_usd, status, error FROM reports WHERE device_id = ? ORDER BY created_at DESC' - ).all(deviceId) as Array<{ id: string; created_at: number; chain: string; lod: number; total_usd: number; status: string; error: string | null }> + 'SELECT id, created_at, chain, lod, total_usd, status, error FROM reports WHERE device_id = ? ORDER BY created_at DESC LIMIT ?' + ).all(deviceId, limit) as Array<{ id: string; created_at: number; chain: string; lod: number; total_usd: number; status: string; error: string | null }> return rows.map(r => ({ id: r.id, createdAt: r.created_at, @@ -616,12 +627,14 @@ export function getReportsList(deviceId: string): ReportMeta[] { } } -export function getReportById(id: string): { meta: ReportMeta; data: ReportData } | null { +export function getReportById(id: string, deviceId?: string): { meta: ReportMeta; data: ReportData } | null { try { if (!db) return null - const row = db.query( - 'SELECT id, created_at, chain, lod, total_usd, status, error, data_json FROM reports WHERE id = ?' - ).get(id) as { id: string; created_at: number; chain: string; lod: number; total_usd: number; status: string; error: string | null; data_json: string } | null + const query = deviceId + ? 'SELECT id, created_at, chain, lod, total_usd, status, error, data_json FROM reports WHERE id = ? AND device_id = ?' + : 'SELECT id, created_at, chain, lod, total_usd, status, error, data_json FROM reports WHERE id = ?' + const params = deviceId ? [id, deviceId] : [id] + const row = db.query(query).get(...params) as { id: string; created_at: number; chain: string; lod: number; total_usd: number; status: string; error: string | null; data_json: string } | null if (!row) return null const meta: ReportMeta = { id: row.id, @@ -631,7 +644,13 @@ export function getReportById(id: string): { meta: ReportMeta; data: ReportData status: row.status as ReportMeta['status'], error: row.error || undefined, } - const data: ReportData = JSON.parse(row.data_json) + let data: ReportData + try { + data = JSON.parse(row.data_json) + } catch { + console.warn(`[db] Report ${id} has corrupted JSON data`) + return { meta: { ...meta, status: 'error', error: 'Report data corrupted' }, data: { title: 'Corrupted Report', subtitle: '', generatedDate: '', sections: [] } } + } return { meta, data } } catch (e: any) { console.warn('[db] getReportById failed:', e.message) @@ -639,12 +658,26 @@ export function getReportById(id: string): { meta: ReportMeta; data: ReportData } } -export function deleteReport(id: string) { +export function deleteReport(id: string, deviceId?: string) { try { if (!db) return - db.run('DELETE FROM reports WHERE id = ?', [id]) + if (deviceId) { + db.run('DELETE FROM reports WHERE id = ? AND device_id = ?', [id, deviceId]) + } else { + db.run('DELETE FROM reports WHERE id = ?', [id]) + } } catch (e: any) { console.warn('[db] deleteReport failed:', e.message) } } +export function reportExists(id: string): boolean { + try { + if (!db) return false + const row = db.query('SELECT 1 FROM reports WHERE id = ?').get(id) + return !!row + } catch { + return false + } +} + diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index a960a3f..f23f5cb 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -17,8 +17,10 @@ import { CHAINS, customChainToChainDef } from "../shared/chains" import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" import { EvmAddressManager, evmAddressPath } from "./evm-addresses" -import { initDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport } from "./db" +import { initDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists } from "./db" import { generateReport, reportToCsv, reportToPdfBuffer } from "./reports" +import * as os from "os" +import * as path from "path" import { EVM_RPC_URLS, getTokenMetadata, broadcastEvmTx } from "./evm-rpc" import { startCamera, stopCamera } from "./camera" import type { ChainBalance, TokenBalance, CustomToken, SigningRequestInfo, ApiLogEntry, PioneerChainInfo, EvmAddressSet } from "../shared/types" @@ -1280,7 +1282,10 @@ const rpc = BrowserView.defineRPC({ }) const totalUsd = balances.reduce((s, b) => s + (b.balanceUsd || 0), 0) - saveReport(deviceId, reportId, 'all', 5, totalUsd, 'complete', JSON.stringify(reportData)) + // M7: Only save final result if report wasn't deleted during generation + if (reportExists(reportId)) { + saveReport(deviceId, reportId, 'all', 5, totalUsd, 'complete', JSON.stringify(reportData)) + } try { rpc.send['report-progress']({ id: reportId, message: 'Complete', percent: 100 }) } catch {} @@ -1292,9 +1297,11 @@ const rpc = BrowserView.defineRPC({ status: 'complete' as const, } } catch (e: any) { - saveReport(deviceId, reportId, 'all', 5, 0, 'error', '{}', e.message) - try { rpc.send['report-progress']({ id: reportId, message: `Error: ${e.message}`, percent: 100 }) } catch {} - throw e + // M9: Sanitize error messages — strip auth keys and URLs + const safeMsg = e.message?.replace(/key:[^\s"',}]+/gi, 'key:***').replace(/https?:\/\/[^\s"',}]+/gi, '') || 'Report generation failed' + saveReport(deviceId, reportId, 'all', 5, 0, 'error', '{}', safeMsg) + try { rpc.send['report-progress']({ id: reportId, message: `Error: ${safeMsg}`, percent: 100 }) } catch {} + throw new Error(safeMsg) } }, @@ -1304,46 +1311,59 @@ const rpc = BrowserView.defineRPC({ return getReportsList(deviceId) }, + // H1: Scope getReport/deleteReport to the current device getReport: async (params) => { - return getReportById(params.id) + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) throw new Error('No device connected') + return getReportById(params.id, deviceId) }, deleteReport: async (params) => { - deleteReport(params.id) + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) throw new Error('No device connected') + deleteReport(params.id, deviceId) }, saveReportFile: async (params) => { - const report = getReportById(params.id) + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) throw new Error('No device connected') + const report = getReportById(params.id, deviceId) if (!report) throw new Error('Report not found') - const homedir = require('os').homedir() - const downloadsDir = require('path').join(homedir, 'Downloads') + // H2: Sanitize chain for path safety, append report ID for uniqueness + const safeChain = (report.meta.chain || 'all').replace(/[^a-zA-Z0-9_-]/g, '_') const dateSuffix = new Date(report.meta.createdAt).toISOString().split('T')[0] - const baseName = `keepkey-report-${report.meta.chain}-${dateSuffix}` + const shortId = params.id.slice(-6) + const downloadsDir = path.join(os.homedir(), 'Downloads') + const baseName = `keepkey-report-${safeChain}-${dateSuffix}-${shortId}` console.log(`[reports] saveReportFile: format=${params.format}, id=${params.id}`) let filePath: string if (params.format === 'csv') { - filePath = require('path').join(downloadsDir, `${baseName}.csv`) + filePath = path.join(downloadsDir, `${baseName}.csv`) const csv = reportToCsv(report.data) await Bun.write(filePath, csv) } else if (params.format === 'pdf') { - filePath = require('path').join(downloadsDir, `${baseName}.pdf`) + filePath = path.join(downloadsDir, `${baseName}.pdf`) console.log(`[reports] Generating PDF to ${filePath}...`) const pdfBuffer = await reportToPdfBuffer(report.data) console.log(`[reports] PDF buffer ready: ${pdfBuffer.length} bytes`) await Bun.write(filePath, pdfBuffer) console.log(`[reports] PDF written to disk`) } else { - filePath = require('path').join(downloadsDir, `${baseName}.json`) + filePath = path.join(downloadsDir, `${baseName}.json`) await Bun.write(filePath, JSON.stringify(report.data, null, 2)) } - // Reveal in Finder / file manager - const cmd = process.platform === 'win32' ? 'explorer' : process.platform === 'linux' ? 'xdg-open' : 'open' - const args = process.platform === 'darwin' ? ['-R', filePath] : [filePath] - Bun.spawn([cmd, ...args]) + // L3: Reveal in Finder / file manager (with error handling) + try { + const cmd = process.platform === 'win32' ? 'explorer' : process.platform === 'linux' ? 'xdg-open' : 'open' + const args = process.platform === 'darwin' ? ['-R', filePath] : [filePath] + Bun.spawn([cmd, ...args]) + } catch (e: any) { + console.warn('[reports] Failed to reveal file:', e.message) + } console.log(`[reports] File saved: ${filePath}`) return { filePath } diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index 1c49713..874d0cf 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -16,7 +16,10 @@ import { getLatestDeviceSnapshot, getCachedPubkeys } from './db' import { getPioneerApiBase } from './pioneer' const REPORT_TIMEOUT_MS = 60_000 -const PIONEER_QUERY_KEY = process.env.PIONEER_API_KEY || `key:public-${Date.now()}` + +function getPioneerQueryKey(): string { + return process.env.PIONEER_API_KEY || `key:public-${Date.now()}` +} function getPioneerBase(): string { return getPioneerApiBase() @@ -37,7 +40,7 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe async function fetchPubkeyInfo(baseUrl: string, xpub: string): Promise { const resp = await fetchWithTimeout( `${baseUrl}/api/v1/utxo/pubkey-info/BTC/${xpub}`, - { method: 'GET', headers: { 'Authorization': PIONEER_QUERY_KEY } }, + { method: 'GET', headers: { 'Authorization': getPioneerQueryKey() } }, REPORT_TIMEOUT_MS, ) if (!resp.ok) throw new Error(`PubkeyInfo ${resp.status}`) @@ -50,7 +53,7 @@ async function fetchTxHistory(baseUrl: string, xpub: string, caip: string): Prom `${baseUrl}/api/v1/tx/history`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': PIONEER_QUERY_KEY }, + headers: { 'Content-Type': 'application/json', 'Authorization': getPioneerQueryKey() }, body: JSON.stringify({ queries: [{ pubkey: xpub, caip }] }), }, REPORT_TIMEOUT_MS, @@ -254,9 +257,9 @@ async function buildBtcSections( xpub: x.xpub, scriptType: x.scriptType, label: `${x.scriptType}`, - balance: parseInt(String(info.balance || '0'), 10), - totalReceived: parseInt(String(info.totalReceived || '0'), 10), - totalSent: parseInt(String(info.totalSent || '0'), 10), + balance: Math.round(Number(info.balance || 0)), + totalReceived: Math.round(Number(info.totalReceived || 0)), + totalSent: Math.round(Number(info.totalSent || 0)), txCount: info.txs || 0, usedAddresses: used.map((t: any) => ({ name: t.name, path: t.path, transfers: t.transfers, @@ -342,8 +345,8 @@ async function buildBtcSections( confirmations: tx.confirmations || 0, from: tx.from || [], to: tx.to || [], - value: parseInt(String(tx.value || '0'), 10), - fee: parseInt(String(tx.fee || '0'), 10), + value: Math.round(Number(tx.value || 0)), + fee: Math.round(Number(tx.fee || 0)), status: tx.status || 'confirmed', }) } @@ -404,28 +407,37 @@ async function buildBtcSections( ].filter(Boolean), }) - // Per-transaction details (first 50) - for (const tx of allTxs.slice(0, 50)) { - const date = tx.timestamp - ? new Date(tx.timestamp * 1000).toISOString().replace('T', ' ').substring(0, 19) - : 'Pending' + // Per-transaction details as a single table (capped at 50 rows) + const MAX_TX_DETAILS = 50 + const detailTxs = allTxs.slice(0, MAX_TX_DETAILS) + if (detailTxs.length > 0) { sections.push({ - title: `TX: ${tx.txid.substring(0, 20)}... (${tx.direction.toUpperCase()})`, - type: 'summary', - data: [ - `Block: ${tx.blockHeight}`, - `Date: ${date}`, - `Confirmations: ${tx.confirmations}`, - `Value: ${(tx.value / 1e8).toFixed(8)} BTC`, - `Fee: ${(tx.fee / 1e8).toFixed(8)} BTC`, - `From: ${tx.from.join(', ') || 'N/A'}`, - `To: ${tx.to.join(', ') || 'N/A'}`, - ], + title: `Transaction Details (${Math.min(allTxs.length, MAX_TX_DETAILS)}${allTxs.length > MAX_TX_DETAILS ? ` of ${allTxs.length}` : ''})`, + type: 'table', + data: { + headers: ['TXID', 'Dir', 'Block', 'Date', 'Value (BTC)', 'Fee (BTC)', 'From', 'To'], + widths: ['14%', '6%', '8%', '14%', '12%', '10%', '18%', '18%'], + rows: detailTxs.map(tx => { + const date = tx.timestamp + ? new Date(tx.timestamp * 1000).toISOString().replace('T', ' ').substring(0, 19) + : 'Pending' + return [ + `${tx.txid.substring(0, 16)}...`, + tx.direction.toUpperCase(), + tx.blockHeight.toString(), + date, + (tx.value / 1e8).toFixed(8), + (tx.fee / 1e8).toFixed(8), + tx.from.slice(0, 3).join(', ') || 'N/A', + tx.to.slice(0, 3).join(', ') || 'N/A', + ] + }), + }, }) } - if (allTxs.length > 50) { - sections.push({ title: 'Note', type: 'text', data: `Showing 50 of ${allTxs.length} transactions.` }) + if (allTxs.length > MAX_TX_DETAILS) { + sections.push({ title: 'Note', type: 'text', data: `Showing ${MAX_TX_DETAILS} of ${allTxs.length} transactions.` }) } } else { sections.push({ title: 'Transaction History', type: 'text', data: 'No transactions found' }) @@ -553,7 +565,7 @@ export async function generateReport(opts: GenerateReportOptions): Promise 0) { const btcBalance = totalSats / 1e8 @@ -564,7 +576,7 @@ export async function generateReport(opts: GenerateReportOptions): Promise { function safeDrawText(text: string, x: number, yPos: number, f: any, s: number, c: { r: number; g: number; b: number }, maxW?: number) { let t = sanitize(text) - if (maxW && maxW > 0) { - while (t.length > 1 && f.widthOfTextAtSize(t, s) > maxW) { - t = t.slice(0, -1) + if (maxW && maxW > 0 && t.length > 0 && f.widthOfTextAtSize(t, s) > maxW) { + // Binary search for the longest string that fits + let lo = 0, hi = t.length + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (f.widthOfTextAtSize(t.slice(0, mid), s) <= maxW) lo = mid + else hi = mid - 1 } - if (t.length < sanitize(text).length && t.length > 0) { - t = t.slice(0, -1) + '..' + if (lo < t.length && lo > 2) { + t = t.slice(0, lo - 2) + '..' + } else if (lo < t.length) { + t = t.slice(0, lo) } } if (!t) return @@ -860,36 +879,13 @@ export async function reportToPdfBuffer(data: ReportData): Promise { const pieCenterY = y - pieRadius - 10 const legendX = pieCenterX + pieRadius + 60 - // Draw pie chart using filled triangle fan approximation + // Draw pie chart using thick radial lines (optimized: ~4x fewer draw ops) let startAngle = -Math.PI / 2 // start from top for (const slice of slices) { const fraction = slice.usd / totalPortfolioUsd const sweepAngle = fraction * 2 * Math.PI - const steps = Math.max(2, Math.ceil(sweepAngle / 0.05)) - const angleStep = sweepAngle / steps - - for (let i = 0; i < steps; i++) { - const a1 = startAngle + i * angleStep - const a2 = startAngle + (i + 1) * angleStep - // Draw a thin triangle from center to arc segment - const x1 = pieCenterX + pieRadius * Math.cos(a1) - const y1 = pieCenterY + pieRadius * Math.sin(a1) - const x2 = pieCenterX + pieRadius * Math.cos(a2) - const y2 = pieCenterY + pieRadius * Math.sin(a2) - - // Use drawLine for the two radii and arc - // Actually use filled rectangles rotated — pdf-lib doesn't have drawTriangle - // Instead, draw many thin lines from center - page.drawLine({ - start: { x: pieCenterX, y: pieCenterY }, - end: { x: x1, y: y1 }, - thickness: 3, - color: rgb(slice.color.r, slice.color.g, slice.color.b), - }) - } - - // Fill the area with closely-spaced lines from center to arc - const fillSteps = Math.max(10, Math.ceil(sweepAngle / 0.015)) + // Use thicker lines with wider steps to reduce draw operations + const fillSteps = Math.max(8, Math.ceil(sweepAngle / 0.04)) const fillStep = sweepAngle / fillSteps for (let i = 0; i <= fillSteps; i++) { const a = startAngle + i * fillStep @@ -898,21 +894,20 @@ export async function reportToPdfBuffer(data: ReportData): Promise { page.drawLine({ start: { x: pieCenterX, y: pieCenterY }, end: { x: ex, y: ey }, - thickness: 2.5, + thickness: 4, color: rgb(slice.color.r, slice.color.g, slice.color.b), }) } - startAngle += sweepAngle } - // Draw white circle in center for donut effect + // Draw white circle in center for donut effect (optimized: wider step) const innerR = pieRadius * 0.45 - for (let a = 0; a < Math.PI * 2; a += 0.01) { + for (let a = 0; a < Math.PI * 2; a += 0.03) { page.drawLine({ start: { x: pieCenterX, y: pieCenterY }, end: { x: pieCenterX + innerR * Math.cos(a), y: pieCenterY + innerR * Math.sin(a) }, - thickness: 2.5, + thickness: 4, color: rgb(1, 1, 1), }) } diff --git a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx index 06fa080..1791998 100644 --- a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react" import { Box, Flex, Text, Spinner } from "@chakra-ui/react" -import { useTranslation } from "react-i18next" import { rpcRequest, onRpcMessage } from "../lib/rpc" import { Z } from "../lib/z-index" import type { ReportMeta } from "../../shared/types" @@ -10,13 +9,13 @@ interface ReportDialogProps { } export function ReportDialog({ onClose }: ReportDialogProps) { - const { t } = useTranslation("common") const [generating, setGenerating] = useState(false) const [progress, setProgress] = useState<{ message: string; percent: number } | null>(null) const [reports, setReports] = useState([]) const [error, setError] = useState(null) const [loadingReports, setLoadingReports] = useState(true) const activeReportId = useRef(null) + const [confirmDeleteId, setConfirmDeleteId] = useState(null) // Load report list on mount useEffect(() => { @@ -62,6 +61,7 @@ export function ReportDialog({ onClose }: ReportDialogProps) { await rpcRequest("deleteReport", { id }, 5000) setReports(prev => prev.filter(r => r.id !== id)) } catch {} + setConfirmDeleteId(null) }, []) const [saving, setSaving] = useState(null) @@ -248,6 +248,7 @@ export function ReportDialog({ onClose }: ReportDialogProps) { {(["JSON", "CSV", "PDF"] as const).map(fmt => { const key = fmt.toLowerCase() as "json" | "csv" | "pdf" const savingKey = `${r.id}-${key}` + const isSavingThis = saving === savingKey return ( !saving && handleDownload(r.id, key)} + cursor={isSavingThis ? "default" : "pointer"} + opacity={isSavingThis ? 0.6 : 1} + _hover={isSavingThis ? {} : { bg: "rgba(192,168,96,0.2)" }} + onClick={() => !isSavingThis && handleDownload(r.id, key)} > - {saving === savingKey ? "Saving..." : fmt} + {isSavingThis ? "Saving..." : fmt} ) })} )} - handleDelete(r.id)} - > - Delete - + {/* L8: Delete with confirmation */} + {confirmDeleteId === r.id ? ( + + handleDelete(r.id)} + > + Confirm + + setConfirmDeleteId(null)} + > + Cancel + + + ) : ( + setConfirmDeleteId(r.id)} + > + Delete + + )} ))} diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 248d711..2471a09 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -355,11 +355,11 @@ export interface ReportData { sections: ReportSection[] } -export interface ReportSection { - title: string - type: 'table' | 'summary' | 'list' | 'text' | 'transactions' | 'xpub_details' | 'address_details' - data: any -} +export type ReportSection = + | { title: string; type: 'table'; data: { headers: string[]; rows: string[][]; widths?: string[] } } + | { title: string; type: 'summary'; data: string[] } + | { title: string; type: 'list'; data: string[] } + | { title: string; type: 'text'; data: string } // RPC types — derived from the single source of truth in rpc-schema.ts // Import VaultRPCSchema from './rpc-schema' if you need the full Electrobun schema. From 9aa0bd327ebb82f91ed1c8b9e80a7c89bf0e7408 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 8 Mar 2026 16:52:05 -0600 Subject: [PATCH 5/7] feat: tax export, firmware upgrade preview, OOB wizard overhaul, i18n updates - Add tax export module (CoinTracker/ZenLedger CSV generation) - Add firmware version map and upgrade preview component - Overhaul OOB setup wizard with firmware upgrade preview step - Expand report dialog with enhanced export options - Improve spam filter with tighter heuristics - Update TopNav, Dashboard, DeviceSettingsDrawer, LanguageSelector - Add security assessment docs (fault injection, findus library) - Add firmware build docs - Update i18n locales across all 10 languages Co-Authored-By: Claude Opus 4.6 --- docs/firmware/BUILD.md | 259 ++++ docs/security/fault-injection-assessment.md | 313 +++++ docs/security/fault.md | 142 +++ docs/security/findus-library-analysis.md | 362 ++++++ projects/keepkey-vault/package.json | 3 +- .../src/bun/engine-controller.ts | 75 +- projects/keepkey-vault/src/bun/index.ts | 44 +- projects/keepkey-vault/src/bun/rest-api.ts | 5 + projects/keepkey-vault/src/bun/tax-export.ts | 259 ++++ projects/keepkey-vault/src/mainview/App.tsx | 30 +- .../src/mainview/assets/logo/cointracker.png | Bin 0 -> 7600 bytes .../src/mainview/assets/logo/zenledger.png | Bin 0 -> 5910 bytes .../src/mainview/components/AssetPage.tsx | 6 +- .../src/mainview/components/Dashboard.tsx | 87 +- .../components/DeviceSettingsDrawer.tsx | 14 +- .../mainview/components/FirmwareDropZone.tsx | 5 +- .../components/FirmwareUpgradePreview.tsx | 371 ++++++ .../mainview/components/OobSetupWizard.tsx | 1053 +++++++++++++---- .../src/mainview/components/ReportDialog.tsx | 112 +- .../src/mainview/components/TopNav.tsx | 55 +- .../src/mainview/hooks/useDeviceState.ts | 2 +- .../src/mainview/hooks/useFirmwareUpdate.ts | 27 +- .../src/mainview/i18n/LanguageSelector.tsx | 85 +- .../mainview/i18n/locales/de/dashboard.json | 24 +- .../src/mainview/i18n/locales/de/setup.json | 66 +- .../mainview/i18n/locales/en/dashboard.json | 14 +- .../src/mainview/i18n/locales/en/setup.json | 56 +- .../mainview/i18n/locales/es/dashboard.json | 24 +- .../src/mainview/i18n/locales/es/setup.json | 66 +- .../mainview/i18n/locales/fr/dashboard.json | 24 +- .../src/mainview/i18n/locales/fr/setup.json | 66 +- .../mainview/i18n/locales/it/dashboard.json | 24 +- .../src/mainview/i18n/locales/it/setup.json | 66 +- .../mainview/i18n/locales/ja/dashboard.json | 24 +- .../src/mainview/i18n/locales/ja/setup.json | 66 +- .../mainview/i18n/locales/ko/dashboard.json | 24 +- .../src/mainview/i18n/locales/ko/setup.json | 66 +- .../mainview/i18n/locales/pt/dashboard.json | 24 +- .../src/mainview/i18n/locales/pt/setup.json | 66 +- .../mainview/i18n/locales/ru/dashboard.json | 24 +- .../src/mainview/i18n/locales/ru/setup.json | 66 +- .../mainview/i18n/locales/zh/dashboard.json | 24 +- .../src/mainview/i18n/locales/zh/setup.json | 66 +- .../src/shared/firmware-versions.ts | 159 +++ .../keepkey-vault/src/shared/rpc-schema.ts | 2 +- .../keepkey-vault/src/shared/spamFilter.ts | 127 +- projects/keepkey-vault/src/shared/types.ts | 1 + 47 files changed, 4037 insertions(+), 441 deletions(-) create mode 100644 docs/firmware/BUILD.md create mode 100644 docs/security/fault-injection-assessment.md create mode 100644 docs/security/fault.md create mode 100644 docs/security/findus-library-analysis.md create mode 100644 projects/keepkey-vault/src/bun/tax-export.ts create mode 100644 projects/keepkey-vault/src/mainview/assets/logo/cointracker.png create mode 100644 projects/keepkey-vault/src/mainview/assets/logo/zenledger.png create mode 100644 projects/keepkey-vault/src/mainview/components/FirmwareUpgradePreview.tsx create mode 100644 projects/keepkey-vault/src/shared/firmware-versions.ts diff --git a/docs/firmware/BUILD.md b/docs/firmware/BUILD.md new file mode 100644 index 0000000..087f47e --- /dev/null +++ b/docs/firmware/BUILD.md @@ -0,0 +1,259 @@ +# Firmware Build Guide (v7.11.0+ with Solana) + +## Quick Start + +```bash +cd projects/keepkey-vault-v11-solana # or whichever worktree has firmware +make firmware-build # Docker build → artifacts/firmware/ +make firmware-flash FW_PATH=artifacts/firmware/firmware.keepkey. )} diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 0265d83..3ad183e 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -10,7 +10,8 @@ import { DonutChart, ChartLegend, type DonutChartItem } from "./DonutChart" import { AddChainDialog } from "./AddChainDialog" import { ReportDialog } from "./ReportDialog" import { rpcRequest, onRpcMessage } from "../lib/rpc" -import type { ChainBalance, CustomChain } from "../../shared/types" +import { categorizeTokens } from "../../shared/spamFilter" +import type { ChainBalance, CustomChain, TokenVisibilityStatus } from "../../shared/types" const DASHBOARD_ANIMATIONS = ` @keyframes pulseGold { @@ -30,16 +31,16 @@ interface DashboardProps { onOpenSettings?: () => void } -/** Format a timestamp as a relative "time ago" string */ -function formatTimeAgo(ts: number): string { +/** Format a timestamp as a relative "time ago" string (i18n-aware) */ +function formatTimeAgo(ts: number, t: (key: string, opts?: Record) => string): string { const diff = Date.now() - ts const mins = Math.floor(diff / 60_000) - if (mins < 1) return 'just now' - if (mins < 60) return `${mins}m ago` + if (mins < 1) return t('timeJustNow') + if (mins < 60) return t('timeMinutesAgo', { count: mins }) const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours}h ago` + if (hours < 24) return t('timeHoursAgo', { count: hours }) const days = Math.floor(hours / 24) - return `${days}d ago` + return t('timeDaysAgo', { count: days }) } export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProps) { @@ -56,6 +57,14 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp const [cacheUpdatedAt, setCacheUpdatedAt] = useState(null) const [tokenWarning, setTokenWarning] = useState(false) const [hasEverRefreshed, setHasEverRefreshed] = useState(false) + const [visibilityMap, setVisibilityMap] = useState>({}) + + // Load token visibility overrides (for spam filtering) + useEffect(() => { + rpcRequest>('getTokenVisibilityMap', undefined, 5000) + .then(setVisibilityMap) + .catch(() => {}) + }, []) // Listen for Pioneer connection errors from backend useEffect(() => { @@ -112,7 +121,7 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp for (const b of cached.balances) map.set(b.chainId, b) setBalances(map) setCacheUpdatedAt(cached.updatedAt) - console.log(`[Dashboard] Cache hit: ${cached.balances.length} chains, $${cached.balances.reduce((s, b) => s + (b.balanceUsd || 0), 0).toFixed(2)}, age: ${formatTimeAgo(cached.updatedAt)}`) + console.log(`[Dashboard] Cache hit: ${cached.balances.length} chains, $${cached.balances.reduce((s, b) => s + (b.balanceUsd || 0), 0).toFixed(2)}, age: ${formatTimeAgo(cached.updatedAt, t)}`) } } catch { /* cache unavailable */ } @@ -157,7 +166,24 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp setLoadingBalances(false) }, [loadingBalances, watchOnly]) - const totalUsd = useMemo(() => Array.from(balances.values()).reduce((sum, b) => sum + (b.balanceUsd || 0), 0), [balances]) + // Compute spam-filtered USD per chain: subtract spam token values from chain totals + const cleanBalanceUsd = useMemo(() => { + const overrides = new Map(Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v])) + const result = new Map() + for (const [chainId, bal] of balances) { + if (!bal.tokens || bal.tokens.length === 0) { + result.set(chainId, { usd: bal.balanceUsd || 0, cleanTokenCount: 0 }) + continue + } + const { spam } = categorizeTokens(bal.tokens, overrides) + const spamUsd = spam.reduce((s, t) => s + (t.balanceUsd || 0), 0) + const cleanTokens = (bal.tokens?.length || 0) - spam.length + result.set(chainId, { usd: (bal.balanceUsd || 0) - spamUsd, cleanTokenCount: cleanTokens }) + } + return result + }, [balances, visibilityMap]) + + const totalUsd = useMemo(() => Array.from(cleanBalanceUsd.values()).reduce((sum, b) => sum + b.usd, 0), [cleanBalanceUsd]) const allChains = useMemo(() => [...CHAINS, ...customChainDefs], [customChainDefs]) @@ -168,24 +194,24 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp const chartData = useMemo(() => allChains .map((chain) => { - const bal = balances.get(chain.id) - return { name: chain.coin, value: bal?.balanceUsd || 0, color: chain.color, chainId: chain.id } + const clean = cleanBalanceUsd.get(chain.id) + return { name: chain.coin, value: clean?.usd || 0, color: chain.color, chainId: chain.id } }) .filter((d) => d.value > 0) - .sort((a, b) => b.value - a.value), [allChains, balances]) + .sort((a, b) => b.value - a.value), [allChains, cleanBalanceUsd]) const hasAnyBalance = chartData.length > 0 const sortedChains = useMemo(() => [...allChains].sort((a, b) => { - const aUsd = balances.get(a.id)?.balanceUsd || 0 - const bUsd = balances.get(b.id)?.balanceUsd || 0 + const aUsd = cleanBalanceUsd.get(a.id)?.usd || 0 + const bUsd = cleanBalanceUsd.get(b.id)?.usd || 0 const aHas = aUsd > 0 || parseFloat(balances.get(a.id)?.balance || '0') > 0 const bHas = bUsd > 0 || parseFloat(balances.get(b.id)?.balance || '0') > 0 if (aHas && !bHas) return -1 if (!aHas && bHas) return 1 if (aHas && bHas) return bUsd - aUsd return 0 - }), [allChains, balances]) + }), [allChains, balances, cleanBalanceUsd]) // Is data stale? (loaded from cache but haven't refreshed yet this session) const isStale = !hasEverRefreshed && !loadingBalances @@ -242,11 +268,11 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp - {t("pioneerOfflineTitle", { defaultValue: "Balance server offline" })} + {t("pioneerOfflineTitle")} - {t("pioneerOfflineDesc", { defaultValue: "Unable to connect to {{url}}. Balances may be unavailable.", url: pioneerError.url })} + {t("pioneerOfflineDesc", { url: pioneerError.url })} {onOpenSettings && ( @@ -268,7 +294,7 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp onOpenSettings() }} > - {t("changeServer", { defaultValue: "Change Server" })} + {t("changeServer")} )} window.open("https://support.keepkey.com", "_blank")} > - {t("getSupport", { defaultValue: "Get Support" })} + {t("getSupport")} - {t("retry", { defaultValue: "Retry" })} + {t("retry")} @@ -394,10 +420,10 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp - {t("welcomeTitle", { defaultValue: "Welcome to KeepKey Vault" })} + {t("welcomeTitle")} - {t("welcomeSubtitle", { defaultValue: "Your wallet is ready. Here's how to get started:" })} + {t("welcomeSubtitle")} @@ -405,19 +431,19 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp 1. - {t("welcomeTip1", { defaultValue: "Tap any chain below, then hit Receive to get your deposit address" })} + {t("welcomeTip1")} 2. - {t("welcomeTip2", { defaultValue: "Send crypto to your address — your balance will appear here automatically" })} + {t("welcomeTip2")} 3. - {t("welcomeTip3", { defaultValue: "Add custom EVM chains with the + card to track any network" })} + {t("welcomeTip3")} @@ -450,7 +476,7 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp - Reports + {t("reports")} - {formatTimeAgo(cacheUpdatedAt)} + {formatTimeAgo(cacheUpdatedAt, t)} {" · "}{t("refreshBalances")} - : t("refreshPrompt", { defaultValue: "Press to update balances" })} + : t("refreshPrompt")} @@ -503,10 +529,11 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProp {sortedChains.map((chain) => { const bal = balances.get(chain.id) + const clean = cleanBalanceUsd.get(chain.id) const balNum = parseFloat(bal?.balance || '0') - const usdNum = bal?.balanceUsd || 0 + const usdNum = clean?.usd || 0 const hasBalance = balNum > 0 || usdNum > 0 - const tokenCount = bal?.tokens?.length || 0 + const tokenCount = clean?.cleanTokenCount || 0 return ( void onOpenPairedApps?: () => void onRestApiChanged?: (enabled: boolean) => void + onWordCountChange?: (count: 12 | 18 | 24) => void } // ── Collapsible Section ───────────────────────────────────────────── @@ -125,7 +126,7 @@ function VerificationBadge({ verified, t }: { verified?: boolean; t: (key: strin // ── Main Component ────────────────────────────────────────────────── -export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpdate, updatePhase, appVersion, onOpenAuditLog, onOpenPairedApps, onRestApiChanged }: DeviceSettingsDrawerProps) { +export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpdate, updatePhase, appVersion, onOpenAuditLog, onOpenPairedApps, onRestApiChanged, onWordCountChange }: DeviceSettingsDrawerProps) { const { t } = useTranslation("settings") const [features, setFeatures] = useState(null) const [featuresError, setFeaturesError] = useState(false) @@ -208,6 +209,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd const verifySeed = useCallback(async () => { setVerifying(true) setVerifyResult(null) + onWordCountChange?.(verifyWordCount) try { const result = await rpcRequest("verifySeed", { wordCount: verifyWordCount }, 0) as { success: boolean; message: string } setVerifyResult(result) @@ -391,6 +393,11 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd {/* Content */} + {/* ── Language ────────────────────────────────────── */} +
+ +
+ {/* ── Device Identity ─────────────────────────────── */}
@@ -802,11 +809,6 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd )} - {/* ── Language ────────────────────── */} - - {t("language")} - -
diff --git a/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx b/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx index e379412..073c1ef 100644 --- a/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx +++ b/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx @@ -30,10 +30,13 @@ export function FirmwareDropZone() { const [warningAcknowledged, setWarningAcknowledged] = useState(false) const [wipeAcknowledged, setWipeAcknowledged] = useState(false) const dragCounter = useRef(0) + const phaseRef = useRef(phase) + phaseRef.current = phase - // Listen for firmware progress + // Listen for firmware progress — only react when THIS component initiated the flash (C1 fix) useEffect(() => { return onRpcMessage("firmware-progress", (payload: FirmwareProgress) => { + if (phaseRef.current !== "flashing") return setProgress(payload) if (payload.percent >= 100) { setPhase("complete") diff --git a/projects/keepkey-vault/src/mainview/components/FirmwareUpgradePreview.tsx b/projects/keepkey-vault/src/mainview/components/FirmwareUpgradePreview.tsx new file mode 100644 index 0000000..7fc5e55 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/FirmwareUpgradePreview.tsx @@ -0,0 +1,371 @@ +/** + * FirmwareUpgradePreview — shows users what new features they'll unlock + * by upgrading firmware. Animated chain logos, feature cards, and a + * glowing highlight for the primary new chain. + */ +import { useState, useEffect, useMemo } from 'react' +import { Box, Text, VStack, HStack, Flex } from '@chakra-ui/react' +import { CHAINS } from '../../shared/chains' +import { getUpgradeFeatures, getVersionInfo, type FirmwareFeature } from '../../shared/firmware-versions' + +// ── Icon URL helper (same convention as assetLookup.ts) ────────────── +function chainIconUrl(caip: string): string { + return `https://api.keepkey.info/coins/${btoa(caip).replace(/=+$/, '')}.png` +} + +// ── CSS keyframe animations (injected once) ────────────────────────── +const ANIM_ID = '__fw-upgrade-anims' +function ensureAnimations() { + if (typeof document === 'undefined') return + if (document.getElementById(ANIM_ID)) return + const style = document.createElement('style') + style.id = ANIM_ID + style.textContent = ` + @keyframes fw-glow-pulse { + 0%, 100% { box-shadow: 0 0 20px 4px var(--glow-color, rgba(20,241,149,0.3)); } + 50% { box-shadow: 0 0 40px 12px var(--glow-color, rgba(20,241,149,0.5)); } + } + @keyframes fw-float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-6px); } + } + @keyframes fw-fade-up { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes fw-shine { + 0% { background-position: -200% center; } + 100% { background-position: 200% center; } + } + @keyframes fw-orbit { + 0% { transform: rotate(0deg) translateX(48px) rotate(0deg); } + 100% { transform: rotate(360deg) translateX(48px) rotate(-360deg); } + } + @keyframes fw-scale-in { + from { opacity: 0; transform: scale(0.7); } + to { opacity: 1; transform: scale(1); } + } + ` + document.head.appendChild(style) +} + +interface Props { + /** Current device firmware version (null if OOB / not installed) */ + currentVersion: string | null + /** Target firmware version being installed */ + targetVersion: string +} + +export function FirmwareUpgradePreview({ currentVersion, targetVersion }: Props) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + ensureAnimations() + const timer = setTimeout(() => setVisible(true), 100) + return () => clearTimeout(timer) + }, []) + + const features = useMemo( + () => getUpgradeFeatures(currentVersion, targetVersion), + [currentVersion, targetVersion], + ) + + const versionInfo = useMemo(() => getVersionInfo(targetVersion), [targetVersion]) + + if (features.length === 0) return null + + // Resolve chain data for logo display + const chainFeatures = features.filter(f => f.chains?.length) + const resolvedChains = chainFeatures.flatMap(f => + (f.chains || []).map(chainId => { + const chain = CHAINS.find(c => c.id === chainId) + return chain ? { ...chain, featureColor: f.color || chain.color } : null + }).filter(Boolean) + ) as Array<(typeof CHAINS)[0] & { featureColor: string }> + + // Primary feature (first chain feature for hero display) + const hero = resolvedChains[0] + const heroColor = hero?.featureColor || '#14F195' + + return ( + + + {/* Ambient glow behind the card */} + + + + {/* ── Hero section ──────────────────────────────────── */} + + {/* Version badge */} + + + v{targetVersion} + + + + {/* "What's New" label */} + + What's New + + + {/* Headline */} + + {versionInfo?.headline || `Firmware v${targetVersion}`} + + + + {/* ── Chain hero logo ─────────────────────────────── */} + {hero && ( + + {/* Orbiting particles */} + {[0, 1, 2].map(i => ( + + ))} + + {/* Glow ring */} + + + {/* Logo container */} + + {hero.coin} { + // Fallback: show the chain symbol text + const target = e.currentTarget + target.style.display = 'none' + const parent = target.parentElement + if (parent && !parent.querySelector('[data-fallback]')) { + const span = document.createElement('span') + span.setAttribute('data-fallback', '1') + span.style.cssText = `font-size:20px;font-weight:700;color:${heroColor}` + span.textContent = hero.symbol + parent.appendChild(span) + } + }} + /> + + + )} + + {/* ── Feature cards ──────────────────────────────── */} + + {features.map((feature, i) => ( + + ))} + + + + + ) +} + +// ── Feature card ──────────────────────────────────────────────────── + +function FeatureCard({ feature, index, visible }: { feature: FirmwareFeature; index: number; visible: boolean }) { + const color = feature.color || '#C0A860' + + // Resolve chain logos for this feature + const featureChains = (feature.chains || []) + .map(id => CHAINS.find(c => c.id === id)) + .filter(Boolean) as (typeof CHAINS)[number][] + + return ( + + {/* Accent stripe */} + + + + {/* Icon */} + + {feature.icon === 'chain' && featureChains.length > 0 ? ( + + {featureChains[0].coin} { + const t = e.currentTarget + t.style.display = 'none' + }} + /> + + ) : feature.icon === 'security' ? ( + + + + + + ) : feature.icon === 'performance' ? ( + + + + + + ) : ( + + + + + + + + )} + + + {/* Text */} + + + {feature.title} + + + {feature.description} + + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx b/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx index 62199ec..4a85959 100644 --- a/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx +++ b/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx @@ -10,14 +10,18 @@ import { FaPlus, FaChevronDown, FaChevronUp, + FaFolderOpen, } from 'react-icons/fa' import holdAndConnectRaw from '../assets/svg/hold-and-connect.svg?raw' import { useFirmwareUpdate } from '../hooks/useFirmwareUpdate' import { useDeviceState } from '../hooks/useDeviceState' -import { rpcRequest } from '../lib/rpc' +import { rpcRequest, onRpcMessage } from '../lib/rpc' +import type { FirmwareAnalysis, FirmwareProgress } from '../../shared/types' +import { FirmwareUpgradePreview } from './FirmwareUpgradePreview' +import { LanguagePicker } from '../i18n/LanguageSelector' -// ── Design tokens matching keepkey-bitcoin-only ───────────────────────────── -const HIGHLIGHT = 'orange.500' +// ── Design tokens ─────────────────────────────────────────────────────────── +const HIGHLIGHT = 'green.500' // ── Animations ────────────────────────────────────────────────────────────── const ANIMATIONS_CSS = ` @@ -35,9 +39,9 @@ const ANIMATIONS_CSS = ` 100% { background-position: 40px 0; } } @keyframes kkGlow { - 0% { box-shadow: 0 0 8px rgba(251, 146, 60, 0.4); } - 50% { box-shadow: 0 0 20px rgba(251, 146, 60, 0.7); } - 100% { box-shadow: 0 0 8px rgba(251, 146, 60, 0.4); } + 0% { box-shadow: 0 0 8px rgba(72, 187, 120, 0.4); } + 50% { box-shadow: 0 0 20px rgba(72, 187, 120, 0.7); } + 100% { box-shadow: 0 0 8px rgba(72, 187, 120, 0.4); } } ` @@ -80,6 +84,7 @@ const stepToVisibleId: Record = { interface OobSetupWizardProps { onComplete: () => void onSetupInProgress?: (inProgress: boolean) => void + onWordCountChange?: (count: 12 | 18 | 24) => void } // ── Confetti pieces ───────────────────────────────────────────────────────── @@ -94,13 +99,13 @@ const confettiPieces = Array.from({ length: 50 }, (_, i) => ({ // ── Main Wizard ───────────────────────────────────────────────────────────── -export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizardProps) { +export function OobSetupWizard({ onComplete, onSetupInProgress, onWordCountChange }: OobSetupWizardProps) { const [step, setStep] = useState('welcome') const [setupType, setSetupType] = useState<'create' | 'recover' | null>(null) const [wordCount, setWordCount] = useState<12 | 18 | 24>(12) const [deviceLabel, setDeviceLabel] = useState('') const [setupError, setSetupError] = useState(null) - const [, setSetupLoading] = useState(false) + // L1 fix: removed unused setupLoading state (value was never read) const { t } = useTranslation('setup') const STEP_DESCRIPTIONS: Record = { 'welcome': t('stepDescriptions.welcome'), @@ -118,6 +123,8 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard { id: 'init-choose', label: t('visibleSteps.setup'), number: 3 }, ] + // Read-more toggle on welcome/bootloader + const [showReadMore, setShowReadMore] = useState(false) // Advanced seed length toggle for create wallet const [showCreateAdvanced, setShowCreateAdvanced] = useState(false) @@ -131,8 +138,31 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard const [waitingForBootloaderFw, setWaitingForBootloaderFw] = useState(false) const bootloaderPollRef = useRef | null>(null) + // Custom firmware state (inline file picker in firmware step) + const fileInputRef = useRef(null) + const [customFwPhase, setCustomFwPhase] = useState<'idle' | 'analyzing' | 'confirm' | 'flashing' | 'error'>('idle') + const [customFwAnalysis, setCustomFwAnalysis] = useState(null) + const [customFwDataB64, setCustomFwDataB64] = useState('') + const [customFwFileName, setCustomFwFileName] = useState('') + const [customFwError, setCustomFwError] = useState(null) + const [customFwProgress, setCustomFwProgress] = useState(null) + // Reboot phase: after BL/FW flash, wait for device to reconnect with fresh features const [rebootPhase, setRebootPhase] = useState<'idle' | 'rebooting'>('idle') + // Tracks elapsed time during reboot for progressive user messaging + const [rebootElapsedMs, setRebootElapsedMs] = useState(0) + + // Tick reboot elapsed timer while waiting for device reconnection + useEffect(() => { + if (rebootPhase !== 'rebooting') { + setRebootElapsedMs(0) + return + } + const timer = setInterval(() => { + setRebootElapsedMs(prev => prev + 1000) + }, 1000) + return () => clearInterval(timer) + }, [rebootPhase]) // Hooks — use Electrobun RPC-based hooks const deviceStatus = useDeviceState() @@ -152,6 +182,19 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard const inBootloader = deviceStatus.bootloaderMode const isOobDevice = deviceStatus.isOob + // Bootloader skip is only safe on firmware >= 6.1.1. + // In bootloader mode we don't know the FW version — never allow skip. + const canSkipBootloader = (() => { + if (inBootloader) return false + const fv = deviceStatus.firmwareVersion + if (!fv) return false + const [maj, min, pat] = fv.split('.').map(Number) + if (maj > 6) return true + if (maj === 6 && min > 1) return true + if (maj === 6 && min === 1 && pat >= 1) return true + return false + })() + // ── Progress calculation ──────────────────────────────────────────────── const visibleId = stepToVisibleId[step] @@ -174,24 +217,23 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard const isVisibleStepCurrent = (vsId: string) => visibleId === vsId // ── Signal setupInProgress for entire wizard lifecycle ───────────────── - // Keeps wizard visible during device reboots (firmware → init-choose transition) + // M5 fix: Split into two effects — step changes signal the current value + // (no cleanup that would briefly flash false between transitions), and a + // separate unmount-only effect signals false when the wizard is removed. useEffect(() => { onSetupInProgress?.(step !== 'complete') - return () => onSetupInProgress?.(false) }, [step, onSetupInProgress]) - // ── Welcome → first real step ────────────────────────────────────────── - useEffect(() => { - if (step !== 'welcome') return - if (deviceStatus.state === 'disconnected') return // Wait for device - const timer = setTimeout(() => { - handleGetStarted() - }, 1500) - return () => clearTimeout(timer) - }, [step, deviceStatus.state, needsBootloader, needsFirmware, needsInit]) + // onSetupInProgress is setSetupInProgress (stable React setter) — safe to capture + return () => onSetupInProgress?.(false) + }, [onSetupInProgress]) - const handleGetStarted = () => { + // ── Welcome → user clicks to advance ─────────────────────────────────── + // Device state determines the target step; the button only shows once state is known. + const welcomeReady = step === 'welcome' && deviceStatus.state !== 'disconnected' + + const handleWelcomeNext = useCallback(() => { if (needsBootloader) { setStep('bootloader') } else if (needsFirmware) { @@ -201,20 +243,20 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard } else { onComplete() } - } + }, [needsBootloader, needsFirmware, needsInit, onComplete]) // ── Bootloader step ──────────────────────────────────────────────────── const handleEnterBootloaderMode = () => { + if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) // H1 fix setWaitingForBootloader(true) - // Poll device state for bootloader mode detection + // Poll device state for bootloader mode detection (does NOT auto-start update) bootloaderPollRef.current = setInterval(async () => { try { const state = await rpcRequest('getDeviceState') if (state.bootloaderMode) { setWaitingForBootloader(false) if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) - await startBootloaderUpdate() } } catch { // Device may be disconnecting/reconnecting @@ -222,7 +264,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard }, 2000) } - // Event-driven bootloader detection via device state pushes + // Event-driven bootloader detection via device state pushes (does NOT auto-start) useEffect(() => { if (!waitingForBootloader) return if (updateState === 'updating') return @@ -230,18 +272,18 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard if (deviceStatus.bootloaderMode) { setWaitingForBootloader(false) if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) - startBootloaderUpdate() } - }, [waitingForBootloader, deviceStatus.bootloaderMode, updateState, startBootloaderUpdate]) + }, [waitingForBootloader, deviceStatus.bootloaderMode, updateState]) - // Auto-start: if device is already in bootloader mode when we reach this step + // Auto-start bootloader detection polling when entering bootloader step useEffect(() => { if (step !== 'bootloader') return + if (inBootloader) return // Already detected + if (waitingForBootloader) return // Already polling if (updateState !== 'idle') return - if (rebootPhase === 'rebooting') return // Don't re-trigger during reboot wait - if (!inBootloader) return - startBootloaderUpdate() - }, [step, updateState, rebootPhase, inBootloader, startBootloaderUpdate]) + if (rebootPhase === 'rebooting') return + handleEnterBootloaderMode() + }, [step, inBootloader, waitingForBootloader, updateState, rebootPhase]) // Enter reboot phase when bootloader update completes useEffect(() => { @@ -264,7 +306,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard setRebootPhase('idle') // Device is back — route based on fresh state - if (s === 'bootloader' && needsBootloader) return // stay, auto-start will retry + if (s === 'bootloader' && needsBootloader) return // stay, user can retry if (needsFirmware) { setStep('firmware') } else if (needsInit) { @@ -283,6 +325,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard // ── Firmware step ────────────────────────────────────────────────────── const handleEnterBootloaderForFirmware = () => { + if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) // H1 fix setWaitingForBootloaderFw(true) bootloaderPollRef.current = setInterval(async () => { try { @@ -290,7 +333,6 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard if (state.bootloaderMode) { setWaitingForBootloaderFw(false) if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) - await startFirmwareUpdate(deviceStatus.latestFirmware || undefined) } } catch { // Device may be disconnecting/reconnecting @@ -298,7 +340,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard }, 2000) } - // Event-driven: detect bootloader mode for firmware step + // Event-driven: detect bootloader mode for firmware step (does NOT auto-start) useEffect(() => { if (step !== 'firmware') return if (!waitingForBootloaderFw) return @@ -307,22 +349,19 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard if (deviceStatus.bootloaderMode) { setWaitingForBootloaderFw(false) if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) - startFirmwareUpdate(deviceStatus.latestFirmware || undefined) } - }, [step, waitingForBootloaderFw, deviceStatus.bootloaderMode, updateState, startFirmwareUpdate, deviceStatus.latestFirmware]) + }, [step, waitingForBootloaderFw, deviceStatus.bootloaderMode, updateState]) - // Auto-start firmware update if already in bootloader mode, - // otherwise auto-start polling for bootloader entry + // Auto-start polling for bootloader entry (not the update itself) useEffect(() => { if (step !== 'firmware') return if (updateState !== 'idle') return - if (rebootPhase === 'rebooting') return // Don't re-trigger during reboot wait - if (inBootloader) { - startFirmwareUpdate(deviceStatus.latestFirmware || undefined) - } else if (!waitingForBootloaderFw) { + if (rebootPhase === 'rebooting') return + if (inBootloader) return // Already in BL — user will click to start + if (!waitingForBootloaderFw) { handleEnterBootloaderForFirmware() } - }, [step, updateState, rebootPhase, inBootloader]) + }, [step, updateState, rebootPhase, inBootloader, waitingForBootloaderFw]) // H4 fix: added waitingForBootloaderFw // Enter reboot phase when firmware update completes useEffect(() => { @@ -342,7 +381,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard setRebootPhase('idle') - if (s === 'bootloader') return // auto-start will retry firmware flash + if (s === 'bootloader') return // user can retry firmware flash if (needsInit) { setStep('init-choose') } else { @@ -358,6 +397,97 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard } } + // ── Custom firmware file handling ────────────────────────────────────── + + // Listen for firmware progress during custom flash + useEffect(() => { + return onRpcMessage('firmware-progress', (payload: FirmwareProgress) => { + if (customFwPhase === 'flashing') { + setCustomFwProgress(payload) + if (payload.percent >= 100) { + setCustomFwPhase('idle') + resetUpdate() + setRebootPhase('rebooting') + } + } + }) + }, [customFwPhase, resetUpdate]) + + const handleFileSelected = useCallback(async (file: File) => { + if (!file.name.endsWith('.bin')) { + setCustomFwError('Only .bin firmware files are supported') + setCustomFwPhase('error') + return + } + setCustomFwPhase('analyzing') + setCustomFwFileName(file.name) + setCustomFwError(null) + try { + const arrayBuf = await file.arrayBuffer() + // M4 fix: chunked conversion avoids O(n^2) string concatenation + const bytes = new Uint8Array(arrayBuf) + const CHUNK = 8192 + let binary = '' + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)) + } + const b64 = btoa(binary) + setCustomFwDataB64(b64) + const result = await rpcRequest('analyzeFirmware', { data: b64 }) + setCustomFwAnalysis(result) + setCustomFwPhase('confirm') + } catch (err: any) { + setCustomFwError(err?.message || 'Failed to analyze firmware') + setCustomFwPhase('error') + } + }, []) + + const handleCustomFlash = useCallback(async () => { + if (!customFwDataB64) return + setCustomFwPhase('flashing') + setCustomFwProgress({ percent: 0, message: 'Starting firmware flash...' }) + try { + await rpcRequest('flashCustomFirmware', { data: customFwDataB64 }, 0) + } catch (err: any) { + setCustomFwError(err?.message || 'Firmware flash failed') + setCustomFwPhase('error') + } + }, [customFwDataB64]) + + const handleCustomFwReset = useCallback(() => { + setCustomFwPhase('idle') + setCustomFwAnalysis(null) + setCustomFwDataB64('') + setCustomFwFileName('') + setCustomFwError(null) + setCustomFwProgress(null) + if (fileInputRef.current) fileInputRef.current.value = '' + }, []) + + // Inline drop zone handlers for firmware step + const [showCustomFw, setShowCustomFw] = useState(false) + const [fwDragOver, setFwDragOver] = useState(false) + + const handleFwDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setFwDragOver(true) + }, []) + + const handleFwDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setFwDragOver(false) + }, []) + + const handleFwDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setFwDragOver(false) + const files = e.dataTransfer?.files + if (files?.length) handleFileSelected(files[0]) + }, [handleFileSelected]) + // ── Init: Create / Recover ───────────────────────────────────────────── // No timeout for device-interactive ops — user can take as long as needed @@ -366,7 +496,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard const handleCreateWallet = async () => { setSetupType('create') setStep('init-progress') - setSetupLoading(true) + setSetupError(null) try { await rpcRequest('resetDevice', { @@ -379,7 +509,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard setSetupError(err instanceof Error ? err.message : t('initProgress.failedToCreate')) setStep('init-choose') } finally { - setSetupLoading(false) + } } @@ -389,7 +519,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard setDevLoadOpen(false) setSetupType('create') setStep('init-progress') - setSetupLoading(true) + setSetupError(null) try { await rpcRequest('loadDevice', { mnemonic: words }, DEVICE_INTERACTION_TIMEOUT) @@ -400,7 +530,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard setSetupError(err instanceof Error ? err.message : 'Failed to load device') setStep('init-choose') } finally { - setSetupLoading(false) + } } @@ -413,7 +543,8 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard const handleRecoverWallet = async () => { setSetupType('recover') setStep('init-progress') - setSetupLoading(true) + onWordCountChange?.(wordCount) + setSetupError(null) try { await rpcRequest('recoverDevice', { @@ -426,7 +557,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard setSetupError(err instanceof Error ? err.message : t('initProgress.failedToRecover')) setStep('init-choose') } finally { - setSetupLoading(false) + } } @@ -465,10 +596,13 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard } }, [step, onComplete]) - const showPrevious = !['welcome', 'complete'].includes(step) + // L7 fix: prevent navigating back to already-completed steps + const showPrevious = !['welcome', 'complete', 'init-progress'].includes(step) + // L4 fix: hide Next on firmware step for OOB devices (firmware is required) const showNext = !['bootloader', 'init-choose', 'init-progress', 'init-label', 'complete'].includes(step) && - !(step === 'firmware' && (updateState === 'updating' || updateState === 'complete')) + !(step === 'firmware' && (updateState === 'updating' || updateState === 'complete')) && + !(step === 'firmware' && isOobDevice) // ── Render ───────────────────────────────────────────────────────────── @@ -503,14 +637,20 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard > {/* ── Header ─────────────────────────────────────────────────── */} - - - {t('title')} - - - {STEP_DESCRIPTIONS[step]} - - + + + + + {t('title')} + + + {STEP_DESCRIPTIONS[step]} + + + + + + {/* ── Progress bar ───────────────────────────────────────────── */} @@ -546,7 +686,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard transition="all 0.3s" flexShrink={0} transform={current ? 'scale(1.1)' : 'scale(1)'} - boxShadow={current ? '0 0 0 3px rgba(251, 146, 60, 0.25)' : 'none'} + boxShadow={current ? '0 0 0 3px rgba(72, 187, 120, 0.25)' : 'none'} > {completed ? ( @@ -592,45 +732,103 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard {/* ═══════════════ WELCOME ═══════════════════════════════ */} {step === 'welcome' && ( - - - - {t('welcome.title')} - - - {t('subtitle')} - - - {isOobDevice - ? t('welcome.oobIntro') - : t('welcome.intro')} - - - - - - {deviceStatus.state !== 'disconnected' ? t('welcome.startingSetup') : t('welcome.detectingDevice')} - - + {isOobDevice ? ( + <> + + + + + + {t('welcome.oobTitle')} + + + {t('welcome.oobWelcome')} + + + {t('welcome.oobWizardDesc')} + + + + {/* Read More link → opens dialog */} + setShowReadMore(true)} + > + {t('welcome.readMore')} + + + ) : ( + <> + + + + {t('welcome.title')} + + + {t('subtitle')} + + + {t('welcome.intro')} + + + + )} + {welcomeReady ? ( + + ) : ( + + + + {t('welcome.detectingDevice')} + + + )} )} {/* ═══════════════ BOOTLOADER ════════════════════════════ */} {step === 'bootloader' && ( - {!inBootloader && updateState !== 'updating' && updateState !== 'error' && rebootPhase !== 'rebooting' && ( + {/* Bootloader detected — user must click to start update */} + {inBootloader && updateState === 'idle' && rebootPhase !== 'rebooting' && ( <> - + - - {t('bootloader.title')} + + {t('bootloader.bootloaderDetected')} - {t('bootloader.description')} + {t('bootloader.deviceReadyForUpdate')} @@ -639,7 +837,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard {t('bootloader.current')} - + {(deviceStatus.bootloaderVersion && !deviceStatus.bootloaderVersion.startsWith('hash:')) ? `v${deviceStatus.bootloaderVersion}` : t('bootloader.outdated')} @@ -656,81 +854,174 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard )} - - - - - - {t('bootloader.enterFirmwareUpdateMode')} - + + {canSkipBootloader && ( + + )} + + )} + + {!inBootloader && updateState !== 'updating' && updateState !== 'error' && rebootPhase !== 'rebooting' && ( + <> + + + {t('bootloader.putDeviceInBootloader')} + + + {t('bootloader.followStepsBelow')} + + + + {deviceStatus.latestBootloader && ( + + + + {t('bootloader.current')} + + {(deviceStatus.bootloaderVersion && !deviceStatus.bootloaderVersion.startsWith('hash:')) + ? `v${deviceStatus.bootloaderVersion}` + : t('bootloader.outdated')} + + + + + {t('bootloader.latest')} + + v{deviceStatus.latestBootloader} + + - - {t('bootloader.step1Unplug')} - {t('bootloader.step2Hold')} - {t('bootloader.step3Plugin')} - {t('bootloader.step4Release')} + + )} + + + + + {t('bootloader.stepsTitle')} + + + + + 1 + + {t('bootloader.step1Unplug')} + + + + 2 + + {t('bootloader.step2Hold')} + + + + 3 + + {t('bootloader.step3Plugin')} + + + + 4 + + {t('bootloader.step4Release')} + - {waitingForBootloader && ( - - - - {t('bootloader.listeningForBootloader')} + + + + + {t('bootloader.waitingForBootloader')} - )} + - {!waitingForBootloader && ( - <> - - - + {canSkipBootloader && ( + )} )} {rebootPhase === 'rebooting' && ( - - - - - - {t('firmware.deviceRebooting', { defaultValue: 'Device rebooting...' })} + + + + + + + {rebootElapsedMs < 20000 + ? t('firmware.deviceRebooting', { defaultValue: 'Device rebooting...' }) + : t('firmware.rebootTakingLong', { defaultValue: 'Reconnection is taking longer than usual...' })} + + + + {rebootElapsedMs < 20000 + ? t('firmware.rebootingMessage', { defaultValue: 'Waiting for device to reconnect after update.' }) + : t('firmware.rebootTakingLongSub', { defaultValue: 'The device may need a moment to restart.' })} - - - {t('firmware.rebootingMessage', { defaultValue: 'Waiting for device to reconnect after update.' })} - - - + + + + {rebootElapsedMs >= 30000 && ( + + + + + + {t('firmware.manualReconnectTitle', { defaultValue: 'Device not reconnecting?' })} + + + + {t('firmware.manualReconnectStep1', { defaultValue: '1. Unplug your KeepKey' })} + {t('firmware.manualReconnectStep2', { defaultValue: '2. Wait 5 seconds' })} + {t('firmware.manualReconnectStep3', { defaultValue: '3. Plug it back in' })} + + + {t('firmware.manualReconnectNote', { defaultValue: 'Setup will continue automatically when the device is detected.' })} + + + + )} + )} {updateState === 'updating' && ( - + {t('bootloader.title')} @@ -744,7 +1035,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard {t('bootloader.current')} - + v{deviceStatus.firmwareVersion || '?'} @@ -769,10 +1060,10 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard - + - - + + {t('bootloader.doNotUnplugBrick')} @@ -804,7 +1095,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard {/* ═══════════════ FIRMWARE ══════════════════════════════ */} {step === 'firmware' && ( - + {t('firmware.title')} @@ -821,7 +1112,13 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard {inBootloader ? t('firmware.firmwareLabel') : t('bootloader.current')} - {inBootloader ? t('firmware.notInstalled') : `v${deviceStatus.firmwareVersion || '?'}`} + {inBootloader + ? deviceStatus.resolvedFwVersion + ? deviceStatus.resolvedFwVersion + : deviceStatus.firmwareHash + ? `${deviceStatus.firmwareHash.slice(0, 10)}… (custom)` + : t('firmware.notInstalled') + : `v${deviceStatus.firmwareVersion || '?'}`} @@ -834,31 +1131,266 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard - {updateState === 'idle' && !inBootloader && rebootPhase !== 'rebooting' && ( + {/* In bootloader — show firmware install options (user must click) */} + {updateState === 'idle' && inBootloader && rebootPhase !== 'rebooting' && customFwPhase === 'idle' && ( <> - - - + + + {t('firmware.bootloaderReadyForFirmware')} + + + {/* Upgrade preview — show new features coming with this firmware */} + {deviceStatus.latestFirmware && ( + + )} + + + + {/* Custom firmware section — collapsible caret */} + + setShowCustomFw((prev) => !prev)} + py={1} + > + + {t('firmware.orCustomFirmware')} + + {showCustomFw ? ( + + ) : ( + + )} + + {showCustomFw && ( + fileInputRef.current?.click()} + onDragOver={handleFwDragOver} + onDragLeave={handleFwDragLeave} + onDrop={handleFwDrop} + > + + + + {t('firmware.customFirmwareHint')} + + + + + )} + { + const file = e.target.files?.[0] + if (file) handleFileSelected(file) + }} + /> + + + )} + + {/* Custom firmware analyzing */} + {customFwPhase === 'analyzing' && ( + + + + {t('firmware.analyzingFirmware')} + + {customFwFileName} + + )} + + {/* Custom firmware confirmation */} + {customFwPhase === 'confirm' && customFwAnalysis && ( + + + {t('firmware.customFirmwareReady')} + + + + + + File + {customFwFileName} + + + {t('firmware.version')} + + {customFwAnalysis.detectedVersion || '?.?.?'} + + + + {t('firmware.size')} + + {(customFwAnalysis.fileSize / 1024).toFixed(1)} KB + + + + + {customFwAnalysis.isSigned ? t('firmware.signed') : t('firmware.unsigned')} + + + + + + {customFwAnalysis.willWipeDevice && ( + + + + + {t('firmware.willWipeWarning')} + + + + )} + + {!customFwAnalysis.isSigned && !customFwAnalysis.willWipeDevice && ( + - - - {t('bootloader.enterFirmwareUpdateMode')} + + + {t('firmware.unsignedWarning')} - - {t('bootloader.step1Unplug')} - {t('bootloader.step2Hold')} - {t('bootloader.step3Plugin')} - {t('bootloader.step4Release')} + + )} + + + + + + + )} + + {/* Custom firmware flashing */} + {customFwPhase === 'flashing' && ( + + + + {customFwProgress?.message || t('firmware.uploadingFirmware')} + + + + + {t('firmware.doNotUnplug')} + + )} + + {/* Custom firmware error */} + {customFwPhase === 'error' && ( + + + {t('firmware.customFlashFailed')} + {customFwError} + + + + )} + + {/* Not in bootloader — show instructions to enter bootloader */} + {updateState === 'idle' && !inBootloader && rebootPhase !== 'rebooting' && customFwPhase === 'idle' && ( + <> + + + + + {t('bootloader.stepsTitle')} + + + 1. {t('bootloader.step1Unplug')} + 2. {t('bootloader.step2Hold')} + 3. {t('bootloader.step3Plugin')} + 4. {t('bootloader.step4Release')} - - - - {t('bootloader.listeningForBootloader')} - - + + + + + {t('bootloader.waitingForBootloader')} + + + )} @@ -867,20 +1399,20 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard - + {t('firmware.confirmOnDevice')} - + {t('firmware.lookAtDeviceAndPress')} - + {t('firmware.verifyBackupNote')} @@ -910,19 +1442,46 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard )} {rebootPhase === 'rebooting' && ( - - - - - - {t('firmware.deviceRebooting', { defaultValue: 'Device rebooting...' })} + + + + + + + {rebootElapsedMs < 20000 + ? t('firmware.deviceRebooting', { defaultValue: 'Device rebooting...' }) + : t('firmware.rebootTakingLong', { defaultValue: 'Reconnection is taking longer than usual...' })} + + + + {rebootElapsedMs < 20000 + ? t('firmware.rebootingMessage', { defaultValue: 'Waiting for device to reconnect after update.' }) + : t('firmware.rebootTakingLongSub', { defaultValue: 'The device may need a moment to restart.' })} - - - {t('firmware.rebootingMessage', { defaultValue: 'Waiting for device to reconnect after update.' })} - - - + + + + {rebootElapsedMs >= 30000 && ( + + + + + + {t('firmware.manualReconnectTitle', { defaultValue: 'Device not reconnecting?' })} + + + + {t('firmware.manualReconnectStep1', { defaultValue: '1. Unplug your KeepKey' })} + {t('firmware.manualReconnectStep2', { defaultValue: '2. Wait 5 seconds' })} + {t('firmware.manualReconnectStep3', { defaultValue: '3. Plug it back in' })} + + + {t('firmware.manualReconnectNote', { defaultValue: 'Setup will continue automatically when the device is detected.' })} + + + + )} +
)} {updateState === 'error' && ( @@ -937,7 +1496,7 @@ export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizard
)} - {updateState === 'idle' && !isOobDevice && rebootPhase !== 'rebooting' && ( + {updateState === 'idle' && !isOobDevice && !inBootloader && rebootPhase !== 'rebooting' && ( + + + + )} ) } diff --git a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx index 1791998..289d611 100644 --- a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -1,9 +1,21 @@ import { useState, useEffect, useCallback, useRef } from "react" -import { Box, Flex, Text, Spinner } from "@chakra-ui/react" +import { Box, Flex, Text, Spinner, Image } from "@chakra-ui/react" import { rpcRequest, onRpcMessage } from "../lib/rpc" import { Z } from "../lib/z-index" import type { ReportMeta } from "../../shared/types" +import keepkeyLogo from "../assets/icon.png" +import coinTrackerLogo from "../assets/logo/cointracker.png" +import zenLedgerLogo from "../assets/logo/zenledger.png" + +type ExportFormat = "pdf" | "cointracker" | "zenledger" + +const EXPORT_OPTIONS: { key: ExportFormat; label: string; sub: string; logo: string; bg: string }[] = [ + { key: "pdf", label: "KeepKey PDF", sub: "Full portfolio report", logo: keepkeyLogo, bg: "rgba(192,168,96,0.10)" }, + { key: "cointracker", label: "CoinTracker", sub: "Tax CSV export", logo: coinTrackerLogo, bg: "rgba(255,255,255,0.05)" }, + { key: "zenledger", label: "ZenLedger", sub: "Tax CSV export", logo: zenLedgerLogo, bg: "rgba(255,255,255,0.05)" }, +] + interface ReportDialogProps { onClose: () => void } @@ -66,7 +78,7 @@ export function ReportDialog({ onClose }: ReportDialogProps) { const [saving, setSaving] = useState(null) - const handleDownload = useCallback(async (id: string, format: "json" | "csv" | "pdf") => { + const handleDownload = useCallback(async (id: string, format: ExportFormat) => { try { setSaving(`${id}-${format}`) await rpcRequest<{ filePath: string }>("saveReportFile", { id, format }, 30000) @@ -115,7 +127,7 @@ export function ReportDialog({ onClose }: ReportDialogProps) { flexShrink={0} > - Portfolio Reports + Reports & Tax Export {!generating && ( - Full Detail Report — includes device info, all chain balances, cached pubkeys, - token details, BTC transaction history, and address flow analysis. + Generate a report then export as KeepKey branded PDF, CoinTracker CSV, + or ZenLedger CSV for tax filing. Reports include device info, chain balances, + BTC transaction history, and address flow analysis. Store securely and never share with untrusted parties. @@ -218,7 +231,7 @@ export function ReportDialog({ onClose }: ReportDialogProps) { Previous Reports - + {reports.map(r => ( - + {new Date(r.createdAt).toLocaleString()} {r.error && ( - {r.error} + {r.error} )} - - {r.status === "complete" && ( - <> - {(["JSON", "CSV", "PDF"] as const).map(fmt => { - const key = fmt.toLowerCase() as "json" | "csv" | "pdf" - const savingKey = `${r.id}-${key}` - const isSavingThis = saving === savingKey - return ( - !isSavingThis && handleDownload(r.id, key)} - > - {isSavingThis ? "Saving..." : fmt} - - ) - })} - - )} - {/* L8: Delete with confirmation */} + + {/* Export buttons with logos */} + {r.status === "complete" && ( + + {EXPORT_OPTIONS.map(({ key, label, sub, logo, bg }) => { + const savingKey = `${r.id}-${key}` + const isSavingThis = saving === savingKey + return ( + !isSavingThis && handleDownload(r.id, key)} + > + + {label} + + + {isSavingThis ? "Saving..." : label} + + + {sub} + + + + + ) + })} + + )} + + {/* Delete with confirmation */} + {confirmDeleteId === r.id ? ( void settingsOpen?: boolean activeTab: NavTab @@ -17,6 +19,23 @@ interface TopNavProps { watchOnly?: boolean } +/** Construction/hard-hat icon for dev firmware */ +const ConstructionIcon = () => ( + + + + + +) + +/** Shield icon for verified/signed firmware */ +const ShieldCheckIcon = ({ color }: { color: string }) => ( + + + + +) + /** Grid icon (11px) for Apps tab */ const GridIcon = () => ( @@ -60,7 +79,7 @@ export function SplashNav() { ) } -export function TopNav({ label, connected, firmwareVersion, firmwareVerified, onSettingsToggle, settingsOpen, activeTab, onTabChange, watchOnly }: TopNavProps) { +export function TopNav({ label, connected, firmwareVersion, firmwareVerified, needsFirmwareUpdate, latestFirmware, onSettingsToggle, settingsOpen, activeTab, onTabChange, watchOnly }: TopNavProps) { const { t } = useTranslation("nav") const TAB_DEFS: { id: NavTab; label: string; icon: JSX.Element }[] = [ @@ -126,15 +145,33 @@ export function TopNav({ label, connected, firmwareVersion, firmwareVerified, on ) : firmwareVersion ? ( - {firmwareVerified === false && ( - - - - + {firmwareVerified === false ? ( + <> + + + v{firmwareVersion} (dev) + + + ) : needsFirmwareUpdate ? ( + <> + + + v{firmwareVersion} + + {latestFirmware && ( + + → v{latestFirmware} + + )} + + ) : ( + <> + + + v{firmwareVersion} + + )} - - v{firmwareVersion} - ) : null} diff --git a/projects/keepkey-vault/src/mainview/hooks/useDeviceState.ts b/projects/keepkey-vault/src/mainview/hooks/useDeviceState.ts index 24a4dbe..a4b7fdb 100644 --- a/projects/keepkey-vault/src/mainview/hooks/useDeviceState.ts +++ b/projects/keepkey-vault/src/mainview/hooks/useDeviceState.ts @@ -9,7 +9,7 @@ const DEFAULT_STATE: DeviceStateInfo = { bootloaderMode: false, needsBootloaderUpdate: false, needsFirmwareUpdate: false, - needsInit: true, + needsInit: false, // L8 fix: default false prevents brief wizard flash before features arrive initialized: false, isOob: false, } diff --git a/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts b/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts index 2d4270e..aff9309 100644 --- a/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts +++ b/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react' -import { rpcRequest, onRpcMessage } from '../lib/rpc' +import { useState, useEffect, useCallback, useRef } from 'react' +import { onRpcMessage, rpcRequest } from '../lib/rpc' import type { FirmwareProgress } from '../../shared/types' type UpdateState = 'idle' | 'updating' | 'complete' | 'error' @@ -8,12 +8,16 @@ export function useFirmwareUpdate() { const [state, setState] = useState('idle') const [progress, setProgress] = useState(null) const [error, setError] = useState(null) + // Guard: only process firmware-progress events when this hook initiated a flash + const activeRef = useRef(false) // Listen for firmware-progress messages from Bun useEffect(() => { const unsubscribe = onRpcMessage('firmware-progress', (payload: FirmwareProgress) => { + if (!activeRef.current) return // C1 fix: ignore events from other flash paths setProgress(payload) if (payload.percent >= 100) { + activeRef.current = false setState('complete') } }) @@ -22,32 +26,43 @@ export function useFirmwareUpdate() { }, []) const startBootloaderUpdate = useCallback(async () => { + activeRef.current = true setState('updating') setError(null) setProgress({ percent: 0, message: 'Starting bootloader update...' }) try { await rpcRequest('startBootloaderUpdate', undefined, 0) + activeRef.current = false setState('complete') } catch (err: any) { - console.error('[firmware] Bootloader update error:', err?.message || err) - setState('complete') + activeRef.current = false + const msg = err?.message || 'Unknown error' + console.error('[firmware] Bootloader update error:', msg) + setError(msg) // C2 fix: actually set the error + setState('error') } }, []) const startFirmwareUpdate = useCallback(async (_version?: string) => { + activeRef.current = true setState('updating') setError(null) setProgress({ percent: 0, message: 'Starting firmware update...' }) try { await rpcRequest('startFirmwareUpdate', undefined, 0) + activeRef.current = false setState('complete') } catch (err: any) { - console.error('[firmware] Firmware update error:', err?.message || err) - setState('complete') + activeRef.current = false + const msg = err?.message || 'Unknown error' + console.error('[firmware] Firmware update error:', msg) + setError(msg) // C2 fix: actually set the error + setState('error') } }, []) const reset = useCallback(() => { + activeRef.current = false setState('idle') setProgress(null) setError(null) diff --git a/projects/keepkey-vault/src/mainview/i18n/LanguageSelector.tsx b/projects/keepkey-vault/src/mainview/i18n/LanguageSelector.tsx index 10fb920..a5cddf8 100644 --- a/projects/keepkey-vault/src/mainview/i18n/LanguageSelector.tsx +++ b/projects/keepkey-vault/src/mainview/i18n/LanguageSelector.tsx @@ -1,7 +1,9 @@ -import { Flex, Button } from "@chakra-ui/react" +import { Flex, Box, Text, Button } from "@chakra-ui/react" import { useTranslation } from "react-i18next" +import { FaGlobe } from "react-icons/fa" +import { useState, useRef, useEffect } from "react" -const LANGUAGES = [ +export const LANGUAGES = [ { code: "en", label: "English" }, { code: "es", label: "Español" }, { code: "fr", label: "Français" }, @@ -47,3 +49,82 @@ export function LanguageSelector() { ) } + +/** Compact globe dropdown for use in wizard headers */ +export function LanguagePicker() { + const { i18n } = useTranslation() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener("mousedown", handler) + return () => document.removeEventListener("mousedown", handler) + }, [open]) + + const current = LANGUAGES.find(l => l.code === i18n.language) || LANGUAGES[0] + + return ( + + setOpen(o => !o)} + > + + {current.label} + + {open && ( + + {LANGUAGES.map(({ code, label }) => { + const active = i18n.language === code + return ( + { i18n.changeLanguage(code); setOpen(false) }} + > + {label} + + ) + })} + + )} + + ) +} diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/de/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/de/dashboard.json index 59ea8de..fd0486c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/de/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/de/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "Kein Guthaben", "tokensCount_one": "+{{count}} Token", "tokensCount_other": "+{{count}} Token", - "watchOnlyBanner": "Nur Ansicht — Gerät verbinden für vollen Zugriff" + "watchOnlyBanner": "Nur Ansicht — Gerät verbinden für vollen Zugriff", + "welcomeTitle": "Willkommen bei KeepKey Vault", + "welcomeSubtitle": "Ihre Wallet ist bereit. So starten Sie:", + "welcomeTip1": "Tippen Sie auf eine Blockchain unten und dann auf Empfangen, um Ihre Einzahlungsadresse zu erhalten", + "welcomeTip2": "Senden Sie Krypto an Ihre Adresse — Ihr Guthaben erscheint hier automatisch", + "welcomeTip3": "Fügen Sie benutzerdefinierte EVM-Chains mit der +-Karte hinzu, um jedes Netzwerk zu verfolgen", + "balancesStale": "Guthaben können veraltet sein", + "lastUpdated": "Zuletzt aktualisiert {{time}}", + "lastUpdatedNever": "Nie geladen", + "refreshPrompt": "Drücken Sie, um Guthaben zu aktualisieren", + "tokenWarningTitle": "Token-Guthaben nicht verfügbar", + "tokenWarningDesc": "Keine Token-Daten zurückgegeben. Ihre nativen Guthaben werden angezeigt, aber ERC-20 / Token-Guthaben können fehlen.", + "refreshing": "Aktualisierung...", + "reports": "Berichte", + "pioneerOfflineTitle": "Guthabenserver offline", + "pioneerOfflineDesc": "Verbindung zu {{url}} nicht möglich. Guthaben sind möglicherweise nicht verfügbar.", + "changeServer": "Server wechseln", + "getSupport": "Support erhalten", + "retry": "Erneut versuchen", + "timeJustNow": "gerade eben", + "timeMinutesAgo": "vor {{count}}m", + "timeHoursAgo": "vor {{count}}h", + "timeDaysAgo": "vor {{count}}t" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json index ea63828..0dfd0ac 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/de/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "Willkommen bei KeepKey", + "oobTitle": "Neues Gerät erkannt", + "oobWelcome": "Willkommen bei KeepKey", + "oobWizardDesc": "Dieser Assistent führt Sie durch Bootloader- und Firmware-Updates, um Ihr Gerät einsatzbereit zu machen.", "oobIntro": "Lassen Sie uns Ihre Firmware aktualisieren und Ihre Wallet einrichten.", "intro": "Lassen Sie uns Ihre Hardware-Wallet einrichten.", + "getStarted": "Loslegen", "startingSetup": "Einrichtung wird gestartet...", - "detectingDevice": "Gerätestatus wird erkannt..." + "detectingDevice": "Gerätestatus wird erkannt...", + "readMore": "Wie funktioniert das?", + "readMoreTitle": "Wie funktioniert das?", + "readMoreBootloaderTitle": "Was ist der Bootloader?", + "readMoreBootloaderBody": "Der Bootloader ist ein kleines Programm, das zuerst ausgeführt wird, wenn Ihr KeepKey eingeschaltet wird. Stellen Sie ihn sich als Sicherheitswächter vor — er prüft, ob die Hauptsoftware auf Ihrem Gerät authentisch ist und nicht manipuliert wurde, bevor er sie ausführen lässt.", + "readMoreFirmwareTitle": "Was ist Firmware?", + "readMoreFirmwareBody": "Firmware ist die Hauptsoftware, die Ihr KeepKey zur Verwaltung Ihrer Kryptowährungen verwendet. Sie übernimmt alles von der Adressgenerierung bis zur Transaktionssignierung. Ein Update bringt Ihnen die neuesten Funktionen und Sicherheitspatches.", + "readMoreWhyTitle": "Warum beides aktualisieren?", + "readMoreWhyBody": "Durch die Aktualisierung des Bootloaders wird Ihr Gerät so gesperrt, dass es nur offizielle KeepKey-Software akzeptiert — selbst wenn jemand versuchen würde, etwas Schädliches zu installieren, würde Ihr Gerät es ablehnen. Zusammen halten Bootloader und Firmware Ihre Guthaben sicher.", + "gotIt": "Verstanden" }, "bootloader": { "title": "Bootloader-Update", "description": "Ihr Bootloader muss aus Sicherheits- und Kompatibilitätsgründen aktualisiert werden.", + "putDeviceInBootloader": "Versetzen Sie Ihr Gerät in den Bootloader-Modus", + "followStepsBelow": "Befolgen Sie die untenstehenden Schritte, dann erkennen wir Ihr Gerät automatisch.", + "stepsTitle": "So gelangen Sie in den Bootloader-Modus:", + "waitingForBootloader": "Warte auf Ihr Gerät... schließen Sie es an, während Sie den Knopf gedrückt halten", "current": "Aktuell", "latest": "Neueste", "outdated": "Veraltet", @@ -33,13 +50,19 @@ "updateFailed": "Update fehlgeschlagen", "tryAgain": "Erneut versuchen", "enterFirmwareUpdateMode": "Firmware-Update-Modus starten", - "step1Unplug": "1. Trennen Sie Ihren KeepKey", - "step2Hold": "2. Halten Sie den Knopf am Gerät gedrückt", - "step3Plugin": "3. Stecken Sie bei gedrücktem Knopf das USB-Kabel ein", - "step4Release": "4. Lassen Sie los, wenn der Bootloader-Bildschirm erscheint", + "step1Unplug": "Trennen Sie Ihren KeepKey", + "step2Hold": "Halten Sie den Knopf am Gerät gedrückt", + "step3Plugin": "Stecken Sie bei gedrücktem Knopf das USB-Kabel ein", + "step4Release": "Lassen Sie los, wenn der Bootloader-Bildschirm erscheint", "listeningForBootloader": "Warte auf Gerät im Bootloader-Modus...", "readyDetectBootloader": "Bereit — Bootloader erkennen", - "skipBootloaderUpdate": "Bootloader-Update überspringen" + "skipBootloaderUpdate": "Bootloader-Update überspringen", + "verifyBackupHint": "Auf dem KeepKey werden Sie aufgefordert, das Backup zu überprüfen. Das machen wir nach dem Update — halten Sie den Knopf gedrückt, um dies vorerst zu überspringen.", + "followDirectionsOnDevice": "Folgen Sie den Anweisungen auf dem Gerät", + "deviceWillGuide": "Ihr KeepKey führt Sie durch den Update-Vorgang.", + "bootloaderDetected": "Bootloader-Modus erkannt", + "deviceReadyForUpdate": "Ihr KeepKey befindet sich im Bootloader-Modus und ist bereit für das Update.", + "updateBootloaderTo": "Bootloader auf v{{version}} aktualisieren" }, "firmware": { "title": "Firmware-Update", @@ -58,7 +81,36 @@ "firmwareVerified": "Firmware als offizielle Version verifiziert", "firmwareHashNotFound": "Firmware-Hash nicht im Manifest gefunden", "updateFirmwareTo": "Firmware auf v{{version}} aktualisieren", - "skipUpdate": "Update überspringen" + "skipUpdate": "Update überspringen", + "confirmOnDevice": "Aktion auf dem Gerät bestätigen!", + "lookAtDeviceAndPress": "Schauen Sie auf den Bildschirm Ihres KeepKey und drücken Sie den Knopf zur Bestätigung.", + "verifyBackupNote": "Wenn Ihr Gerät noch nicht eingerichtet ist, können Sie den Bildschirm 'Backup überprüfen' bedenkenlos ignorieren.", + "uploadingFirmware": "Firmware wird hochgeladen... Trennen Sie Ihr Gerät nicht", + "estimatedTimeRemaining": "Geschätzte verbleibende Zeit: {{seconds}}s", + "deviceWillRestart": "Ihr Gerät wird nach Abschluss neu gestartet.", + "skipWarning": "Sie können mit älterer Firmware fortfahren. Einige Funktionen funktionieren möglicherweise nicht wie erwartet.", + "bootloaderReadyForFirmware": "Ihr KeepKey befindet sich im Bootloader-Modus und ist bereit für die Firmware.", + "installLatestFirmware": "Offizielle Firmware v{{version}} installieren", + "orCustomFirmware": "Oder benutzerdefinierte Firmware installieren", + "customFirmwareHint": "Wählen Sie eine .bin-Firmware-Datei von Ihrem Computer aus oder ziehen Sie sie hierher.", + "browseFiles": "Dateien durchsuchen", + "analyzingFirmware": "Firmware wird analysiert...", + "customFirmwareReady": "Benutzerdefinierte Firmware bereit zum Flashen", + "flashFirmware": "Firmware flashen", + "signed": "SIGNIERT", + "unsigned": "UNSIGNIERT", + "version": "Version", + "size": "Größe", + "willWipeWarning": "Das Flashen unsignierter Firmware auf ein signiertes Gerät LÖSCHT ALLE DATEN. Stellen Sie sicher, dass Ihr Seed gesichert ist.", + "unsignedWarning": "Dies ist unsignierte Entwickler-Firmware. Sie kann unerwartetes Verhalten verursachen.", + "customFlashFailed": "Flashen der benutzerdefinierten Firmware fehlgeschlagen", + "rebootTakingLong": "Die Wiederverbindung dauert länger als üblich...", + "rebootTakingLongSub": "Das Gerät benötigt möglicherweise einen Moment zum Neustart.", + "manualReconnectTitle": "Gerät verbindet sich nicht wieder?", + "manualReconnectStep1": "1. Trennen Sie Ihren KeepKey", + "manualReconnectStep2": "2. Warten Sie 5 Sekunden", + "manualReconnectStep3": "3. Schließen Sie ihn wieder an", + "manualReconnectNote": "Die Einrichtung wird automatisch fortgesetzt, sobald das Gerät erkannt wird." }, "initChoose": { "title": "Wallet einrichten", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json index edb5685..e2022cb 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json @@ -16,8 +16,18 @@ "balancesStale": "Balances may be outdated", "lastUpdated": "Last updated {{time}}", "lastUpdatedNever": "Never loaded", - "refreshPrompt": "Press Refresh to load latest balances", + "refreshPrompt": "Press to update balances", "tokenWarningTitle": "Token balances unavailable", "tokenWarningDesc": "No token data was returned. Your native balances are shown but ERC-20 / token balances may be missing.", - "refreshing": "Refreshing..." + "refreshing": "Refreshing...", + "reports": "Reports", + "pioneerOfflineTitle": "Balance server offline", + "pioneerOfflineDesc": "Unable to connect to {{url}}. Balances may be unavailable.", + "changeServer": "Change Server", + "getSupport": "Get Support", + "retry": "Retry", + "timeJustNow": "just now", + "timeMinutesAgo": "{{count}}m ago", + "timeHoursAgo": "{{count}}h ago", + "timeDaysAgo": "{{count}}d ago" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json index 2f01219..c312bfd 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "Welcome to KeepKey", + "oobTitle": "New Device Detected", + "oobWelcome": "Welcome to KeepKey", + "oobWizardDesc": "This wizard will walk you through bootloader and firmware updates to get your device ready.", "oobIntro": "Let's update your firmware and set up your wallet.", "intro": "Let's get your hardware wallet set up.", + "getStarted": "Get Started", "startingSetup": "Starting setup...", - "detectingDevice": "Detecting device status..." + "detectingDevice": "Detecting device...", + "readMore": "How does this work?", + "readMoreTitle": "How Does This Work?", + "readMoreBootloaderTitle": "What is the bootloader?", + "readMoreBootloaderBody": "The bootloader is a small program that runs first when your KeepKey powers on. Think of it as a security guard — it checks that the main software on your device is authentic and hasn't been tampered with before allowing it to run.", + "readMoreFirmwareTitle": "What is firmware?", + "readMoreFirmwareBody": "Firmware is the main software your KeepKey uses to manage your crypto. It handles everything from generating addresses to signing transactions. Updating it gives you the latest features and security patches.", + "readMoreWhyTitle": "Why update both?", + "readMoreWhyBody": "Updating the bootloader locks your device to only accept official KeepKey software — so even if someone tried to install something malicious, your device would reject it. Together, the bootloader and firmware keep your funds safe.", + "gotIt": "Got It" }, "bootloader": { "title": "Bootloader Update", "description": "Your bootloader needs to be updated for security and compatibility.", + "putDeviceInBootloader": "Put Your Device in Bootloader Mode", + "followStepsBelow": "Follow the steps below, then we'll detect your device automatically.", + "stepsTitle": "How to enter bootloader mode:", + "waitingForBootloader": "Waiting for your device... plug it in while holding the button", "current": "Current", "latest": "Latest", "outdated": "Outdated", @@ -33,16 +50,19 @@ "updateFailed": "Update Failed", "tryAgain": "Try Again", "enterFirmwareUpdateMode": "Enter Firmware Update Mode", - "step1Unplug": "1. Unplug your KeepKey", - "step2Hold": "2. Hold down the button on the device", - "step3Plugin": "3. While holding, plug in the USB cable", - "step4Release": "4. Release when the bootloader screen appears", + "step1Unplug": "Unplug your KeepKey", + "step2Hold": "Hold down the button on the device", + "step3Plugin": "While holding, plug in the USB cable", + "step4Release": "Release when the bootloader screen appears", "listeningForBootloader": "Listening for device in bootloader mode...", "readyDetectBootloader": "I'm Ready — Detect Bootloader", "skipBootloaderUpdate": "Skip Bootloader Update", "verifyBackupHint": "On the KeepKey, it will ask you to verify backup. We will do this after updating — hold the button to skip this for now.", "followDirectionsOnDevice": "Follow directions on device", - "deviceWillGuide": "Your KeepKey will guide you through the update process." + "deviceWillGuide": "Your KeepKey will guide you through the update process.", + "bootloaderDetected": "Bootloader Mode Detected", + "deviceReadyForUpdate": "Your KeepKey is in bootloader mode and ready for update.", + "updateBootloaderTo": "Update Bootloader to v{{version}}" }, "firmware": { "title": "Firmware Update", @@ -68,7 +88,29 @@ "uploadingFirmware": "Uploading firmware... Do not disconnect your device", "estimatedTimeRemaining": "Estimated time remaining: {{seconds}}s", "deviceWillRestart": "Your device will restart when complete.", - "skipWarning": "You can continue with older firmware. Some features may not work as expected." + "skipWarning": "You can continue with older firmware. Some features may not work as expected.", + "bootloaderReadyForFirmware": "Your KeepKey is in bootloader mode and ready for firmware.", + "installLatestFirmware": "Install Official Firmware v{{version}}", + "orCustomFirmware": "Or install custom firmware", + "customFirmwareHint": "Select a .bin firmware file from your computer, or drag and drop anywhere.", + "browseFiles": "Browse Files", + "analyzingFirmware": "Analyzing firmware...", + "customFirmwareReady": "Custom firmware ready to flash", + "flashFirmware": "Flash Firmware", + "signed": "SIGNED", + "unsigned": "UNSIGNED", + "version": "Version", + "size": "Size", + "willWipeWarning": "Flashing unsigned firmware onto a signed device will WIPE ALL DATA. Make sure your seed is backed up.", + "unsignedWarning": "This is unsigned developer firmware. It may cause unexpected behavior.", + "customFlashFailed": "Custom firmware flash failed", + "rebootTakingLong": "Reconnection is taking longer than usual...", + "rebootTakingLongSub": "The device may need a moment to restart.", + "manualReconnectTitle": "Device not reconnecting?", + "manualReconnectStep1": "1. Unplug your KeepKey", + "manualReconnectStep2": "2. Wait 5 seconds", + "manualReconnectStep3": "3. Plug it back in", + "manualReconnectNote": "Setup will continue automatically when the device is detected." }, "initChoose": { "title": "Set Up Your Wallet", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/es/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/es/dashboard.json index 0fbca96..5458b07 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/es/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/es/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "Sin saldo", "tokensCount_one": "+{{count}} token", "tokensCount_other": "+{{count}} tokens", - "watchOnlyBanner": "Solo lectura — Conecta el dispositivo para acceso completo" + "watchOnlyBanner": "Solo lectura — Conecta el dispositivo para acceso completo", + "welcomeTitle": "Bienvenido a KeepKey Vault", + "welcomeSubtitle": "Tu billetera está lista. Aquí te explicamos cómo empezar:", + "welcomeTip1": "Toca cualquier cadena abajo y luego presiona Recibir para obtener tu dirección de depósito", + "welcomeTip2": "Envía cripto a tu dirección — tu saldo aparecerá aquí automáticamente", + "welcomeTip3": "Agrega cadenas EVM personalizadas con la tarjeta + para rastrear cualquier red", + "balancesStale": "Los saldos pueden estar desactualizados", + "lastUpdated": "Última actualización {{time}}", + "lastUpdatedNever": "Nunca cargado", + "refreshPrompt": "Presiona para actualizar saldos", + "tokenWarningTitle": "Saldos de tokens no disponibles", + "tokenWarningDesc": "No se devolvieron datos de tokens. Se muestran tus saldos nativos pero los saldos ERC-20 / tokens pueden faltar.", + "refreshing": "Actualizando...", + "reports": "Informes", + "pioneerOfflineTitle": "Servidor de saldos sin conexión", + "pioneerOfflineDesc": "No se puede conectar a {{url}}. Los saldos pueden no estar disponibles.", + "changeServer": "Cambiar servidor", + "getSupport": "Obtener soporte", + "retry": "Reintentar", + "timeJustNow": "ahora mismo", + "timeMinutesAgo": "hace {{count}}m", + "timeHoursAgo": "hace {{count}}h", + "timeDaysAgo": "hace {{count}}d" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json index d9743d2..fe0ae08 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/es/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "Bienvenido a KeepKey", + "oobTitle": "Nuevo dispositivo detectado", + "oobWelcome": "Bienvenido a KeepKey", + "oobWizardDesc": "Este asistente te guiará a través de las actualizaciones de bootloader y firmware para preparar tu dispositivo.", "oobIntro": "Actualicemos tu firmware y configuremos tu billetera.", "intro": "Vamos a configurar tu billetera de hardware.", + "getStarted": "Comenzar", "startingSetup": "Iniciando configuración...", - "detectingDevice": "Detectando estado del dispositivo..." + "detectingDevice": "Detectando estado del dispositivo...", + "readMore": "¿Cómo funciona esto?", + "readMoreTitle": "¿Cómo funciona esto?", + "readMoreBootloaderTitle": "¿Qué es el bootloader?", + "readMoreBootloaderBody": "El bootloader es un pequeño programa que se ejecuta primero cuando tu KeepKey se enciende. Piensa en él como un guardia de seguridad — verifica que el software principal de tu dispositivo sea auténtico y no haya sido alterado antes de permitir que se ejecute.", + "readMoreFirmwareTitle": "¿Qué es el firmware?", + "readMoreFirmwareBody": "El firmware es el software principal que tu KeepKey usa para gestionar tus criptomonedas. Maneja todo, desde generar direcciones hasta firmar transacciones. Actualizarlo te da las últimas funciones y parches de seguridad.", + "readMoreWhyTitle": "¿Por qué actualizar ambos?", + "readMoreWhyBody": "Actualizar el bootloader bloquea tu dispositivo para que solo acepte software oficial de KeepKey — así, incluso si alguien intentara instalar algo malicioso, tu dispositivo lo rechazaría. Juntos, el bootloader y el firmware mantienen tus fondos seguros.", + "gotIt": "Entendido" }, "bootloader": { "title": "Actualización del Bootloader", "description": "Tu bootloader necesita ser actualizado por seguridad y compatibilidad.", + "putDeviceInBootloader": "Pon tu dispositivo en modo Bootloader", + "followStepsBelow": "Sigue los pasos a continuación, luego detectaremos tu dispositivo automáticamente.", + "stepsTitle": "Cómo entrar en modo bootloader:", + "waitingForBootloader": "Esperando tu dispositivo... conéctalo mientras mantienes presionado el botón", "current": "Actual", "latest": "Último", "outdated": "Desactualizado", @@ -33,13 +50,19 @@ "updateFailed": "Actualización fallida", "tryAgain": "Intentar de nuevo", "enterFirmwareUpdateMode": "Entrar en modo de actualización de firmware", - "step1Unplug": "1. Desconecta tu KeepKey", - "step2Hold": "2. Mantén presionado el botón del dispositivo", - "step3Plugin": "3. Mientras mantienes presionado, conecta el cable USB", - "step4Release": "4. Suelta cuando aparezca la pantalla del bootloader", + "step1Unplug": "Desconecta tu KeepKey", + "step2Hold": "Mantén presionado el botón del dispositivo", + "step3Plugin": "Mientras mantienes presionado, conecta el cable USB", + "step4Release": "Suelta cuando aparezca la pantalla del bootloader", "listeningForBootloader": "Escuchando dispositivo en modo bootloader...", "readyDetectBootloader": "Estoy listo — Detectar Bootloader", - "skipBootloaderUpdate": "Omitir actualización del bootloader" + "skipBootloaderUpdate": "Omitir actualización del bootloader", + "verifyBackupHint": "En el KeepKey, te pedirá verificar la copia de seguridad. Lo haremos después de actualizar — mantén presionado el botón para omitir esto por ahora.", + "followDirectionsOnDevice": "Sigue las instrucciones en el dispositivo", + "deviceWillGuide": "Tu KeepKey te guiará a través del proceso de actualización.", + "bootloaderDetected": "Modo Bootloader detectado", + "deviceReadyForUpdate": "Tu KeepKey está en modo bootloader y listo para la actualización.", + "updateBootloaderTo": "Actualizar Bootloader a v{{version}}" }, "firmware": { "title": "Actualización de Firmware", @@ -58,7 +81,36 @@ "firmwareVerified": "Firmware verificado como versión oficial", "firmwareHashNotFound": "Hash del firmware no encontrado en el manifiesto", "updateFirmwareTo": "Actualizar Firmware a v{{version}}", - "skipUpdate": "Omitir actualización" + "skipUpdate": "Omitir actualización", + "confirmOnDevice": "¡Confirma la acción en el dispositivo!", + "lookAtDeviceAndPress": "Mira la pantalla de tu KeepKey y presiona el botón para confirmar.", + "verifyBackupNote": "Si tu dispositivo no está configurado, puedes ignorar con seguridad cualquier pantalla de \"verificar copia de seguridad\".", + "uploadingFirmware": "Subiendo firmware... No desconectes tu dispositivo", + "estimatedTimeRemaining": "Tiempo estimado restante: {{seconds}}s", + "deviceWillRestart": "Tu dispositivo se reiniciará cuando termine.", + "skipWarning": "Puedes continuar con firmware más antiguo. Algunas funciones pueden no funcionar como se espera.", + "bootloaderReadyForFirmware": "Tu KeepKey está en modo bootloader y listo para el firmware.", + "installLatestFirmware": "Instalar Firmware Oficial v{{version}}", + "orCustomFirmware": "O instalar firmware personalizado", + "customFirmwareHint": "Selecciona un archivo de firmware .bin de tu computadora, o arrástralo y suéltalo en cualquier lugar.", + "browseFiles": "Explorar archivos", + "analyzingFirmware": "Analizando firmware...", + "customFirmwareReady": "Firmware personalizado listo para flashear", + "flashFirmware": "Flashear Firmware", + "signed": "FIRMADO", + "unsigned": "SIN FIRMAR", + "version": "Versión", + "size": "Tamaño", + "willWipeWarning": "Flashear firmware sin firmar en un dispositivo firmado BORRARÁ TODOS LOS DATOS. Asegúrate de tener tu semilla respaldada.", + "unsignedWarning": "Este es firmware de desarrollador sin firmar. Puede causar comportamiento inesperado.", + "customFlashFailed": "Error al flashear firmware personalizado", + "rebootTakingLong": "La reconexión está tardando más de lo habitual...", + "rebootTakingLongSub": "El dispositivo puede necesitar un momento para reiniciarse.", + "manualReconnectTitle": "¿El dispositivo no se reconecta?", + "manualReconnectStep1": "1. Desconecta tu KeepKey", + "manualReconnectStep2": "2. Espera 5 segundos", + "manualReconnectStep3": "3. Conéctalo de nuevo", + "manualReconnectNote": "La configuración continuará automáticamente cuando se detecte el dispositivo." }, "initChoose": { "title": "Configura tu billetera", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/fr/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/fr/dashboard.json index 39bfb29..68ad3aa 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/fr/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/fr/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "Aucun solde", "tokensCount_one": "+{{count}} jeton", "tokensCount_other": "+{{count}} jetons", - "watchOnlyBanner": "Lecture seule — Connectez l'appareil pour un accès complet" + "watchOnlyBanner": "Lecture seule — Connectez l'appareil pour un accès complet", + "welcomeTitle": "Bienvenue sur KeepKey Vault", + "welcomeSubtitle": "Votre portefeuille est prêt. Voici comment démarrer :", + "welcomeTip1": "Appuyez sur une chaîne ci-dessous, puis sur Recevoir pour obtenir votre adresse de dépôt", + "welcomeTip2": "Envoyez des cryptos à votre adresse — votre solde apparaîtra ici automatiquement", + "welcomeTip3": "Ajoutez des chaînes EVM personnalisées avec la carte + pour suivre n'importe quel réseau", + "balancesStale": "Les soldes peuvent être obsolètes", + "lastUpdated": "Dernière mise à jour {{time}}", + "lastUpdatedNever": "Jamais chargé", + "refreshPrompt": "Appuyez pour mettre à jour les soldes", + "tokenWarningTitle": "Soldes des jetons indisponibles", + "tokenWarningDesc": "Aucune donnée de jeton n'a été retournée. Vos soldes natifs sont affichés mais les soldes ERC-20 / jetons peuvent manquer.", + "refreshing": "Rafraîchissement...", + "reports": "Rapports", + "pioneerOfflineTitle": "Serveur de soldes hors ligne", + "pioneerOfflineDesc": "Impossible de se connecter à {{url}}. Les soldes peuvent être indisponibles.", + "changeServer": "Changer de serveur", + "getSupport": "Obtenir de l'aide", + "retry": "Réessayer", + "timeJustNow": "à l'instant", + "timeMinutesAgo": "il y a {{count}}m", + "timeHoursAgo": "il y a {{count}}h", + "timeDaysAgo": "il y a {{count}}j" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json index 77f33ef..288bffd 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/fr/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "Bienvenue sur KeepKey", + "oobTitle": "Nouvel appareil détecté", + "oobWelcome": "Bienvenue sur KeepKey", + "oobWizardDesc": "Cet assistant vous guidera à travers les mises à jour du bootloader et du firmware pour préparer votre appareil.", "oobIntro": "Mettons à jour votre firmware et configurons votre portefeuille.", "intro": "Configurons votre portefeuille matériel.", + "getStarted": "Commencer", "startingSetup": "Démarrage de la configuration...", - "detectingDevice": "Détection de l'état de l'appareil..." + "detectingDevice": "Détection de l'état de l'appareil...", + "readMore": "Comment ça marche ?", + "readMoreTitle": "Comment ça marche ?", + "readMoreBootloaderTitle": "Qu'est-ce que le bootloader ?", + "readMoreBootloaderBody": "Le bootloader est un petit programme qui s'exécute en premier lorsque votre KeepKey s'allume. Considérez-le comme un agent de sécurité — il vérifie que le logiciel principal de votre appareil est authentique et n'a pas été altéré avant de l'autoriser à s'exécuter.", + "readMoreFirmwareTitle": "Qu'est-ce que le firmware ?", + "readMoreFirmwareBody": "Le firmware est le logiciel principal que votre KeepKey utilise pour gérer vos cryptomonnaies. Il gère tout, de la génération d'adresses à la signature de transactions. Le mettre à jour vous donne les dernières fonctionnalités et correctifs de sécurité.", + "readMoreWhyTitle": "Pourquoi mettre à jour les deux ?", + "readMoreWhyBody": "La mise à jour du bootloader verrouille votre appareil pour n'accepter que le logiciel officiel KeepKey — ainsi, même si quelqu'un tentait d'installer quelque chose de malveillant, votre appareil le rejetterait. Ensemble, le bootloader et le firmware protègent vos fonds.", + "gotIt": "Compris" }, "bootloader": { "title": "Mise à jour du Bootloader", "description": "Votre bootloader doit être mis à jour pour la sécurité et la compatibilité.", + "putDeviceInBootloader": "Mettez votre appareil en mode Bootloader", + "followStepsBelow": "Suivez les étapes ci-dessous, nous détecterons automatiquement votre appareil.", + "stepsTitle": "Comment entrer en mode bootloader :", + "waitingForBootloader": "En attente de votre appareil... branchez-le en maintenant le bouton enfoncé", "current": "Actuel", "latest": "Dernier", "outdated": "Obsolète", @@ -33,13 +50,19 @@ "updateFailed": "Mise à jour échouée", "tryAgain": "Réessayer", "enterFirmwareUpdateMode": "Entrer en mode de mise à jour du firmware", - "step1Unplug": "1. Débranchez votre KeepKey", - "step2Hold": "2. Maintenez le bouton de l'appareil enfoncé", - "step3Plugin": "3. Tout en maintenant, branchez le câble USB", - "step4Release": "4. Relâchez lorsque l'écran du bootloader apparaît", + "step1Unplug": "Débranchez votre KeepKey", + "step2Hold": "Maintenez le bouton de l'appareil enfoncé", + "step3Plugin": "Tout en maintenant, branchez le câble USB", + "step4Release": "Relâchez lorsque l'écran du bootloader apparaît", "listeningForBootloader": "Écoute de l'appareil en mode bootloader...", "readyDetectBootloader": "Je suis prêt — Détecter le Bootloader", - "skipBootloaderUpdate": "Passer la mise à jour du bootloader" + "skipBootloaderUpdate": "Passer la mise à jour du bootloader", + "verifyBackupHint": "Sur le KeepKey, il vous sera demandé de vérifier la sauvegarde. Nous le ferons après la mise à jour — maintenez le bouton enfoncé pour passer cette étape pour l'instant.", + "followDirectionsOnDevice": "Suivez les instructions sur l'appareil", + "deviceWillGuide": "Votre KeepKey vous guidera tout au long du processus de mise à jour.", + "bootloaderDetected": "Mode Bootloader détecté", + "deviceReadyForUpdate": "Votre KeepKey est en mode bootloader et prêt pour la mise à jour.", + "updateBootloaderTo": "Mettre à jour le Bootloader vers v{{version}}" }, "firmware": { "title": "Mise à jour du Firmware", @@ -58,7 +81,36 @@ "firmwareVerified": "Firmware vérifié comme version officielle", "firmwareHashNotFound": "Hash du firmware non trouvé dans le manifeste", "updateFirmwareTo": "Mettre à jour le Firmware vers v{{version}}", - "skipUpdate": "Passer la mise à jour" + "skipUpdate": "Passer la mise à jour", + "confirmOnDevice": "Confirmez l'action sur l'appareil !", + "lookAtDeviceAndPress": "Regardez l'écran de votre KeepKey et appuyez sur le bouton pour confirmer.", + "verifyBackupNote": "Si votre appareil n'est pas configuré, vous pouvez ignorer en toute sécurité tout écran « vérifier la sauvegarde ».", + "uploadingFirmware": "Téléversement du firmware... Ne déconnectez pas votre appareil", + "estimatedTimeRemaining": "Temps restant estimé : {{seconds}}s", + "deviceWillRestart": "Votre appareil redémarrera une fois terminé.", + "skipWarning": "Vous pouvez continuer avec l'ancien firmware. Certaines fonctionnalités pourraient ne pas fonctionner comme prévu.", + "bootloaderReadyForFirmware": "Votre KeepKey est en mode bootloader et prêt pour le firmware.", + "installLatestFirmware": "Installer le Firmware officiel v{{version}}", + "orCustomFirmware": "Ou installer un firmware personnalisé", + "customFirmwareHint": "Sélectionnez un fichier firmware .bin depuis votre ordinateur, ou glissez-déposez n'importe où.", + "browseFiles": "Parcourir les fichiers", + "analyzingFirmware": "Analyse du firmware...", + "customFirmwareReady": "Firmware personnalisé prêt à être flashé", + "flashFirmware": "Flasher le Firmware", + "signed": "SIGNÉ", + "unsigned": "NON SIGNÉ", + "version": "Version", + "size": "Taille", + "willWipeWarning": "Flasher un firmware non signé sur un appareil signé EFFACERA TOUTES LES DONNÉES. Assurez-vous que votre phrase de récupération est sauvegardée.", + "unsignedWarning": "Ceci est un firmware développeur non signé. Il peut provoquer un comportement inattendu.", + "customFlashFailed": "Échec du flash du firmware personnalisé", + "rebootTakingLong": "La reconnexion prend plus de temps que prévu...", + "rebootTakingLongSub": "L'appareil peut avoir besoin d'un moment pour redémarrer.", + "manualReconnectTitle": "L'appareil ne se reconnecte pas ?", + "manualReconnectStep1": "1. Débranchez votre KeepKey", + "manualReconnectStep2": "2. Attendez 5 secondes", + "manualReconnectStep3": "3. Rebranchez-le", + "manualReconnectNote": "La configuration continuera automatiquement lorsque l'appareil sera détecté." }, "initChoose": { "title": "Configurez votre portefeuille", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/it/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/it/dashboard.json index 3c697b2..08c3feb 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/it/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/it/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "Nessun saldo", "tokensCount_one": "+{{count}} token", "tokensCount_other": "+{{count}} token", - "watchOnlyBanner": "Solo visualizzazione — Collega il dispositivo per l'accesso completo" + "watchOnlyBanner": "Solo visualizzazione — Collega il dispositivo per l'accesso completo", + "welcomeTitle": "Benvenuto in KeepKey Vault", + "welcomeSubtitle": "Il tuo portafoglio è pronto. Ecco come iniziare:", + "welcomeTip1": "Tocca una catena qui sotto, poi premi Ricevi per ottenere il tuo indirizzo di deposito", + "welcomeTip2": "Invia crypto al tuo indirizzo — il tuo saldo apparirà qui automaticamente", + "welcomeTip3": "Aggiungi catene EVM personalizzate con la scheda + per monitorare qualsiasi rete", + "balancesStale": "I saldi potrebbero essere obsoleti", + "lastUpdated": "Ultimo aggiornamento {{time}}", + "lastUpdatedNever": "Mai caricato", + "refreshPrompt": "Premi per aggiornare i saldi", + "tokenWarningTitle": "Saldi token non disponibili", + "tokenWarningDesc": "Nessun dato token restituito. Vengono mostrati i saldi nativi ma i saldi ERC-20 / token potrebbero mancare.", + "refreshing": "Aggiornamento...", + "reports": "Rapporti", + "pioneerOfflineTitle": "Server saldi offline", + "pioneerOfflineDesc": "Impossibile connettersi a {{url}}. I saldi potrebbero non essere disponibili.", + "changeServer": "Cambia server", + "getSupport": "Ottieni supporto", + "retry": "Riprova", + "timeJustNow": "proprio ora", + "timeMinutesAgo": "{{count}}m fa", + "timeHoursAgo": "{{count}}h fa", + "timeDaysAgo": "{{count}}g fa" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json index 7e62169..f924d28 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/it/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "Benvenuto su KeepKey", + "oobTitle": "Nuovo dispositivo rilevato", + "oobWelcome": "Benvenuto in KeepKey", + "oobWizardDesc": "Questa procedura guidata ti accompagnerà attraverso gli aggiornamenti del bootloader e del firmware per preparare il tuo dispositivo.", "oobIntro": "Aggiorniamo il firmware e configuriamo il tuo portafoglio.", "intro": "Configuriamo il tuo portafoglio hardware.", + "getStarted": "Inizia", "startingSetup": "Avvio configurazione...", - "detectingDevice": "Rilevamento stato dispositivo..." + "detectingDevice": "Rilevamento stato dispositivo...", + "readMore": "Come funziona?", + "readMoreTitle": "Come funziona?", + "readMoreBootloaderTitle": "Cos'è il bootloader?", + "readMoreBootloaderBody": "Il bootloader è un piccolo programma che si avvia per primo quando il tuo KeepKey si accende. Pensalo come una guardia di sicurezza — verifica che il software principale sul tuo dispositivo sia autentico e non sia stato manomesso prima di permetterne l'esecuzione.", + "readMoreFirmwareTitle": "Cos'è il firmware?", + "readMoreFirmwareBody": "Il firmware è il software principale che il tuo KeepKey usa per gestire le tue criptovalute. Si occupa di tutto, dalla generazione degli indirizzi alla firma delle transazioni. Aggiornarlo ti offre le ultime funzionalità e le patch di sicurezza più recenti.", + "readMoreWhyTitle": "Perché aggiornare entrambi?", + "readMoreWhyBody": "Aggiornare il bootloader blocca il tuo dispositivo affinché accetti solo software ufficiale KeepKey — quindi anche se qualcuno tentasse di installare qualcosa di malevolo, il tuo dispositivo lo rifiuterebbe. Insieme, il bootloader e il firmware mantengono i tuoi fondi al sicuro.", + "gotIt": "Capito" }, "bootloader": { "title": "Aggiornamento Bootloader", "description": "Il bootloader deve essere aggiornato per sicurezza e compatibilità.", + "putDeviceInBootloader": "Metti il tuo dispositivo in modalità Bootloader", + "followStepsBelow": "Segui i passaggi qui sotto, poi rileveremo il tuo dispositivo automaticamente.", + "stepsTitle": "Come entrare in modalità bootloader:", + "waitingForBootloader": "In attesa del dispositivo... collegalo tenendo premuto il pulsante", "current": "Attuale", "latest": "Ultimo", "outdated": "Obsoleto", @@ -33,13 +50,19 @@ "updateFailed": "Aggiornamento fallito", "tryAgain": "Riprova", "enterFirmwareUpdateMode": "Entra in modalità aggiornamento firmware", - "step1Unplug": "1. Scollega il tuo KeepKey", - "step2Hold": "2. Tieni premuto il pulsante del dispositivo", - "step3Plugin": "3. Tenendo premuto, collega il cavo USB", - "step4Release": "4. Rilascia quando appare la schermata del bootloader", + "step1Unplug": "Scollega il tuo KeepKey", + "step2Hold": "Tieni premuto il pulsante del dispositivo", + "step3Plugin": "Tenendo premuto, collega il cavo USB", + "step4Release": "Rilascia quando appare la schermata del bootloader", "listeningForBootloader": "In ascolto del dispositivo in modalità bootloader...", "readyDetectBootloader": "Pronto — Rileva Bootloader", - "skipBootloaderUpdate": "Salta aggiornamento bootloader" + "skipBootloaderUpdate": "Salta aggiornamento bootloader", + "verifyBackupHint": "Sul KeepKey, ti verrà chiesto di verificare il backup. Lo faremo dopo l'aggiornamento — tieni premuto il pulsante per saltare questo passaggio per ora.", + "followDirectionsOnDevice": "Segui le istruzioni sul dispositivo", + "deviceWillGuide": "Il tuo KeepKey ti guiderà attraverso il processo di aggiornamento.", + "bootloaderDetected": "Modalità Bootloader rilevata", + "deviceReadyForUpdate": "Il tuo KeepKey è in modalità bootloader e pronto per l'aggiornamento.", + "updateBootloaderTo": "Aggiorna Bootloader a v{{version}}" }, "firmware": { "title": "Aggiornamento Firmware", @@ -58,7 +81,36 @@ "firmwareVerified": "Firmware verificato come versione ufficiale", "firmwareHashNotFound": "Hash del firmware non trovato nel manifesto", "updateFirmwareTo": "Aggiorna Firmware a v{{version}}", - "skipUpdate": "Salta aggiornamento" + "skipUpdate": "Salta aggiornamento", + "confirmOnDevice": "Conferma l'azione sul dispositivo!", + "lookAtDeviceAndPress": "Guarda lo schermo del tuo KeepKey e premi il pulsante per confermare.", + "verifyBackupNote": "Se il tuo dispositivo non è configurato, puoi ignorare tranquillamente qualsiasi schermata \"verifica backup\".", + "uploadingFirmware": "Caricamento firmware... Non scollegare il dispositivo", + "estimatedTimeRemaining": "Tempo stimato rimanente: {{seconds}}s", + "deviceWillRestart": "Il dispositivo si riavvierà al completamento.", + "skipWarning": "Puoi continuare con il firmware precedente. Alcune funzionalità potrebbero non funzionare correttamente.", + "bootloaderReadyForFirmware": "Il tuo KeepKey è in modalità bootloader e pronto per il firmware.", + "installLatestFirmware": "Installa Firmware Ufficiale v{{version}}", + "orCustomFirmware": "Oppure installa firmware personalizzato", + "customFirmwareHint": "Seleziona un file firmware .bin dal tuo computer, oppure trascinalo qui.", + "browseFiles": "Sfoglia file", + "analyzingFirmware": "Analisi firmware...", + "customFirmwareReady": "Firmware personalizzato pronto per il flash", + "flashFirmware": "Installa Firmware", + "signed": "FIRMATO", + "unsigned": "NON FIRMATO", + "version": "Versione", + "size": "Dimensione", + "willWipeWarning": "Installare firmware non firmato su un dispositivo firmato CANCELLERÀ TUTTI I DATI. Assicurati che il tuo seed sia stato salvato.", + "unsignedWarning": "Questo è firmware non firmato per sviluppatori. Potrebbe causare comportamenti imprevisti.", + "customFlashFailed": "Installazione firmware personalizzato fallita", + "rebootTakingLong": "La riconnessione sta impiegando più tempo del previsto...", + "rebootTakingLongSub": "Il dispositivo potrebbe aver bisogno di un momento per riavviarsi.", + "manualReconnectTitle": "Il dispositivo non si riconnette?", + "manualReconnectStep1": "1. Scollega il tuo KeepKey", + "manualReconnectStep2": "2. Attendi 5 secondi", + "manualReconnectStep3": "3. Ricollegalo", + "manualReconnectNote": "La configurazione continuerà automaticamente quando il dispositivo verrà rilevato." }, "initChoose": { "title": "Configura il tuo portafoglio", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ja/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/ja/dashboard.json index bebf70c..e8994bf 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ja/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ja/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "残高なし", "tokensCount_one": "+{{count}} トークン", "tokensCount_other": "+{{count}} トークン", - "watchOnlyBanner": "閲覧専用 — フルアクセスにはデバイスを接続してください" + "watchOnlyBanner": "閲覧専用 — フルアクセスにはデバイスを接続してください", + "welcomeTitle": "KeepKey Vault へようこそ", + "welcomeSubtitle": "ウォレットの準備ができました。始め方はこちら:", + "welcomeTip1": "下のチェーンをタップして「受取」を押し、入金アドレスを取得します", + "welcomeTip2": "アドレスに暗号通貨を送信 — 残高はここに自動的に表示されます", + "welcomeTip3": "+ カードでカスタム EVM チェーンを追加して、任意のネットワークを追跡します", + "balancesStale": "残高が古い可能性があります", + "lastUpdated": "最終更新 {{time}}", + "lastUpdatedNever": "未読み込み", + "refreshPrompt": "押して残高を更新", + "tokenWarningTitle": "トークン残高が利用できません", + "tokenWarningDesc": "トークンデータが返されませんでした。ネイティブ残高は表示されていますが、ERC-20 / トークン残高が欠落している可能性があります。", + "refreshing": "更新中...", + "reports": "レポート", + "pioneerOfflineTitle": "残高サーバーがオフラインです", + "pioneerOfflineDesc": "{{url}} に接続できません。残高が利用できない場合があります。", + "changeServer": "サーバーを変更", + "getSupport": "サポートを受ける", + "retry": "再試行", + "timeJustNow": "たった今", + "timeMinutesAgo": "{{count}}分前", + "timeHoursAgo": "{{count}}時間前", + "timeDaysAgo": "{{count}}日前" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json index 570f5c9..1f31fd0 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ja/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "KeepKeyへようこそ", + "oobTitle": "新しいデバイスが検出されました", + "oobWelcome": "KeepKeyへようこそ", + "oobWizardDesc": "このウィザードでは、ブートローダーとファームウェアの更新を行い、デバイスを使用可能な状態にします。", "oobIntro": "ファームウェアを更新してウォレットを設定しましょう。", "intro": "ハードウェアウォレットを設定しましょう。", + "getStarted": "始める", "startingSetup": "セットアップを開始中...", - "detectingDevice": "デバイスの状態を検出中..." + "detectingDevice": "デバイスの状態を検出中...", + "readMore": "どのように機能しますか?", + "readMoreTitle": "どのように機能しますか?", + "readMoreBootloaderTitle": "ブートローダーとは?", + "readMoreBootloaderBody": "ブートローダーは、KeepKeyの電源を入れたときに最初に実行される小さなプログラムです。セキュリティガードのようなもので、デバイス上のメインソフトウェアが本物であり、改ざんされていないことを確認してから実行を許可します。", + "readMoreFirmwareTitle": "ファームウェアとは?", + "readMoreFirmwareBody": "ファームウェアは、KeepKeyが暗号資産を管理するために使用するメインソフトウェアです。アドレスの生成からトランザクションの署名まで、すべてを処理します。更新することで、最新の機能とセキュリティパッチが適用されます。", + "readMoreWhyTitle": "なぜ両方を更新するのですか?", + "readMoreWhyBody": "ブートローダーを更新すると、デバイスは公式のKeepKeyソフトウェアのみを受け入れるようになります。たとえ誰かが悪意のあるソフトウェアをインストールしようとしても、デバイスはそれを拒否します。ブートローダーとファームウェアが一緒になって、あなたの資金を安全に保ちます。", + "gotIt": "了解しました" }, "bootloader": { "title": "ブートローダーの更新", "description": "セキュリティと互換性のためにブートローダーを更新する必要があります。", + "putDeviceInBootloader": "デバイスをブートローダーモードにしてください", + "followStepsBelow": "以下の手順に従ってください。デバイスは自動的に検出されます。", + "stepsTitle": "ブートローダーモードに入る方法:", + "waitingForBootloader": "デバイスを待機中...ボタンを押しながら接続してください", "current": "現在", "latest": "最新", "outdated": "古い", @@ -33,13 +50,19 @@ "updateFailed": "更新に失敗しました", "tryAgain": "再試行", "enterFirmwareUpdateMode": "ファームウェア更新モードに入る", - "step1Unplug": "1. KeepKeyを抜いてください", - "step2Hold": "2. デバイスのボタンを押し続けてください", - "step3Plugin": "3. ボタンを押したまま、USBケーブルを接続してください", - "step4Release": "4. ブートローダー画面が表示されたら離してください", + "step1Unplug": "KeepKeyを抜いてください", + "step2Hold": "デバイスのボタンを押し続けてください", + "step3Plugin": "ボタンを押したまま、USBケーブルを接続してください", + "step4Release": "ブートローダー画面が表示されたら離してください", "listeningForBootloader": "ブートローダーモードのデバイスを待機中...", "readyDetectBootloader": "準備完了 — ブートローダーを検出", - "skipBootloaderUpdate": "ブートローダー更新をスキップ" + "skipBootloaderUpdate": "ブートローダー更新をスキップ", + "verifyBackupHint": "KeepKeyでバックアップの確認を求められます。更新後に行いますので、今はボタンを長押ししてスキップしてください。", + "followDirectionsOnDevice": "デバイスの指示に従ってください", + "deviceWillGuide": "KeepKeyが更新プロセスをガイドします。", + "bootloaderDetected": "ブートローダーモードを検出しました", + "deviceReadyForUpdate": "KeepKeyはブートローダーモードで、更新の準備ができています。", + "updateBootloaderTo": "ブートローダーをv{{version}}に更新" }, "firmware": { "title": "ファームウェアの更新", @@ -58,7 +81,36 @@ "firmwareVerified": "ファームウェアは公式リリースとして検証されました", "firmwareHashNotFound": "マニフェストにファームウェアハッシュが見つかりません", "updateFirmwareTo": "ファームウェアをv{{version}}に更新", - "skipUpdate": "更新をスキップ" + "skipUpdate": "更新をスキップ", + "confirmOnDevice": "デバイスで操作を確認してください!", + "lookAtDeviceAndPress": "KeepKeyの画面を見て、ボタンを押して確認してください。", + "verifyBackupNote": "デバイスが未設定の場合、「バックアップを確認」画面は無視しても安全です。", + "uploadingFirmware": "ファームウェアをアップロード中...デバイスを切断しないでください", + "estimatedTimeRemaining": "推定残り時間:{{seconds}}秒", + "deviceWillRestart": "完了するとデバイスが再起動します。", + "skipWarning": "古いファームウェアのまま続行できます。一部の機能が正常に動作しない場合があります。", + "bootloaderReadyForFirmware": "KeepKeyはブートローダーモードで、ファームウェアの準備ができています。", + "installLatestFirmware": "公式ファームウェアv{{version}}をインストール", + "orCustomFirmware": "またはカスタムファームウェアをインストール", + "customFirmwareHint": "コンピュータから.binファームウェアファイルを選択するか、ここにドラッグ&ドロップしてください。", + "browseFiles": "ファイルを参照", + "analyzingFirmware": "ファームウェアを分析中...", + "customFirmwareReady": "カスタムファームウェアの書き込み準備完了", + "flashFirmware": "ファームウェアを書き込む", + "signed": "署名済み", + "unsigned": "未署名", + "version": "バージョン", + "size": "サイズ", + "willWipeWarning": "署名済みデバイスに未署名ファームウェアを書き込むと、すべてのデータが消去されます。シードがバックアップされていることを確認してください。", + "unsignedWarning": "これは未署名の開発者向けファームウェアです。予期しない動作を引き起こす可能性があります。", + "customFlashFailed": "カスタムファームウェアの書き込みに失敗しました", + "rebootTakingLong": "再接続に通常より時間がかかっています...", + "rebootTakingLongSub": "デバイスの再起動に少し時間がかかる場合があります。", + "manualReconnectTitle": "デバイスが再接続しませんか?", + "manualReconnectStep1": "1. KeepKeyを抜いてください", + "manualReconnectStep2": "2. 5秒待ってください", + "manualReconnectStep3": "3. もう一度接続してください", + "manualReconnectNote": "デバイスが検出されると、セットアップは自動的に続行されます。" }, "initChoose": { "title": "ウォレットをセットアップ", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ko/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/ko/dashboard.json index 6bbf3cb..d013c19 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ko/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ko/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "잔액 없음", "tokensCount_one": "+{{count}}개 토큰", "tokensCount_other": "+{{count}}개 토큰", - "watchOnlyBanner": "읽기 전용 — 전체 액세스를 위해 기기를 연결하세요" + "watchOnlyBanner": "읽기 전용 — 전체 액세스를 위해 기기를 연결하세요", + "welcomeTitle": "KeepKey Vault에 오신 것을 환영합니다", + "welcomeSubtitle": "지갑이 준비되었습니다. 시작하는 방법은 다음과 같습니다:", + "welcomeTip1": "아래 체인을 탭한 다음 수신을 눌러 입금 주소를 받으세요", + "welcomeTip2": "주소로 암호화폐를 보내세요 — 잔액이 여기에 자동으로 표시됩니다", + "welcomeTip3": "+ 카드로 커스텀 EVM 체인을 추가하여 모든 네트워크를 추적하세요", + "balancesStale": "잔액이 오래되었을 수 있습니다", + "lastUpdated": "마지막 업데이트 {{time}}", + "lastUpdatedNever": "로드된 적 없음", + "refreshPrompt": "눌러서 잔액 업데이트", + "tokenWarningTitle": "토큰 잔액을 사용할 수 없습니다", + "tokenWarningDesc": "토큰 데이터가 반환되지 않았습니다. 네이티브 잔액은 표시되지만 ERC-20 / 토큰 잔액이 누락될 수 있습니다.", + "refreshing": "새로고침 중...", + "reports": "보고서", + "pioneerOfflineTitle": "잔액 서버 오프라인", + "pioneerOfflineDesc": "{{url}}에 연결할 수 없습니다. 잔액을 사용할 수 없을 수 있습니다.", + "changeServer": "서버 변경", + "getSupport": "지원 받기", + "retry": "재시도", + "timeJustNow": "방금", + "timeMinutesAgo": "{{count}}분 전", + "timeHoursAgo": "{{count}}시간 전", + "timeDaysAgo": "{{count}}일 전" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json index 391a90a..15d4e6c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ko/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "KeepKey에 오신 것을 환영합니다", + "oobTitle": "새 기기가 감지되었습니다", + "oobWelcome": "KeepKey에 오신 것을 환영합니다", + "oobWizardDesc": "이 마법사가 부트로더 및 펌웨어 업데이트를 안내하여 기기를 준비합니다.", "oobIntro": "펌웨어를 업데이트하고 지갑을 설정해 봅시다.", "intro": "하드웨어 지갑을 설정해 봅시다.", + "getStarted": "시작하기", "startingSetup": "설정 시작 중...", - "detectingDevice": "기기 상태 감지 중..." + "detectingDevice": "기기 상태 감지 중...", + "readMore": "어떻게 작동하나요?", + "readMoreTitle": "어떻게 작동하나요?", + "readMoreBootloaderTitle": "부트로더란 무엇인가요?", + "readMoreBootloaderBody": "부트로더는 KeepKey 전원이 켜질 때 가장 먼저 실행되는 작은 프로그램입니다. 보안 요원이라고 생각하면 됩니다 — 기기의 메인 소프트웨어가 정품이고 변조되지 않았는지 확인한 후에만 실행을 허용합니다.", + "readMoreFirmwareTitle": "펌웨어란 무엇인가요?", + "readMoreFirmwareBody": "펌웨어는 KeepKey가 암호화폐를 관리하는 데 사용하는 메인 소프트웨어입니다. 주소 생성부터 거래 서명까지 모든 것을 처리합니다. 업데이트하면 최신 기능과 보안 패치를 받을 수 있습니다.", + "readMoreWhyTitle": "왜 둘 다 업데이트해야 하나요?", + "readMoreWhyBody": "부트로더를 업데이트하면 기기가 공식 KeepKey 소프트웨어만 허용하도록 잠깁니다 — 누군가 악성 소프트웨어를 설치하려 해도 기기가 거부합니다. 부트로더와 펌웨어가 함께 자금을 안전하게 보호합니다.", + "gotIt": "이해했습니다" }, "bootloader": { "title": "부트로더 업데이트", "description": "보안 및 호환성을 위해 부트로더를 업데이트해야 합니다.", + "putDeviceInBootloader": "기기를 부트로더 모드로 전환하세요", + "followStepsBelow": "아래 단계를 따르면 기기가 자동으로 감지됩니다.", + "stepsTitle": "부트로더 모드 진입 방법:", + "waitingForBootloader": "기기를 기다리는 중... 버튼을 누른 채로 연결하세요", "current": "현재", "latest": "최신", "outdated": "구버전", @@ -33,13 +50,19 @@ "updateFailed": "업데이트 실패", "tryAgain": "다시 시도", "enterFirmwareUpdateMode": "펌웨어 업데이트 모드 진입", - "step1Unplug": "1. KeepKey를 분리하세요", - "step2Hold": "2. 기기의 버튼을 길게 누르세요", - "step3Plugin": "3. 버튼을 누른 채로 USB 케이블을 연결하세요", - "step4Release": "4. 부트로더 화면이 나타나면 놓으세요", + "step1Unplug": "KeepKey를 분리하세요", + "step2Hold": "기기의 버튼을 길게 누르세요", + "step3Plugin": "버튼을 누른 채로 USB 케이블을 연결하세요", + "step4Release": "부트로더 화면이 나타나면 놓으세요", "listeningForBootloader": "부트로더 모드의 기기를 대기 중...", "readyDetectBootloader": "준비 완료 — 부트로더 감지", - "skipBootloaderUpdate": "부트로더 업데이트 건너뛰기" + "skipBootloaderUpdate": "부트로더 업데이트 건너뛰기", + "verifyBackupHint": "KeepKey에서 백업 확인을 요청할 것입니다. 업데이트 후에 진행할 것이므로 — 지금은 버튼을 길게 눌러 건너뛰세요.", + "followDirectionsOnDevice": "기기의 안내를 따르세요", + "deviceWillGuide": "KeepKey가 업데이트 과정을 안내합니다.", + "bootloaderDetected": "부트로더 모드 감지됨", + "deviceReadyForUpdate": "KeepKey가 부트로더 모드에 있으며 업데이트 준비가 되었습니다.", + "updateBootloaderTo": "부트로더를 v{{version}}(으)로 업데이트" }, "firmware": { "title": "펌웨어 업데이트", @@ -58,7 +81,36 @@ "firmwareVerified": "펌웨어가 공식 릴리스로 확인되었습니다", "firmwareHashNotFound": "매니페스트에서 펌웨어 해시를 찾을 수 없습니다", "updateFirmwareTo": "펌웨어를 v{{version}}(으)로 업데이트", - "skipUpdate": "업데이트 건너뛰기" + "skipUpdate": "업데이트 건너뛰기", + "confirmOnDevice": "기기에서 작업을 확인하세요!", + "lookAtDeviceAndPress": "KeepKey 화면을 보고 버튼을 눌러 확인하세요.", + "verifyBackupNote": "기기가 설정되지 않은 경우 \"백업 확인\" 화면은 무시해도 됩니다.", + "uploadingFirmware": "펌웨어 업로드 중... 기기를 분리하지 마세요", + "estimatedTimeRemaining": "예상 남은 시간: {{seconds}}초", + "deviceWillRestart": "완료되면 기기가 재시작됩니다.", + "skipWarning": "이전 펌웨어로 계속할 수 있습니다. 일부 기능이 예상대로 작동하지 않을 수 있습니다.", + "bootloaderReadyForFirmware": "KeepKey가 부트로더 모드에 있으며 펌웨어 설치 준비가 되었습니다.", + "installLatestFirmware": "공식 펌웨어 v{{version}} 설치", + "orCustomFirmware": "또는 커스텀 펌웨어 설치", + "customFirmwareHint": "컴퓨터에서 .bin 펌웨어 파일을 선택하거나 아무 곳에나 끌어다 놓으세요.", + "browseFiles": "파일 찾아보기", + "analyzingFirmware": "펌웨어 분석 중...", + "customFirmwareReady": "커스텀 펌웨어 플래시 준비 완료", + "flashFirmware": "펌웨어 플래시", + "signed": "서명됨", + "unsigned": "서명되지 않음", + "version": "버전", + "size": "크기", + "willWipeWarning": "서명된 기기에 서명되지 않은 펌웨어를 플래시하면 모든 데이터가 삭제됩니다. 시드가 백업되어 있는지 확인하세요.", + "unsignedWarning": "이것은 서명되지 않은 개발자 펌웨어입니다. 예기치 않은 동작이 발생할 수 있습니다.", + "customFlashFailed": "커스텀 펌웨어 플래시 실패", + "rebootTakingLong": "재연결이 평소보다 오래 걸리고 있습니다...", + "rebootTakingLongSub": "기기가 재시작하는 데 시간이 필요할 수 있습니다.", + "manualReconnectTitle": "기기가 다시 연결되지 않나요?", + "manualReconnectStep1": "1. KeepKey를 분리하세요", + "manualReconnectStep2": "2. 5초간 기다리세요", + "manualReconnectStep3": "3. 다시 연결하세요", + "manualReconnectNote": "기기가 감지되면 설정이 자동으로 계속됩니다." }, "initChoose": { "title": "지갑 설정", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pt/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/pt/dashboard.json index 8162225..18a83bb 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pt/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pt/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "Sem saldo", "tokensCount_one": "+{{count}} token", "tokensCount_other": "+{{count}} tokens", - "watchOnlyBanner": "Somente leitura — Conecte o dispositivo para acesso completo" + "watchOnlyBanner": "Somente leitura — Conecte o dispositivo para acesso completo", + "welcomeTitle": "Bem-vindo ao KeepKey Vault", + "welcomeSubtitle": "Sua carteira está pronta. Veja como começar:", + "welcomeTip1": "Toque em qualquer rede abaixo e depois em Receber para obter seu endereço de depósito", + "welcomeTip2": "Envie cripto para seu endereço — seu saldo aparecerá aqui automaticamente", + "welcomeTip3": "Adicione redes EVM personalizadas com o cartão + para rastrear qualquer rede", + "balancesStale": "Os saldos podem estar desatualizados", + "lastUpdated": "Última atualização {{time}}", + "lastUpdatedNever": "Nunca carregado", + "refreshPrompt": "Pressione para atualizar saldos", + "tokenWarningTitle": "Saldos de tokens indisponíveis", + "tokenWarningDesc": "Nenhum dado de token foi retornado. Seus saldos nativos são exibidos, mas saldos ERC-20 / tokens podem estar ausentes.", + "refreshing": "Atualizando...", + "reports": "Relatórios", + "pioneerOfflineTitle": "Servidor de saldos offline", + "pioneerOfflineDesc": "Não foi possível conectar a {{url}}. Os saldos podem estar indisponíveis.", + "changeServer": "Alterar servidor", + "getSupport": "Obter suporte", + "retry": "Tentar novamente", + "timeJustNow": "agora mesmo", + "timeMinutesAgo": "{{count}}min atrás", + "timeHoursAgo": "{{count}}h atrás", + "timeDaysAgo": "{{count}}d atrás" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json index bfceab3..f7b3ead 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pt/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "Bem-vindo ao KeepKey", + "oobTitle": "Novo dispositivo detectado", + "oobWelcome": "Bem-vindo ao KeepKey", + "oobWizardDesc": "Este assistente irá guiá-lo pelas atualizações do bootloader e firmware para deixar seu dispositivo pronto.", "oobIntro": "Vamos atualizar seu firmware e configurar sua carteira.", "intro": "Vamos configurar sua carteira de hardware.", + "getStarted": "Começar", "startingSetup": "Iniciando configuração...", - "detectingDevice": "Detectando estado do dispositivo..." + "detectingDevice": "Detectando estado do dispositivo...", + "readMore": "Como funciona?", + "readMoreTitle": "Como funciona?", + "readMoreBootloaderTitle": "O que é o bootloader?", + "readMoreBootloaderBody": "O bootloader é um pequeno programa que roda primeiro quando seu KeepKey liga. Pense nele como um guarda de segurança — ele verifica se o software principal do seu dispositivo é autêntico e não foi adulterado antes de permitir sua execução.", + "readMoreFirmwareTitle": "O que é firmware?", + "readMoreFirmwareBody": "O firmware é o software principal que seu KeepKey usa para gerenciar suas criptomoedas. Ele cuida de tudo, desde gerar endereços até assinar transações. Atualizá-lo traz os recursos e correções de segurança mais recentes.", + "readMoreWhyTitle": "Por que atualizar ambos?", + "readMoreWhyBody": "Atualizar o bootloader bloqueia seu dispositivo para aceitar apenas software oficial do KeepKey — então, mesmo que alguém tentasse instalar algo malicioso, seu dispositivo o rejeitaria. Juntos, o bootloader e o firmware mantêm seus fundos seguros.", + "gotIt": "Entendi" }, "bootloader": { "title": "Atualização do Bootloader", "description": "Seu bootloader precisa ser atualizado por segurança e compatibilidade.", + "putDeviceInBootloader": "Coloque seu dispositivo no modo Bootloader", + "followStepsBelow": "Siga os passos abaixo e detectaremos seu dispositivo automaticamente.", + "stepsTitle": "Como entrar no modo bootloader:", + "waitingForBootloader": "Aguardando seu dispositivo... conecte-o enquanto segura o botão", "current": "Atual", "latest": "Mais recente", "outdated": "Desatualizado", @@ -33,13 +50,19 @@ "updateFailed": "Atualização falhou", "tryAgain": "Tentar novamente", "enterFirmwareUpdateMode": "Entrar no modo de atualização de firmware", - "step1Unplug": "1. Desconecte seu KeepKey", - "step2Hold": "2. Segure o botão do dispositivo", - "step3Plugin": "3. Enquanto segura, conecte o cabo USB", - "step4Release": "4. Solte quando a tela do bootloader aparecer", + "step1Unplug": "Desconecte seu KeepKey", + "step2Hold": "Segure o botão do dispositivo", + "step3Plugin": "Enquanto segura, conecte o cabo USB", + "step4Release": "Solte quando a tela do bootloader aparecer", "listeningForBootloader": "Aguardando dispositivo no modo bootloader...", "readyDetectBootloader": "Estou pronto — Detectar Bootloader", - "skipBootloaderUpdate": "Pular atualização do bootloader" + "skipBootloaderUpdate": "Pular atualização do bootloader", + "verifyBackupHint": "No KeepKey, será solicitado que você verifique o backup. Faremos isso após a atualização — segure o botão para pular por enquanto.", + "followDirectionsOnDevice": "Siga as instruções no dispositivo", + "deviceWillGuide": "Seu KeepKey irá guiá-lo pelo processo de atualização.", + "bootloaderDetected": "Modo Bootloader detectado", + "deviceReadyForUpdate": "Seu KeepKey está no modo bootloader e pronto para atualização.", + "updateBootloaderTo": "Atualizar Bootloader para v{{version}}" }, "firmware": { "title": "Atualização de Firmware", @@ -58,7 +81,36 @@ "firmwareVerified": "Firmware verificado como versão oficial", "firmwareHashNotFound": "Hash do firmware não encontrado no manifesto", "updateFirmwareTo": "Atualizar Firmware para v{{version}}", - "skipUpdate": "Pular atualização" + "skipUpdate": "Pular atualização", + "confirmOnDevice": "Confirme a ação no dispositivo!", + "lookAtDeviceAndPress": "Olhe para a tela do seu KeepKey e pressione o botão para confirmar.", + "verifyBackupNote": "Se seu dispositivo não estiver configurado, você pode ignorar com segurança qualquer tela de \"verificar backup\".", + "uploadingFirmware": "Enviando firmware... Não desconecte seu dispositivo", + "estimatedTimeRemaining": "Tempo estimado restante: {{seconds}}s", + "deviceWillRestart": "Seu dispositivo reiniciará quando concluído.", + "skipWarning": "Você pode continuar com firmware mais antigo. Alguns recursos podem não funcionar como esperado.", + "bootloaderReadyForFirmware": "Seu KeepKey está no modo bootloader e pronto para o firmware.", + "installLatestFirmware": "Instalar Firmware Oficial v{{version}}", + "orCustomFirmware": "Ou instalar firmware personalizado", + "customFirmwareHint": "Selecione um arquivo de firmware .bin do seu computador ou arraste e solte em qualquer lugar.", + "browseFiles": "Procurar Arquivos", + "analyzingFirmware": "Analisando firmware...", + "customFirmwareReady": "Firmware personalizado pronto para instalação", + "flashFirmware": "Gravar Firmware", + "signed": "ASSINADO", + "unsigned": "NÃO ASSINADO", + "version": "Versão", + "size": "Tamanho", + "willWipeWarning": "Gravar firmware não assinado em um dispositivo assinado IRÁ APAGAR TODOS OS DADOS. Certifique-se de que sua semente está salva.", + "unsignedWarning": "Este é firmware de desenvolvedor não assinado. Pode causar comportamento inesperado.", + "customFlashFailed": "Falha ao gravar firmware personalizado", + "rebootTakingLong": "A reconexão está demorando mais que o normal...", + "rebootTakingLongSub": "O dispositivo pode precisar de um momento para reiniciar.", + "manualReconnectTitle": "Dispositivo não reconecta?", + "manualReconnectStep1": "1. Desconecte seu KeepKey", + "manualReconnectStep2": "2. Aguarde 5 segundos", + "manualReconnectStep3": "3. Conecte novamente", + "manualReconnectNote": "A configuração continuará automaticamente quando o dispositivo for detectado." }, "initChoose": { "title": "Configure sua carteira", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ru/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/ru/dashboard.json index c5c75f7..4e0912c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ru/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ru/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "Нет баланса", "tokensCount_one": "+{{count}} токен", "tokensCount_other": "+{{count}} токенов", - "watchOnlyBanner": "Только просмотр — Подключите устройство для полного доступа" + "watchOnlyBanner": "Только просмотр — Подключите устройство для полного доступа", + "welcomeTitle": "Добро пожаловать в KeepKey Vault", + "welcomeSubtitle": "Ваш кошелёк готов. Вот как начать:", + "welcomeTip1": "Нажмите на любую сеть ниже, затем нажмите Получить, чтобы получить ваш адрес для пополнения", + "welcomeTip2": "Отправьте криптовалюту на ваш адрес — баланс отобразится здесь автоматически", + "welcomeTip3": "Добавляйте пользовательские EVM-сети с помощью карточки +, чтобы отслеживать любую сеть", + "balancesStale": "Балансы могут быть устаревшими", + "lastUpdated": "Последнее обновление {{time}}", + "lastUpdatedNever": "Никогда не загружалось", + "refreshPrompt": "Нажмите для обновления балансов", + "tokenWarningTitle": "Балансы токенов недоступны", + "tokenWarningDesc": "Данные о токенах не были получены. Отображаются ваши нативные балансы, но балансы ERC-20 / токенов могут отсутствовать.", + "refreshing": "Обновление...", + "reports": "Отчёты", + "pioneerOfflineTitle": "Сервер балансов офлайн", + "pioneerOfflineDesc": "Не удалось подключиться к {{url}}. Балансы могут быть недоступны.", + "changeServer": "Сменить сервер", + "getSupport": "Получить поддержку", + "retry": "Повторить", + "timeJustNow": "только что", + "timeMinutesAgo": "{{count}}м назад", + "timeHoursAgo": "{{count}}ч назад", + "timeDaysAgo": "{{count}}д назад" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json index 781dc7f..f45586e 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ru/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "Добро пожаловать в KeepKey", + "oobTitle": "Обнаружено новое устройство", + "oobWelcome": "Добро пожаловать в KeepKey", + "oobWizardDesc": "Этот мастер поможет вам обновить загрузчик и прошивку, чтобы подготовить устройство к работе.", "oobIntro": "Давайте обновим прошивку и настроим ваш кошелёк.", "intro": "Давайте настроим ваш аппаратный кошелёк.", + "getStarted": "Начать", "startingSetup": "Начало настройки...", - "detectingDevice": "Определение состояния устройства..." + "detectingDevice": "Определение состояния устройства...", + "readMore": "Как это работает?", + "readMoreTitle": "Как это работает?", + "readMoreBootloaderTitle": "Что такое загрузчик?", + "readMoreBootloaderBody": "Загрузчик — это небольшая программа, которая запускается первой при включении KeepKey. Считайте его охранником — он проверяет, что основное программное обеспечение на вашем устройстве подлинное и не было изменено, прежде чем разрешить его запуск.", + "readMoreFirmwareTitle": "Что такое прошивка?", + "readMoreFirmwareBody": "Прошивка — это основное программное обеспечение, которое KeepKey использует для управления вашей криптовалютой. Она отвечает за всё: от генерации адресов до подписания транзакций. Обновление даёт вам новейшие функции и исправления безопасности.", + "readMoreWhyTitle": "Зачем обновлять оба компонента?", + "readMoreWhyBody": "Обновление загрузчика блокирует устройство, чтобы оно принимало только официальное программное обеспечение KeepKey — даже если кто-то попытается установить что-то вредоносное, устройство отклонит это. Вместе загрузчик и прошивка обеспечивают безопасность ваших средств.", + "gotIt": "Понятно" }, "bootloader": { "title": "Обновление загрузчика", "description": "Ваш загрузчик необходимо обновить для безопасности и совместимости.", + "putDeviceInBootloader": "Переведите устройство в режим загрузчика", + "followStepsBelow": "Выполните действия ниже, и мы автоматически обнаружим ваше устройство.", + "stepsTitle": "Как войти в режим загрузчика:", + "waitingForBootloader": "Ожидание устройства... подключите его, удерживая кнопку", "current": "Текущий", "latest": "Последний", "outdated": "Устаревший", @@ -33,13 +50,19 @@ "updateFailed": "Обновление не удалось", "tryAgain": "Попробовать снова", "enterFirmwareUpdateMode": "Войти в режим обновления прошивки", - "step1Unplug": "1. Отключите ваш KeepKey", - "step2Hold": "2. Зажмите кнопку на устройстве", - "step3Plugin": "3. Удерживая кнопку, подключите USB-кабель", - "step4Release": "4. Отпустите, когда появится экран загрузчика", + "step1Unplug": "Отключите ваш KeepKey", + "step2Hold": "Зажмите кнопку на устройстве", + "step3Plugin": "Удерживая кнопку, подключите USB-кабель", + "step4Release": "Отпустите, когда появится экран загрузчика", "listeningForBootloader": "Ожидание устройства в режиме загрузчика...", "readyDetectBootloader": "Готов — Обнаружить загрузчик", - "skipBootloaderUpdate": "Пропустить обновление загрузчика" + "skipBootloaderUpdate": "Пропустить обновление загрузчика", + "verifyBackupHint": "На KeepKey появится запрос на проверку резервной копии. Мы сделаем это после обновления — удерживайте кнопку, чтобы пропустить.", + "followDirectionsOnDevice": "Следуйте указаниям на устройстве", + "deviceWillGuide": "Ваш KeepKey проведёт вас через процесс обновления.", + "bootloaderDetected": "Обнаружен режим загрузчика", + "deviceReadyForUpdate": "Ваш KeepKey находится в режиме загрузчика и готов к обновлению.", + "updateBootloaderTo": "Обновить загрузчик до v{{version}}" }, "firmware": { "title": "Обновление прошивки", @@ -58,7 +81,36 @@ "firmwareVerified": "Прошивка подтверждена как официальный релиз", "firmwareHashNotFound": "Хеш прошивки не найден в манифесте", "updateFirmwareTo": "Обновить прошивку до v{{version}}", - "skipUpdate": "Пропустить обновление" + "skipUpdate": "Пропустить обновление", + "confirmOnDevice": "Подтвердите действие на устройстве!", + "lookAtDeviceAndPress": "Посмотрите на экран KeepKey и нажмите кнопку для подтверждения.", + "verifyBackupNote": "Если ваше устройство не настроено, вы можете спокойно проигнорировать экран «проверка резервной копии».", + "uploadingFirmware": "Загрузка прошивки... Не отключайте устройство", + "estimatedTimeRemaining": "Примерное оставшееся время: {{seconds}} сек.", + "deviceWillRestart": "Устройство перезагрузится после завершения.", + "skipWarning": "Вы можете продолжить с более старой прошивкой. Некоторые функции могут работать некорректно.", + "bootloaderReadyForFirmware": "Ваш KeepKey находится в режиме загрузчика и готов к установке прошивки.", + "installLatestFirmware": "Установить официальную прошивку v{{version}}", + "orCustomFirmware": "Или установить пользовательскую прошивку", + "customFirmwareHint": "Выберите файл прошивки .bin с вашего компьютера или перетащите его сюда.", + "browseFiles": "Выбрать файл", + "analyzingFirmware": "Анализ прошивки...", + "customFirmwareReady": "Пользовательская прошивка готова к прошивке", + "flashFirmware": "Прошить", + "signed": "ПОДПИСАННАЯ", + "unsigned": "НЕПОДПИСАННАЯ", + "version": "Версия", + "size": "Размер", + "willWipeWarning": "Прошивка неподписанной прошивки на подписанное устройство УДАЛИТ ВСЕ ДАННЫЕ. Убедитесь, что ваша сид-фраза сохранена.", + "unsignedWarning": "Это неподписанная прошивка для разработчиков. Она может вызвать непредвиденное поведение.", + "customFlashFailed": "Не удалось прошить пользовательскую прошивку", + "rebootTakingLong": "Переподключение занимает больше времени, чем обычно...", + "rebootTakingLongSub": "Устройству может потребоваться время для перезагрузки.", + "manualReconnectTitle": "Устройство не переподключается?", + "manualReconnectStep1": "1. Отключите ваш KeepKey", + "manualReconnectStep2": "2. Подождите 5 секунд", + "manualReconnectStep3": "3. Подключите его обратно", + "manualReconnectNote": "Настройка продолжится автоматически, когда устройство будет обнаружено." }, "initChoose": { "title": "Настройте кошелёк", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/zh/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/zh/dashboard.json index a3de565..399930c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/zh/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/zh/dashboard.json @@ -7,5 +7,27 @@ "noBalance": "无余额", "tokensCount_one": "+{{count}} 个代币", "tokensCount_other": "+{{count}} 个代币", - "watchOnlyBanner": "仅查看 — 连接设备以获得完整访问权限" + "watchOnlyBanner": "仅查看 — 连接设备以获得完整访问权限", + "welcomeTitle": "欢迎使用 KeepKey Vault", + "welcomeSubtitle": "您的钱包已准备就绪。以下是入门指南:", + "welcomeTip1": "点击下方任意链,然后按「接收」获取您的存款地址", + "welcomeTip2": "将加密货币发送到您的地址 — 余额将自动显示在此处", + "welcomeTip3": "使用 + 卡片添加自定义 EVM 链以跟踪任何网络", + "balancesStale": "余额可能已过时", + "lastUpdated": "上次更新 {{time}}", + "lastUpdatedNever": "从未加载", + "refreshPrompt": "按下以更新余额", + "tokenWarningTitle": "代币余额不可用", + "tokenWarningDesc": "未返回代币数据。已显示您的原生余额,但 ERC-20 / 代币余额可能缺失。", + "refreshing": "刷新中...", + "reports": "报告", + "pioneerOfflineTitle": "余额服务器离线", + "pioneerOfflineDesc": "无法连接到 {{url}}。余额可能不可用。", + "changeServer": "更换服务器", + "getSupport": "获取支持", + "retry": "重试", + "timeJustNow": "刚刚", + "timeMinutesAgo": "{{count}}分钟前", + "timeHoursAgo": "{{count}}小时前", + "timeDaysAgo": "{{count}}天前" } diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json index 9d8d14c..c06577c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/zh/setup.json @@ -17,14 +17,31 @@ }, "welcome": { "title": "欢迎使用 KeepKey", + "oobTitle": "检测到新设备", + "oobWelcome": "欢迎使用 KeepKey", + "oobWizardDesc": "此向导将引导您完成引导程序和固件更新,以准备好您的设备。", "oobIntro": "让我们更新固件并设置您的钱包。", "intro": "让我们设置您的硬件钱包。", + "getStarted": "开始", "startingSetup": "正在启动设置...", - "detectingDevice": "正在检测设备状态..." + "detectingDevice": "正在检测设备状态...", + "readMore": "这是如何工作的?", + "readMoreTitle": "这是如何工作的?", + "readMoreBootloaderTitle": "什么是引导加载程序?", + "readMoreBootloaderBody": "引导加载程序是 KeepKey 开机时首先运行的小程序。可以把它想象成一名安全卫士——它会在允许主软件运行之前,检查设备上的软件是否真实且未被篡改。", + "readMoreFirmwareTitle": "什么是固件?", + "readMoreFirmwareBody": "固件是 KeepKey 用来管理加密资产的主要软件。它负责从生成地址到签署交易的所有操作。更新固件可以获得最新的功能和安全补丁。", + "readMoreWhyTitle": "为什么两者都要更新?", + "readMoreWhyBody": "更新引导加载程序可以锁定您的设备,使其只接受官方 KeepKey 软件——即使有人试图安装恶意软件,您的设备也会拒绝。引导加载程序和固件共同保护您的资金安全。", + "gotIt": "明白了" }, "bootloader": { "title": "引导程序更新", "description": "您的引导程序需要更新以确保安全性和兼容性。", + "putDeviceInBootloader": "将设备切换到引导加载程序模式", + "followStepsBelow": "请按照以下步骤操作,我们将自动检测您的设备。", + "stepsTitle": "如何进入引导加载程序模式:", + "waitingForBootloader": "等待您的设备...请按住按钮的同时插入设备", "current": "当前", "latest": "最新", "outdated": "过期", @@ -33,13 +50,19 @@ "updateFailed": "更新失败", "tryAgain": "重试", "enterFirmwareUpdateMode": "进入固件更新模式", - "step1Unplug": "1. 拔出您的 KeepKey", - "step2Hold": "2. 按住设备上的按钮", - "step3Plugin": "3. 按住按钮的同时插入 USB 线", - "step4Release": "4. 当引导程序屏幕出现时松开按钮", + "step1Unplug": "拔出您的 KeepKey", + "step2Hold": "按住设备上的按钮", + "step3Plugin": "按住按钮的同时插入 USB 线", + "step4Release": "当引导程序屏幕出现时松开按钮", "listeningForBootloader": "正在监听引导程序模式下的设备...", "readyDetectBootloader": "已准备好 — 检测引导程序", - "skipBootloaderUpdate": "跳过引导程序更新" + "skipBootloaderUpdate": "跳过引导程序更新", + "verifyBackupHint": "KeepKey 设备上会要求您验证备份。我们将在更新后进行此操作——请按住按钮暂时跳过。", + "followDirectionsOnDevice": "请按照设备上的指示操作", + "deviceWillGuide": "您的 KeepKey 将引导您完成更新过程。", + "bootloaderDetected": "已检测到引导加载程序模式", + "deviceReadyForUpdate": "您的 KeepKey 处于引导加载程序模式,已准备好进行更新。", + "updateBootloaderTo": "将引导程序更新至 v{{version}}" }, "firmware": { "title": "固件更新", @@ -58,7 +81,36 @@ "firmwareVerified": "固件已验证为官方版本", "firmwareHashNotFound": "在清单中未找到固件哈希", "updateFirmwareTo": "将固件更新至 v{{version}}", - "skipUpdate": "跳过更新" + "skipUpdate": "跳过更新", + "confirmOnDevice": "请在设备上确认操作!", + "lookAtDeviceAndPress": "请查看 KeepKey 屏幕并按下按钮进行确认。", + "verifyBackupNote": "如果您的设备尚未设置,您可以安全地忽略任何'验证备份'屏幕。", + "uploadingFirmware": "正在上传固件...请勿断开设备连接", + "estimatedTimeRemaining": "预计剩余时间:{{seconds}}秒", + "deviceWillRestart": "完成后您的设备将自动重启。", + "skipWarning": "您可以继续使用旧版固件。某些功能可能无法正常工作。", + "bootloaderReadyForFirmware": "您的 KeepKey 处于引导加载程序模式,已准备好安装固件。", + "installLatestFirmware": "安装官方固件 v{{version}}", + "orCustomFirmware": "或安装自定义固件", + "customFirmwareHint": "从您的电脑中选择一个 .bin 固件文件,或将其拖放到此处。", + "browseFiles": "浏览文件", + "analyzingFirmware": "正在分析固件...", + "customFirmwareReady": "自定义固件已准备好刷写", + "flashFirmware": "刷写固件", + "signed": "已签名", + "unsigned": "未签名", + "version": "版本", + "size": "大小", + "willWipeWarning": "将未签名固件刷写到已签名设备上将会清除所有数据。请确保您已备份助记词。", + "unsignedWarning": "这是未签名的开发者固件。可能会导致意外行为。", + "customFlashFailed": "自定义固件刷写失败", + "rebootTakingLong": "重新连接时间超出预期...", + "rebootTakingLongSub": "设备可能需要一些时间来重启。", + "manualReconnectTitle": "设备无法重新连接?", + "manualReconnectStep1": "1. 拔出您的 KeepKey", + "manualReconnectStep2": "2. 等待 5 秒", + "manualReconnectStep3": "3. 重新插入", + "manualReconnectNote": "检测到设备后,设置将自动继续。" }, "initChoose": { "title": "设置您的钱包", diff --git a/projects/keepkey-vault/src/shared/firmware-versions.ts b/projects/keepkey-vault/src/shared/firmware-versions.ts new file mode 100644 index 0000000..f63e749 --- /dev/null +++ b/projects/keepkey-vault/src/shared/firmware-versions.ts @@ -0,0 +1,159 @@ +/** + * Firmware Version Map — tracks features introduced in each firmware release. + * + * Used by the upgrade preview UI to show users what they gain from a firmware update. + * Only versions that introduce notable user-facing features need entries. + */ + +export interface FirmwareFeature { + /** Short title: "Solana Support" */ + title: string + /** One-line description */ + description: string + /** Chain IDs from chains.ts that this feature enables (for logo display) */ + chains?: string[] + /** Brand color override for the feature highlight */ + color?: string + /** Icon key: 'chain' shows chain logos, 'security' shows a shield, 'performance' shows a bolt */ + icon?: 'chain' | 'security' | 'performance' | 'feature' +} + +export interface FirmwareVersionInfo { + version: string + /** Release date (display only) */ + date?: string + /** Headline shown at top of upgrade preview */ + headline: string + /** Features introduced in this version */ + features: FirmwareFeature[] +} + +/** + * Map of firmware versions to their notable features. + * Ordered newest-first. Only versions with user-visible features are listed. + */ +export const FIRMWARE_VERSION_MAP: FirmwareVersionInfo[] = [ + { + version: '7.11.0', + date: '2025-03', + headline: 'Solana has arrived on KeepKey', + features: [ + { + title: 'Solana Support', + description: 'Send, receive, and sign Solana transactions directly from your KeepKey.', + chains: ['solana'], + color: '#14F195', + icon: 'chain', + }, + ], + }, + // Future versions go here: + // { + // version: '7.12.0', + // headline: 'Zcash Privacy', + // features: [ + // { title: 'Zcash Shielded', description: '...', chains: ['zcash'], color: '#F4B728', icon: 'chain' }, + // ], + // }, +] + +/** + * Get features introduced between two firmware versions (exclusive of `from`, inclusive of `to`). + * Returns features newest-first. If `from` is null, returns all features up to `to`. + */ +export function getUpgradeFeatures(from: string | null, to: string): FirmwareFeature[] { + const features: FirmwareFeature[] = [] + for (const entry of FIRMWARE_VERSION_MAP) { + if (versionCompare(entry.version, to) > 0) continue // skip versions newer than target + if (from && versionCompare(entry.version, from) <= 0) break // stop at current version + features.push(...entry.features) + } + return features +} + +/** + * Get the version info for a specific firmware version. + */ +export function getVersionInfo(version: string): FirmwareVersionInfo | undefined { + return FIRMWARE_VERSION_MAP.find(v => v.version === version) +} + +/** + * Get all version infos between two versions (features the user will gain). + */ +export function getUpgradeVersions(from: string | null, to: string): FirmwareVersionInfo[] { + const versions: FirmwareVersionInfo[] = [] + for (const entry of FIRMWARE_VERSION_MAP) { + if (versionCompare(entry.version, to) > 0) continue + if (from && versionCompare(entry.version, from) <= 0) break + versions.push(entry) + } + return versions +} + +/** + * On-device firmware hash → version map. + * + * The device reports SHA-256(meta_descriptor + app_code) which equals the + * full-file SHA-256 of the downloadable .bin. This is different from the + * manifest's hashes.firmware which are payload-only (skip 256-byte KPKY header). + * + * Used to resolve firmware version in bootloader mode where the device can't + * report version numbers. Unknown hashes indicate custom/unsigned firmware. + */ +export const ONDEVICE_FIRMWARE_HASHES: Record = { + 'd380357b7403064d7b1ea963dc56032239541a21ef0b7e08082fb36ed470de82': 'v6.0.0', + '699f75ae5936977bf4f9df0478afe40106ea21bc2d94746bbe244a7832d4c5ca': 'v6.0.1', + '14cf71b0872a5c3cda1af2007aafd9bd0d5401be927e08e5b226fe764334d515': 'v6.0.2', + '61c157a7fbc22f4d9825909ac067277a94e44c174e77db419fbb78b361fbf4ea': 'v6.0.4', + '4246ff0e1b71a2a6b3e89e2cfd0882dc207f96b2516640d6c5fff406c02097bf': 'v6.1.0', + 'f9dfd903e6d4d8189409a72b9d31897ca1753a4000a24cc1c9217f4b8141403c': 'v6.1.1', + '0158073bb527b3b14148641722e77346ecec66a12fc4a5b4457dc0559c63169e': 'v6.2.0', + '5bcbeecea0a1c78cbd11344bb31c809072a01cb775f1e42368ef275888012208': 'v6.2.2', + '0e2463b777f39dc8b450aca78f55b3355e906c69d19b59e84052786c5fa8f78c': 'v6.3.0', + '0ef1b51a450fafd8e0586103bda38526c5d012fc260618b8df5437cba7682c5b': 'v6.4.0', + '89d1b5230bbca2e02901b091cbd77207d0636e6f1956f6f27a0ecb10c43cce3d': 'v6.5.1', + '85a44f1872b4b4ed0d5ff062711cfd4d4d69d9274312c9e3780b7db8da9072e8': 'v6.6.0', + '24071db7596f0824e51ce971c1ec39ac5a07e7a5bcaf5f1b33313de844e25580': 'v6.7.0', + '6a5e2bcf98aeafbb2faa98ea425ac066a7b4733e5b9edb29e544bad659cb3766': 'v7.0.3', + 'd8b2b43eada45ded399f347289750a7083081186b37158b85eab41a38cbc6e50': 'v7.1.0', + 'eb3d8853d549671dee532b51363cffdfa2038bc7730117e72dc17bb1452de4db': 'v7.1.1', + 'aa5834bb591c40dffd5e083797fe25e6d5591199a781220025fa469a965d0279': 'v7.1.2', + '7a52fa75be2e3e9794c4a01e74fc2a68cd502aace13fca1f272d5296156f1499': 'v7.1.4', + '2b7edd319536076e0a00058d0cfd1b1863c8d616ba5851668796d04966df8594': 'v7.1.7', + '72838adfe3762760dbbbadd74c8914b783660ea0ef3b8fe340e4a663442c5549': 'v7.1.8', + 'c6cf79e7c2cc1b9cf7eca57aacaab5310b4dd0eff1559cda307295d753251eff': 'v7.2.1', + 'efcdcb32f199110e9a38010bc48d2acc66da89d41fb30c7d0b64c1ef74c90359': 'v7.3.2', + '43472b6fc1a3c9a2546ba771af830005f5758acbd9ea0679d4f20d480f63a040': 'v7.4.0', + '08b1153a6e9ba5f45776094d62c8d055632d414a38f0c70acd1e751229bf097c': 'v7.5.0', + 'fdd10f5cf6469655c82c8259f075cdb3c704a93eb691072e3fa8ba5b4c4cafc4': 'v7.5.1', + 'a94ba237468243929e0363a1bd2f48914580abfe2a90abbb533b0a201c434d54': 'v7.5.2', + 'b4022a002278d1c00ccea54eb4d03934542ac509d03d77c0b7a8b8485b731f11': 'v7.6.0', + '1eb79470f73e40464d5e689e5008dddb47e7eb53bc87c50b1de4f3f150ed36bf': 'v7.7.0', + '31c1cdd945a7331e01b3cced866cb28add5b49eef87c2bbc08370e5aa7daf9bf': 'v7.8.0', + '387ec4c8d3dcc83df8707aa0129eeb44e824c3797fb629a493be845327669da1': 'v7.9.0', + 'fc13cb3a405fdee342ebd0d945403b334f0c43ba19771fdabd0e81caf85a63f7': 'v7.9.1', + '24cca93ef5e7907dc6d8405b8ab9800d4e072dd9259138cf7679107985b88137': 'v7.9.3', + '518ad41643ee8a0aa6a6422f8534ac94f56cd65bc637aea4db7f3fdbb53255c3': 'v7.10.0', +} + +/** + * Resolve firmware version from on-device hash. Returns version string or null for custom firmware. + */ +export function resolveOndeviceFirmwareVersion(hash: string | undefined): string | null { + if (!hash) return null + return ONDEVICE_FIRMWARE_HASHES[hash] ?? null +} + +/** Compare semver strings: returns -1 (ab) */ +function versionCompare(a: string, b: string): number { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < 3; i++) { + const va = pa[i] || 0 + const vb = pb[i] || 0 + if (va < vb) return -1 + if (va > vb) return 1 + } + return 0 +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 7e6a302..8477f71 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -124,7 +124,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { listReports: { params: void; response: ReportMeta[] } getReport: { params: { id: string }; response: { meta: ReportMeta; data: ReportData } | null } deleteReport: { params: { id: string }; response: void } - saveReportFile: { params: { id: string; format: 'json' | 'csv' | 'pdf' }; response: { filePath: string } } + saveReportFile: { params: { id: string; format: 'pdf' | 'cointracker' | 'zenledger' }; response: { filePath: string } } // ── Balance cache (instant portfolio) ───────────────────────────── getCachedBalances: { params: void; response: { balances: ChainBalance[]; updatedAt: number } | null } diff --git a/projects/keepkey-vault/src/shared/spamFilter.ts b/projects/keepkey-vault/src/shared/spamFilter.ts index ae85a4a..545dc6d 100644 --- a/projects/keepkey-vault/src/shared/spamFilter.ts +++ b/projects/keepkey-vault/src/shared/spamFilter.ts @@ -1,11 +1,14 @@ /** - * Token Spam Filter — 3-tier detection adapted from keepkey-vault v10. + * Token Spam Filter — multi-tier heuristic detection. * - * Detection tiers: - * 1. User override (SQLite-persisted 'visible'/'hidden') — bypasses all detection - * 2. Value >= $1 → NOT spam - * 3. Known stablecoin symbol with value < $0.50 → CONFIRMED spam - * 4. Value < $1 → POSSIBLE spam (likely worthless airdrop) + * Detection order (first match wins): + * 1. User override (SQLite-persisted 'visible'/'hidden') — absolute precedence + * 2. Name/symbol contains URL or phishing keywords → CONFIRMED spam + * 3. Symbol has suspicious characters or excessive length → CONFIRMED spam + * 4. Known stablecoin symbol with value < $0.50 → CONFIRMED spam + * 5. Dust airdrop: huge quantity (>1M) + near-zero unit price (<$0.0001) → CONFIRMED spam + * 6. Value < $1 → POSSIBLE spam + * 7. Otherwise → clean */ import type { TokenBalance } from './types' @@ -15,6 +18,27 @@ export const KNOWN_STABLECOINS = [ 'FRAX', 'LUSD', 'SUSD', 'ALUSD', 'FEI', 'MIM', 'DOLA', 'AGEUR', 'EURT', 'EURS', ] +/** Well-known legitimate token symbols — exempt from dust-airdrop heuristic */ +const KNOWN_LEGIT_SYMBOLS = new Set([ + // Top tokens by market cap + 'ETH', 'BTC', 'WETH', 'WBTC', 'BNB', 'MATIC', 'POL', 'AVAX', 'SOL', 'DOT', + 'ADA', 'LINK', 'UNI', 'AAVE', 'MKR', 'CRV', 'COMP', 'SNX', 'SUSHI', 'YFI', + 'LDO', 'RPL', 'ARB', 'OP', 'FTM', 'ATOM', 'OSMO', 'RUNE', 'CACAO', 'XRP', + 'DOGE', 'LTC', 'BCH', 'DASH', 'ZEC', 'ETC', + // Wrapped / bridged + 'WAVAX', 'WBNB', 'WMATIC', 'WPOL', 'WFTM', + // Major stablecoins (also in KNOWN_STABLECOINS but listed here for whitelist purposes) + ...KNOWN_STABLECOINS, + // Major DeFi / governance + 'GRT', 'ENS', 'APE', 'SHIB', 'PEPE', 'WLD', 'IMX', 'RNDR', 'FET', 'OCEAN', + 'SAND', 'MANA', 'AXS', 'GALA', 'ILV', 'BLUR', 'PENDLE', 'ENA', 'ETHFI', + 'STX', 'INJ', 'TIA', 'SEI', 'SUI', 'APT', 'NEAR', 'FIL', 'AR', + // LSTs / LRTs + 'STETH', 'RETH', 'CBETH', 'WSTETH', 'SWETH', 'EETH', 'WEETH', 'METH', 'RSETH', + // FOX + 'FOX', +]) + export type SpamLevel = 'confirmed' | 'possible' | null export interface SpamResult { @@ -23,8 +47,22 @@ export interface SpamResult { reason: string } +// ── Heuristic helpers ──────────────────────────────────────────────── + +/** URL-like patterns in name or symbol — nearly always phishing */ +const URL_PATTERN = /(?:\.[a-z]{2,6}(?:\/|$))|https?:|www\./i + +/** Phishing action words that appear in scam token names */ +const PHISHING_KEYWORDS = /\b(claim|visit|reward|bonus|airdrop|free|voucher|gift|redeem|activate|eligible)\b/i + +/** Symbols should be short alphanumeric; these chars indicate scam */ +const SUSPICIOUS_SYMBOL_CHARS = /[./:$!@#%^&*()+=\[\]{}|\\<>,?~`'"]/ + +/** Max reasonable symbol length — real tokens are 2-11 chars */ +const MAX_SYMBOL_LENGTH = 11 + /** - * Detect whether a token is spam based on its USD value and symbol. + * Detect whether a token is spam. * * Call with optional `userOverride` from the token_visibility DB table. * When a user override is present it takes absolute precedence. @@ -33,7 +71,7 @@ export function detectSpamToken( token: TokenBalance, userOverride?: 'visible' | 'hidden' | null, ): SpamResult { - // User override — absolute precedence + // ── Tier 0: User override — absolute precedence ────────────────── if (userOverride === 'visible') { return { isSpam: false, level: null, reason: 'User marked as safe' } } @@ -42,14 +80,37 @@ export function detectSpamToken( } const usd = token.balanceUsd ?? 0 + const sym = (token.symbol || '').toUpperCase() + const name = token.name || '' - // Tier 1: significant value → NOT spam - if (usd >= 1) { - return { isSpam: false, level: null, reason: 'Has significant USD value' } + // ── Tier 1: Name/symbol contains URL → CONFIRMED spam ──────────── + if (URL_PATTERN.test(name) || URL_PATTERN.test(token.symbol || '')) { + return { + isSpam: true, + level: 'confirmed', + reason: `Name/symbol contains URL — phishing token`, + } } - // Tier 2: fake stablecoin - const sym = (token.symbol || '').toUpperCase() + // ── Tier 2: Name contains phishing keywords → CONFIRMED spam ───── + if (PHISHING_KEYWORDS.test(name)) { + return { + isSpam: true, + level: 'confirmed', + reason: `Name contains phishing keyword`, + } + } + + // ── Tier 3: Suspicious symbol characters or length → CONFIRMED ─── + if (SUSPICIOUS_SYMBOL_CHARS.test(token.symbol || '') || (token.symbol || '').length > MAX_SYMBOL_LENGTH) { + return { + isSpam: true, + level: 'confirmed', + reason: `Symbol has suspicious characters or is too long`, + } + } + + // ── Tier 4: Fake stablecoin (symbol matches but value way off) ─── if (KNOWN_STABLECOINS.includes(sym) && usd < 0.50) { return { isSpam: true, @@ -58,12 +119,42 @@ export function detectSpamToken( } } - // Tier 3: low value → possible spam - return { - isSpam: true, - level: 'possible', - reason: `Low value ($${usd.toFixed(4)}) — common airdrop spam pattern`, + // ── Tier 5: Dust airdrop heuristic ─────────────────────────────── + // Huge quantity + near-zero unit price = classic airdrop spam + // Exempt known legitimate tokens + if (!KNOWN_LEGIT_SYMBOLS.has(sym)) { + const qty = parseFloat(token.balance || '0') + const price = token.priceUsd ?? 0 + + if (qty > 1_000_000 && price < 0.0001) { + return { + isSpam: true, + level: 'confirmed', + reason: `Dust airdrop — ${qty.toLocaleString()} units at $${price.toFixed(8)}/unit`, + } + } + + // Moderate quantity + zero price but somehow has USD value (manipulated) + if (qty > 10_000 && price === 0 && usd > 0) { + return { + isSpam: true, + level: 'confirmed', + reason: `Suspicious — large quantity with $0 price but non-zero value`, + } + } + } + + // ── Tier 6: Low value → POSSIBLE spam ──────────────────────────── + if (usd < 1) { + return { + isSpam: true, + level: 'possible', + reason: `Low value ($${usd.toFixed(4)}) — common airdrop spam pattern`, + } } + + // ── Clean — passed all checks ──────────────────────────────────── + return { isSpam: false, level: null, reason: 'Passed all spam checks' } } /** diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 2471a09..cef30af 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -31,6 +31,7 @@ export interface DeviceStateInfo { needsInit: boolean initialized: boolean isOob: boolean + resolvedFwVersion?: string // firmware version resolved from on-device hash (bootloader mode only) firmwareHash?: string bootloaderHash?: string firmwareVerified?: boolean From 3aeaccfd73bb0869697d3f838818e17e7e103bb0 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 8 Mar 2026 17:17:16 -0600 Subject: [PATCH 6/7] fix: address code review findings and bump version to 1.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore finite RPC timeout (30 min) instead of Infinity - Restore CI codesign/notarize guard in electrobun.config.ts - Restore dereference:true in collect-externals.ts cpSync calls - Fix Windows build submodule path (proto-tx-builder-vendored → proto-tx-builder) - Add idempotency guard to cleanupAndQuit (prevents double-cleanup) - Add max poll count (60 × 5s = 5 min) to reboot poll timer - Add firmware base64 size limit (10MB) and sanitize report shortId - Fix O(n^2) base64 conversion in FirmwareDropZone (use chunked approach) Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/electrobun.config.ts | 7 ++++--- projects/keepkey-vault/package.json | 2 +- .../keepkey-vault/scripts/collect-externals.ts | 4 ++-- .../keepkey-vault/src/bun/engine-controller.ts | 15 +++++++++++++-- projects/keepkey-vault/src/bun/index.ts | 9 +++++++-- .../src/mainview/components/FirmwareDropZone.tsx | 10 +++++++--- scripts/build-windows-production.ps1 | 2 +- 7 files changed, 35 insertions(+), 14 deletions(-) diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index fe648d0..d2954be 100644 --- a/projects/keepkey-vault/electrobun.config.ts +++ b/projects/keepkey-vault/electrobun.config.ts @@ -4,7 +4,7 @@ export default { app: { name: "keepkey-vault", identifier: "com.keepkey.vault", - version: "1.1.0", + version: "1.1.1", urlSchemes: ["keepkey"], }, build: { @@ -35,9 +35,10 @@ export default { bundleCEF: false, icons: "icon.iconset", // Code signing — requires ELECTROBUN_DEVELOPER_ID, ELECTROBUN_TEAMID env vars - codesign: true, + // Disabled in CI (no Apple certs on Linux runners) + codesign: process.env.CI !== 'true', // Notarization — requires ELECTROBUN_APPLEID, ELECTROBUN_APPLEIDPASS env vars - notarize: true, + notarize: process.env.CI !== 'true', // Entitlements for native USB modules (node-hid, usb, hdwallet) entitlements: { "com.apple.security.cs.allow-jit": true, diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index cb10430..cf9f6c1 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.1.0", + "version": "1.1.1", "description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun", "scripts": { "dev": "vite build && bun scripts/collect-externals.ts && electrobun build && electrobun dev", diff --git a/projects/keepkey-vault/scripts/collect-externals.ts b/projects/keepkey-vault/scripts/collect-externals.ts index c6a1cfe..0af4e76 100644 --- a/projects/keepkey-vault/scripts/collect-externals.ts +++ b/projects/keepkey-vault/scripts/collect-externals.ts @@ -226,7 +226,7 @@ for (const dep of sorted) { // Ensure parent dir exists for scoped packages (@keepkey/...) mkdirSync(dirname(dst), { recursive: true }) - cpSync(src, dst, { recursive: true }) + cpSync(src, dst, { recursive: true, dereference: true }) copiedCount++ } @@ -259,7 +259,7 @@ let nestedCount = 0 for (const { src, dst } of nestedCopies) { if (!existsSync(src)) continue mkdirSync(dirname(dst), { recursive: true }) - cpSync(src, dst, { recursive: true }) + cpSync(src, dst, { recursive: true, dereference: true }) nestedCount++ } if (nestedCount > 0) { diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index 3f970c3..e216a1f 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -403,16 +403,27 @@ export class EngineController extends EventEmitter { * so this ensures the device is re-detected via periodic scanning. * The existing `syncing` guard prevents concurrent runs. */ + private rebootPollCount = 0 + private static readonly MAX_REBOOT_POLLS = 60 // 60 × 5s = 5 minutes max + private startRebootPoll() { this.stopRebootPoll() - console.log('[Engine] Starting reboot poll (5s interval)') + this.rebootPollCount = 0 + console.log('[Engine] Starting reboot poll (5s interval, max 5 min)') this.rebootPollTimer = setInterval(() => { if (this.updatePhase !== 'rebooting') { console.log('[Engine] Reboot poll: updatePhase is no longer rebooting, stopping') this.stopRebootPoll() return } - console.log('[Engine] Reboot poll: calling syncState()') + this.rebootPollCount++ + if (this.rebootPollCount >= EngineController.MAX_REBOOT_POLLS) { + console.warn('[Engine] Reboot poll: max attempts reached (5 min), stopping') + this.updatePhase = null + this.stopRebootPoll() + return + } + console.log(`[Engine] Reboot poll ${this.rebootPollCount}/${EngineController.MAX_REBOOT_POLLS}: calling syncState()`) this.syncState() }, 5000) } diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index f023c19..9b6cd8a 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -239,7 +239,7 @@ if (!restApiEnabled) console.log('[Vault] REST API disabled (enable in Settings // ── RPC Bridge (Electrobun UI ↔ Bun) ───────────────────────────────── const rpc = BrowserView.defineRPC({ - maxRequestTime: Infinity, // no timeout — user can take as long as needed to confirm on device + maxRequestTime: 1_800_000, // 30 minutes — generous for device-interactive ops, but not infinite handlers: { requests: { // ── Device lifecycle ────────────────────────────────────── @@ -248,10 +248,12 @@ const rpc = BrowserView.defineRPC({ startFirmwareUpdate: async () => { await engine.startFirmwareUpdate() }, flashFirmware: async () => { await engine.flashFirmware() }, analyzeFirmware: async (params) => { + if (params.data.length > 10_000_000) throw new Error('Firmware data too large (max ~7.5MB)') const buf = Buffer.from(params.data, 'base64') return engine.analyzeFirmware(buf) }, flashCustomFirmware: async (params) => { + if (params.data.length > 10_000_000) throw new Error('Firmware data too large (max ~7.5MB)') const buf = Buffer.from(params.data, 'base64') await engine.flashCustomFirmware(buf) }, @@ -1338,7 +1340,7 @@ const rpc = BrowserView.defineRPC({ const txs = extractTransactionsFromReport(report.data) await Bun.write(filePath, toZenLedgerCsv(txs)) } else if (params.format === 'pdf') { - const shortId = params.id.slice(-6) + const shortId = params.id.slice(-6).replace(/[^a-zA-Z0-9]/g, '') const safeChain = (report.meta.chain || 'all').replace(/[^a-zA-Z0-9_-]/g, '_') filePath = path.join(downloadsDir, `keepkey-report-${safeChain}-${dateSuffix}-${shortId}.pdf`) console.log(`[reports] Generating PDF to ${filePath}...`) @@ -1659,7 +1661,10 @@ mainWindow.on("open-url", (e: any) => { }) // Cleanup and quit helper — shared between window close and app quit +let quitting = false function cleanupAndQuit() { + if (quitting) return + quitting = true stopCamera() engine.stop() restServer?.stop() diff --git a/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx b/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx index 073c1ef..6bb30b2 100644 --- a/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx +++ b/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx @@ -107,9 +107,13 @@ export function FirmwareDropZone() { try { const arrayBuf = await file.arrayBuffer() - const b64 = btoa( - new Uint8Array(arrayBuf).reduce((s, b) => s + String.fromCharCode(b), "") - ) + const bytes = new Uint8Array(arrayBuf) + const CHUNK = 8192 + let binary = '' + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)) + } + const b64 = btoa(binary) setFileDataB64(b64) const result = await rpcRequest("analyzeFirmware", { data: b64 }) diff --git a/scripts/build-windows-production.ps1 b/scripts/build-windows-production.ps1 index a07341d..a328dbc 100644 --- a/scripts/build-windows-production.ps1 +++ b/scripts/build-windows-production.ps1 @@ -249,7 +249,7 @@ if (-not $SkipBuild) { # Only init the submodules we actually need — recursive init pulls deeply # nested firmware deps whose paths exceed Windows MAX_PATH (260 chars) git submodule update --init modules/hdwallet - git submodule update --init modules/proto-tx-builder-vendored + git submodule update --init modules/proto-tx-builder git submodule update --init modules/keepkey-firmware Pop-Location From 4c90baf64f74d0a8e4065976d4bfc5ef289d4ed5 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 8 Mar 2026 17:22:00 -0600 Subject: [PATCH 7/7] fix: use full TXIDs in reports instead of truncated with ellipsis - reports.ts: output full tx.txid in Transaction History and Transaction Details tables - tax-export.ts: remove cleanTxid() helper (no longer needed since TXIDs aren't truncated) Co-Authored-By: Claude Opus 4.6 --- projects/keepkey-vault/src/bun/reports.ts | 4 ++-- projects/keepkey-vault/src/bun/tax-export.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index 874d0cf..a0161c6 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -371,7 +371,7 @@ async function buildBtcSections( return [ (idx + 1).toString(), tx.direction.toUpperCase(), - `${tx.txid.substring(0, 16)}...`, + tx.txid, tx.blockHeight.toString(), date, (tx.value / 1e8).toFixed(8), @@ -422,7 +422,7 @@ async function buildBtcSections( ? new Date(tx.timestamp * 1000).toISOString().replace('T', ' ').substring(0, 19) : 'Pending' return [ - `${tx.txid.substring(0, 16)}...`, + tx.txid, tx.direction.toUpperCase(), tx.blockHeight.toString(), date, diff --git a/projects/keepkey-vault/src/bun/tax-export.ts b/projects/keepkey-vault/src/bun/tax-export.ts index ba47b31..ae5e7bd 100644 --- a/projects/keepkey-vault/src/bun/tax-export.ts +++ b/projects/keepkey-vault/src/bun/tax-export.ts @@ -72,7 +72,7 @@ export function extractTransactionsFromReport(data: ReportData): TaxTransaction[ for (const row of rows) { if (useDetailed) { // Transaction Details: [TXID, Dir, Block, Date, Value (BTC), Fee (BTC), From, To] - const txid = cleanTxid(String(row[0] || '')) + const txid = String(row[0] || '') const dir = String(row[1] || '').toLowerCase() const date = String(row[3] || '') const value = parseNum(row[4]) @@ -83,7 +83,7 @@ export function extractTransactionsFromReport(data: ReportData): TaxTransaction[ txs.push(buildBtcTx(txid, dir, date, value, fee, from, to)) } else { // Transaction History: [#, Dir, TXID, Block, Date, Value (BTC), Fee (BTC)] - const txid = cleanTxid(String(row[2] || '')) + const txid = String(row[2] || '') const dir = String(row[1] || '').toLowerCase() const date = String(row[4] || '') const value = parseNum(row[5]) @@ -96,9 +96,7 @@ export function extractTransactionsFromReport(data: ReportData): TaxTransaction[ return txs } -function cleanTxid(txid: string): string { - return txid.replace(/\.+$/, '') // strip trailing ".." from truncation -} + function parseNum(val: any): number { const n = parseFloat(String(val ?? '0'))