From e04480cf1209e6cb9b39c595b90c9e6b37f936c9 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 4 Mar 2026 12:13:27 -0700 Subject: [PATCH 1/4] 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/4] 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/4] =?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/4] 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.