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..9fe60e6 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,103 @@ 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 + 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] + ) + // 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, 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 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, + 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, deviceId?: string): { meta: ReportMeta; data: ReportData } | null { + try { + if (!db) return 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, + createdAt: row.created_at, + chain: row.chain, + totalUsd: row.total_usd, + status: row.status as ReportMeta['status'], + error: row.error || undefined, + } + 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) + return null + } +} + +export function deleteReport(id: string, deviceId?: string) { + try { + if (!db) return + 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 9e0dda3..f23f5cb 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -17,7 +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 } 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" @@ -34,7 +37,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 +518,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 +565,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 +675,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 +693,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 +757,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 +1211,164 @@ 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) + // 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 {} + + return { + id: reportId, + createdAt: Date.now(), + chain: 'all', + totalUsd, + status: 'complete' as const, + } + } catch (e: any) { + // 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) + } + }, + + listReports: async () => { + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) return [] + return getReportsList(deviceId) + }, + + // H1: Scope getReport/deleteReport to the current device + getReport: async (params) => { + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) throw new Error('No device connected') + return getReportById(params.id, deviceId) + }, + + deleteReport: async (params) => { + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) throw new Error('No device connected') + deleteReport(params.id, deviceId) + }, + + saveReportFile: async (params) => { + 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') + + // 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 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 = path.join(downloadsDir, `${baseName}.csv`) + const csv = reportToCsv(report.data) + await Bun.write(filePath, csv) + } else if (params.format === '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 = path.join(downloadsDir, `${baseName}.json`) + await Bun.write(filePath, JSON.stringify(report.data, null, 2)) + } + + // 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 } + }, + // ── 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..874d0cf --- /dev/null +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -0,0 +1,1086 @@ +/** + * 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 + +function getPioneerQueryKey(): string { + return 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': getPioneerQueryKey() } }, + 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': getPioneerQueryKey() }, + 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: 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, + })), + }) + 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: Math.round(Number(tx.value || 0)), + fee: Math.round(Number(tx.fee || 0)), + 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 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: `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 > 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' }) + } + + // 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 { 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() + + 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 += Math.round(Number(info.balance || 0)) + } + 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': getPioneerQueryKey() }, + 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 ──────────────────────────────────────────────────────── + +/** Escape a cell value for CSV: quote wrapping + formula injection prevention */ +function csvCell(value: any): string { + let s = String(value ?? '').replace(/"/g, '""') + // Prevent formula injection: trim leading whitespace then prefix dangerous chars + const trimmed = s.trimStart() + if (/^[=+\-@\t\r\n]/.test(trimmed)) s = `'${s}` + return `"${s}"` +} + +export function reportToCsv(data: ReportData): string { + const lines: string[] = [] + + lines.push(csvCell(data.title)) + lines.push(csvCell(data.subtitle)) + lines.push(csvCell(`Generated: ${data.generatedDate}`)) + lines.push('') + + for (const section of data.sections) { + lines.push(csvCell(section.title)) + + switch (section.type) { + case 'table': { + const headers = section.data.headers || [] + const rows = section.data.rows || [] + lines.push(headers.map((h: string) => csvCell(h)).join(',')) + for (const row of rows) { + lines.push(row.map((cell: any) => csvCell(cell)).join(',')) + } + break + } + case 'summary': + case 'list': { + const items = Array.isArray(section.data) ? section.data : [section.data] + for (const item of items) { + lines.push(csvCell(item)) + } + break + } + case 'text': { + lines.push(csvCell(section.data)) + 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 && 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 (lo < t.length && lo > 2) { + t = t.slice(0, lo - 2) + '..' + } else if (lo < t.length) { + t = t.slice(0, lo) + } + } + 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 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 + // 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 + 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: 4, + color: rgb(slice.color.r, slice.color.g, slice.color.b), + }) + } + startAngle += sweepAngle + } + + // 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.03) { + page.drawLine({ + start: { x: pieCenterX, y: pieCenterY }, + end: { x: pieCenterX + innerR * Math.cos(a), y: pieCenterY + innerR * Math.sin(a) }, + thickness: 4, + 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..1791998 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -0,0 +1,343 @@ +import { useState, useEffect, useCallback, useRef } from "react" +import { Box, Flex, Text, Spinner } from "@chakra-ui/react" +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 [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(() => { + rpcRequest("listReports", undefined, 5000) + .then(setReports) + .catch(() => {}) + .finally(() => setLoadingReports(false)) + }, []) + + // Listen for progress messages — accept any progress while generating + useEffect(() => { + return onRpcMessage("report-progress", (payload: { id: string; message: string; percent: number }) => { + // 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 }) + } + }) + }, []) + + 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 {} + setConfirmDeleteId(null) + }, []) + + 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}` + const isSavingThis = saving === savingKey + return ( + !isSavingThis && handleDownload(r.id, key)} + > + {isSavingThis ? "Saving..." : fmt} + + ) + })} + + )} + {/* L8: Delete with confirmation */} + {confirmDeleteId === r.id ? ( + + handleDelete(r.id)} + > + Confirm + + setConfirmDeleteId(null)} + > + Cancel + + + ) : ( + setConfirmDeleteId(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..2471a09 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 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. // These aliases are for convenience in frontend code that doesn't need Electrobun types.