From 038586d42ffec11edc6c3d883621dac9bafac5f5 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 13 Mar 2026 20:38:09 -0600 Subject: [PATCH 01/11] feat: add unified activity tracker (recent txids for all chains) Replace the swap-only floating bubble with a generic activity tracker that shows recent transaction events across all wallets and chains. - Always-visible bottom-left bubble (no feature flag) - Extends api_log table with txid/chain/activity_type columns (no new tables) - Auto-detects sign operations via SIGNING_ROUTES + ROUTE_TO_CHAIN mapping - Tracks internal RPC broadcasts and swap executions in api_log - Queries api_log + swap_history for unified activity feed - ActivityPanel drawer with txid copy, explorer links, timestamps --- projects/keepkey-vault/src/bun/db.ts | 80 +++- projects/keepkey-vault/src/bun/index.ts | 29 +- projects/keepkey-vault/src/bun/rest-api.ts | 20 +- projects/keepkey-vault/src/mainview/App.tsx | 4 +- .../src/mainview/components/ActivityPanel.tsx | 404 ++++++++++++++++++ .../mainview/components/ActivityTracker.tsx | 159 +++++++ .../keepkey-vault/src/shared/rpc-schema.ts | 7 +- projects/keepkey-vault/src/shared/types.ts | 24 ++ 8 files changed, 716 insertions(+), 11 deletions(-) create mode 100644 projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx create mode 100644 projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index a0c60c6..7328334 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -212,6 +212,11 @@ export function initDb() { 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 */ } } + // Activity tracking columns on api_log (sign/broadcast ops) + for (const col of ['txid TEXT', 'chain TEXT', 'activity_type TEXT']) { + try { db.exec(`ALTER TABLE api_log ADD COLUMN ${col}`) } catch { /* already exists */ } + } + try { db.exec(`CREATE INDEX IF NOT EXISTS idx_api_log_activity ON api_log(activity_type)`) } catch { /* already exists */ } console.log(`[db] SQLite cache ready at ${dbPath}`) } catch (e: any) { @@ -511,8 +516,8 @@ export function insertApiLog(entry: ApiLogEntry) { try { if (!db) return db.run( - `INSERT INTO api_log (method, route, timestamp, duration_ms, status, app_name, image_url, request_body, response_body) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO api_log (method, route, timestamp, duration_ms, status, app_name, image_url, request_body, response_body, txid, chain, activity_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ entry.method, entry.route, @@ -523,6 +528,9 @@ export function insertApiLog(entry: ApiLogEntry) { entry.imageUrl || null, entry.requestBody ? JSON.stringify(entry.requestBody) : null, entry.responseBody ? JSON.stringify(entry.responseBody) : null, + entry.txid || null, + entry.chain || null, + entry.activityType || null, ] ) // Periodic prune (every ~100 inserts, check if over limit) @@ -574,6 +582,74 @@ export function clearApiLogs() { } +// ── Recent Activity (unified from api_log + swap_history) ───────────── + +import type { RecentActivity, ActivityType } from '../shared/types' + +/** Query api_log entries that have activity_type set + swap_history, merged by timestamp */ +export function getRecentActivityFromLog(limit = 50): RecentActivity[] { + try { + if (!db) return [] + + const logRows = db.query( + `SELECT id, txid, chain, activity_type, app_name, timestamp, route, method + FROM api_log + WHERE activity_type IS NOT NULL + ORDER BY timestamp DESC + LIMIT ?` + ).all(limit) as Array<{ + id: number; txid: string | null; chain: string | null; activity_type: string; + app_name: string; timestamp: number; route: string; method: string + }> + + const VALID_TYPES = new Set(['send', 'swap', 'sign', 'message', 'approve']) + const logActivities: RecentActivity[] = logRows.map(r => ({ + id: String(r.id), + txid: r.txid || undefined, + chain: r.chain || '?', + type: (VALID_TYPES.has(r.activity_type) ? r.activity_type : 'sign') as ActivityType, + source: (r.method === 'RPC' ? 'app' : 'api') as 'app' | 'api', + appName: r.method === 'RPC' ? undefined : r.app_name, + status: r.activity_type === 'broadcast' || r.activity_type === 'swap' ? 'broadcast' : 'signed', + createdAt: r.timestamp, + })) + + // Swap history entries (dedupe by txid against logActivities) + const swapRows = db.query( + `SELECT id, txid, from_symbol, to_symbol, from_chain_id, from_amount, status, created_at + FROM swap_history + ORDER BY created_at DESC + LIMIT ?` + ).all(limit) as Array<{ + id: string; txid: string; from_symbol: string; to_symbol: string; + from_chain_id: string; from_amount: string; status: string; created_at: number + }> + + const logTxids = new Set(logActivities.filter(a => a.txid).map(a => a.txid)) + const swapActivities: RecentActivity[] = swapRows + .filter(r => !logTxids.has(r.txid)) + .map(r => ({ + id: r.id, + txid: r.txid, + chain: r.from_symbol, + chainId: r.from_chain_id, + type: 'swap' as const, + source: 'app' as const, + amount: r.from_amount, + asset: `${r.from_symbol}\u2192${r.to_symbol}`, + status: r.status === 'completed' ? 'broadcast' as const : r.status === 'failed' ? 'failed' as const : 'broadcast' as const, + createdAt: r.created_at, + })) + + return [...logActivities, ...swapActivities] + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + } catch (e: any) { + console.warn('[db] getRecentActivityFromLog failed:', e.message) + return [] + } +} + // ── Device Snapshot (watch-only cache) ────────────────────────────── export function saveDeviceSnapshot(deviceId: string, label: string, firmwareVer: string, featuresJson: string) { diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 0255133..1c41a68 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -19,7 +19,7 @@ import { CHAINS, customChainToChainDef, isChainSupported } from "../shared/chain 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, reportExists, getSwapHistory, getSwapHistoryStats, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys } 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, getSwapHistory, getSwapHistoryStats, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog } from "./db" import { generateReport, reportToPdfBuffer } from "./reports" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import * as os from "os" @@ -1068,17 +1068,24 @@ const rpc = BrowserView.defineRPC({ const chain = getAllChains().find(c => c.id === params.chainId) if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) + let result: { txid: string } + // Custom chains: broadcast via direct RPC const rpcUrl = chain.id.startsWith('evm-custom-') ? getRpcUrl(chain) : undefined if (rpcUrl) { const serialized = params.signedTx?.serializedTx || params.signedTx?.serialized || (typeof params.signedTx === 'string' ? params.signedTx : undefined) if (!serialized || typeof serialized !== 'string') throw new Error(`Cannot extract serialized tx from: ${JSON.stringify(params.signedTx).slice(0, 200)}`) const txid = await broadcastEvmTx(rpcUrl, serialized) - return { txid } + result = { txid } + } else { + const pioneer = await getPioneer() + result = await broadcastTx(pioneer, chain, params.signedTx) } - const pioneer = await getPioneer() - return await broadcastTx(pioneer, chain, params.signedTx) + // Track broadcast in api_log + insertApiLog({ method: 'RPC', route: 'broadcastTx', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: chain.symbol, activityType: 'broadcast' }) + + return result }, getMarketData: async (params) => { @@ -1689,6 +1696,9 @@ const rpc = BrowserView.defineRPC({ } catch (e: any) { console.warn('[index] Failed to register swap for tracking:', e.message) } + // Track swap in api_log + const fromChain = getAllChains().find(c => c.id === params.fromChainId) + insertApiLog({ method: 'RPC', route: 'executeSwap', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: fromChain?.symbol || params.fromChainId, activityType: 'swap' }) return result }, getPendingSwaps: async () => { @@ -1734,6 +1744,17 @@ const rpc = BrowserView.defineRPC({ } }, + // ── Recent Activity (from api_log + swap_history) ──────── + getRecentActivity: async (params) => { + return getRecentActivityFromLog(params?.limit || 50) + }, + dismissActivity: async (_params) => { + // No-op: api_log entries are audit records, not dismissible + }, + clearRecentActivity: async () => { + // No-op: api_log entries are audit records + }, + // ── Balance cache (instant portfolio) ──────────────────── getCachedBalances: async () => { const deviceId = engine.getDeviceState().deviceId diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 947e815..981f795 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -317,6 +317,13 @@ function addressNListToBIP32(addressNList: number[]): string { /** Start time for uptime calculation */ const startTime = Date.now() +/** Route prefix → chain symbol for activity tracking */ +const ROUTE_TO_CHAIN: Record = { + eth: 'ETH', utxo: 'BTC', cosmos: 'ATOM', osmosis: 'OSMO', + thorchain: 'RUNE', mayachain: 'CACAO', xrp: 'XRP', + solana: 'SOL', tron: 'TRX', ton: 'TON', +} + /** Set of signing endpoints that require user approval */ const SIGNING_ROUTES = new Set([ '/eth/sign-transaction', '/eth/sign-typed-data', '/eth/sign', @@ -360,10 +367,16 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 let reqBody: any = undefined // Per-request response helpers (capture req for CORS origin check) - const json = (data: unknown, status = 200) => { + const json = (data: unknown, status = 200, activity?: { txid?: string; chain?: string; activityType?: string }) => { const resp = new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', ...corsHeaders(req) }, }) + // Auto-detect activity type from route if not explicitly provided + let resolvedActivity = activity + if (!resolvedActivity && method === 'POST' && SIGNING_ROUTES.has(path)) { + const chainForRoute = ROUTE_TO_CHAIN[path.split('/')[1]] + if (chainForRoute) resolvedActivity = { chain: chainForRoute, activityType: 'sign' } + } // Log the request with body + response + duration if (callbacks?.onApiLog) { const { appName, imageUrl } = resolveAppInfo() @@ -373,6 +386,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 status, appName, imageUrl: imageUrl || undefined, requestBody: reqBody, responseBody: data, + ...resolvedActivity, }) } return resp @@ -934,7 +948,9 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 ...(body.versionGroupId !== undefined ? { versionGroupId: body.versionGroupId } : {}), ...(body.branchId !== undefined ? { branchId: body.branchId } : {}), }) - return json(validateResponse(result, S.UtxoSignTransactionResponse, path)) + // Explicit chain for UTXO — auto-detect defaults to BTC but could be LTC/DOGE/etc + const coinSymbol = coin === 'Bitcoin' ? 'BTC' : coin === 'Litecoin' ? 'LTC' : coin === 'Dogecoin' ? 'DOGE' : coin === 'Dash' ? 'DASH' : coin === 'BitcoinCash' ? 'BCH' : coin + return json(validateResponse(result, S.UtxoSignTransactionResponse, path), 200, { chain: coinSymbol, activityType: 'sign' }) } // ── COSMOS SIGNING (6 endpoints) ────────────────────────────── diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 6f9dbe1..162cd54 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -25,7 +25,7 @@ import { useDeviceState } from "./hooks/useDeviceState" import { useUpdateState } from "./hooks/useUpdateState" import { rpcRequest, onRpcMessage } from "./lib/rpc" import { Z } from "./lib/z-index" -import { SwapTracker } from "./components/SwapTracker" +import { ActivityTracker } from "./components/ActivityTracker" import type { PinRequestType, PairingRequestInfo, SigningRequestInfo, ApiLogEntry, AppSettings } from "../shared/types" type AppPhase = "splash" | "claimed" | "setup" | "ready" @@ -623,7 +623,7 @@ function App() { wcUri={wcUri} onClose={handleCloseWalletConnect} /> - {swapsEnabled && } + {/* Enable API Bridge dialog — shown when user tries to launch an app with REST disabled */} {(pendingAppUrl || pendingWcOpen) && ( <> diff --git a/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx new file mode 100644 index 0000000..cee1eb2 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx @@ -0,0 +1,404 @@ +/** + * ActivityPanel — drawer showing recent transaction activity across all chains. + * + * Shows: txids, chain, type (send/swap/sign), timestamp, status. + * Click txid to copy or open in explorer. + */ +import { useState, useMemo } from "react" +import { Box, Text, Flex, VStack, HStack } from "@chakra-ui/react" +import { rpcRequest } from "../lib/rpc" +import { Z } from "../lib/z-index" +import { CHAINS } from "../../shared/chains" +import type { RecentActivity, PendingSwap } from "../../shared/types" + +interface ActivityPanelProps { + open: boolean + onClose: () => void + activities: RecentActivity[] + pendingSwaps: PendingSwap[] + onRefresh: () => void +} + +// Chain color lookup +const CHAIN_COLORS: Record = {} +CHAINS.forEach(c => { CHAIN_COLORS[c.symbol] = c.color; CHAIN_COLORS[c.id] = c.color }) + +// Type labels and colors +const TYPE_CONFIG: Record = { + send: { label: 'Send', color: '#23DCC8' }, + swap: { label: 'Swap', color: '#F7931A' }, + sign: { label: 'Signed', color: '#627EEA' }, + message: { label: 'Message', color: '#8247E5' }, + approve: { label: 'Approve', color: '#FF0420' }, +} + +const STATUS_CONFIG: Record = { + broadcast: { label: 'Broadcast', color: '#23DCC8' }, + signed: { label: 'Signed', color: '#F7931A' }, + failed: { label: 'Failed', color: '#E53E3E' }, +} + +function getExplorerUrl(chainId: string | undefined, txid: string): string | null { + if (!chainId || !txid) return null + const chain = CHAINS.find(c => c.id === chainId) + if (!chain?.explorerTxUrl) return null + return chain.explorerTxUrl.replace('{{txid}}', txid) +} + +function truncateTxid(txid: string): string { + if (txid.length <= 16) return txid + return txid.slice(0, 8) + '...' + txid.slice(-8) +} + +function timeAgo(ts: number): string { + const diff = Date.now() - ts + if (diff < 60_000) return 'just now' + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago` + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago` + return `${Math.floor(diff / 86400_000)}d ago` +} + +function SwapRow({ swap }: { swap: PendingSwap }) { + const [copied, setCopied] = useState(false) + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const explorerUrl = (() => { + const chain = CHAINS.find(c => c.id === swap.fromChainId) + if (!chain?.explorerTxUrl) return null + return chain.explorerTxUrl.replace('{{txid}}', swap.txid) + })() + + const statusColor = swap.status === 'completed' ? '#23DCC8' + : swap.status === 'failed' ? '#E53E3E' + : swap.status === 'refunded' ? '#F7931A' + : '#627EEA' + + return ( + + + + + + {swap.fromSymbol} → {swap.toSymbol} + + + Swap + + + + {swap.status} + + + + {swap.fromAmount && ( + + {swap.fromAmount} {swap.fromSymbol} + {swap.expectedOutput ? ` → ${swap.expectedOutput} ${swap.toSymbol}` : ''} + + )} + + + handleCopy(swap.txid)} + title={copied ? 'Copied!' : 'Click to copy'} + > + {copied ? 'Copied!' : truncateTxid(swap.txid)} + + + {explorerUrl && ( + rpcRequest('openUrl', { url: explorerUrl })} + > + Explorer + + )} + + {timeAgo(swap.createdAt)} + + + + + ) +} + +function ActivityRow({ activity }: { activity: RecentActivity }) { + const [copied, setCopied] = useState(false) + const typeConf = TYPE_CONFIG[activity.type] || TYPE_CONFIG.sign + const statusConf = STATUS_CONFIG[activity.status] || STATUS_CONFIG.signed + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const explorerUrl = activity.txid ? getExplorerUrl(activity.chainId, activity.txid) : null + + return ( + + + + + + {activity.chain} + + + {typeConf.label} + + {activity.source === 'api' && ( + + API + + )} + + + {statusConf.label} + + + + {/* Details line */} + {(activity.amount || activity.to || activity.appName) && ( + + {activity.amount && `${activity.amount} ${activity.asset || activity.chain}`} + {activity.to && ` → ${activity.to.slice(0, 12)}...`} + {activity.appName && activity.source === 'api' && ` via ${activity.appName}`} + + )} + + {/* TXID line */} + + {activity.txid ? ( + handleCopy(activity.txid!)} + title={copied ? 'Copied!' : 'Click to copy'} + > + {copied ? 'Copied!' : truncateTxid(activity.txid)} + + ) : ( + + no txid + + )} + + {explorerUrl && ( + rpcRequest('openUrl', { url: explorerUrl })} + > + Explorer + + )} + + {timeAgo(activity.createdAt)} + + + + + ) +} + +export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefresh }: ActivityPanelProps) { + const [tab, setTab] = useState<'all' | 'swaps'>('all') + + // Merge activities + active swaps into unified timeline + const activeSwaps = useMemo(() => + pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), + [pendingSwaps] + ) + + // Filter out swap-type activities that duplicate pending swaps + const nonSwapActivities = useMemo(() => { + const swapTxids = new Set(pendingSwaps.map(s => s.txid)) + return activities.filter(a => !(a.type === 'swap' && a.txid && swapTxids.has(a.txid))) + }, [activities, pendingSwaps]) + + const handleClearAll = () => { + rpcRequest('clearRecentActivity').then(onRefresh).catch(() => {}) + } + + const handleDismiss = (id: string) => { + rpcRequest('dismissActivity', { id }).then(onRefresh).catch(() => {}) + } + + if (!open) return null + + return ( + <> + {/* Backdrop */} + + + {/* Panel */} + + {/* Header */} + + Recent Activity + + + Refresh + + {activities.length > 0 && ( + + Clear + + )} + + × + + + + + {/* Tabs */} + + setTab('all')} + > + All ({nonSwapActivities.length + activeSwaps.length}) + + {pendingSwaps.length > 0 && ( + setTab('swaps')} + > + Swaps ({pendingSwaps.length}) + + )} + + + {/* Content */} + + + {tab === 'all' && ( + <> + {/* Active swaps first */} + {activeSwaps.map(swap => ( + + ))} + {/* Then recent activities (sorted by time, newest first) */} + {nonSwapActivities.map(activity => ( + + ))} + {nonSwapActivities.length === 0 && activeSwaps.length === 0 && ( + + No recent activity + + )} + + )} + {tab === 'swaps' && ( + <> + {pendingSwaps.map(swap => ( + + ))} + {pendingSwaps.length === 0 && ( + + No swaps + + )} + + )} + + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx new file mode 100644 index 0000000..5277716 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx @@ -0,0 +1,159 @@ +/** + * ActivityTracker — floating bubble (bottom-left) showing recent transaction activity. + * + * Always visible. Queries api_log (with activity_type) + swap_history. + * Captures: broadcasts, swaps, API signs, messages. + */ +import { useState, useEffect, useCallback, useRef } from "react" +import { Box, Text } from "@chakra-ui/react" +import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { Z } from "../lib/z-index" +import { ActivityPanel } from "./ActivityPanel" +import type { RecentActivity, PendingSwap, SwapStatusUpdate, ApiLogEntry } from "../../shared/types" + +const TRACKER_CSS = ` + @keyframes kkActivityPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(35,220,200,0.5); } + 50% { box-shadow: 0 0 0 8px rgba(35,220,200,0); } + } +` + +export function ActivityTracker() { + const [activities, setActivities] = useState([]) + const [pendingSwaps, setPendingSwaps] = useState([]) + const [panelOpen, setPanelOpen] = useState(false) + const [hasNew, setHasNew] = useState(false) + const lastCountRef = useRef(0) + + // Fetch recent activities from api_log + swap_history (unified query) + const fetchActivities = useCallback(() => { + rpcRequest('getRecentActivity', { limit: 50 }, 5000) + .then((result) => { if (result) setActivities(result) }) + .catch(() => {}) + }, []) + + // Fetch pending swaps (for live swap tracking) + const fetchSwaps = useCallback(() => { + rpcRequest('getPendingSwaps', undefined, 5000) + .then((result) => { if (result) setPendingSwaps(result) }) + .catch(() => {}) + }, []) + + // Fetch on mount + useEffect(() => { fetchActivities(); fetchSwaps() }, [fetchActivities, fetchSwaps]) + + // Listen for new api-log entries — re-fetch if it's a sign/broadcast + useEffect(() => { + const unsub = onRpcMessage('api-log', (entry: ApiLogEntry) => { + if (entry.activityType) { + // New sign/broadcast logged — refresh activity list + fetchActivities() + } + }) + return unsub + }, [fetchActivities]) + + // Listen for swap updates (keep swap awareness) + useEffect(() => { + const unsub1 = onRpcMessage('swap-update', (_update: SwapStatusUpdate) => { + fetchSwaps() + }) + const unsub2 = onRpcMessage('swap-complete', (swap: PendingSwap) => { + fetchSwaps() + fetchActivities() + if (swap.status === 'completed' || swap.status === 'refunded') { + window.dispatchEvent(new CustomEvent('keepkey-swap-completed', { + detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId } + })) + } + }) + return () => { unsub1(); unsub2() } + }, [fetchSwaps, fetchActivities]) + + // Listen for swap-executed DOM event from SwapDialog + useEffect(() => { + const handler = () => { + fetchActivities() + fetchSwaps() + setTimeout(fetchActivities, 1000) + setTimeout(fetchSwaps, 1000) + } + window.addEventListener('keepkey-swap-executed', handler) + return () => window.removeEventListener('keepkey-swap-executed', handler) + }, [fetchActivities, fetchSwaps]) + + // Detect new items for pulse animation + const activeSwapCount = pendingSwaps.filter(s => + s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' + ).length + const totalCount = activities.length + activeSwapCount + useEffect(() => { + if (totalCount > lastCountRef.current && !panelOpen) { + setHasNew(true) + } + lastCountRef.current = totalCount + }, [totalCount, panelOpen]) + + const handleOpen = () => { + setPanelOpen(true) + setHasNew(false) + } + + // Label + const displayCount = activities.length + activeSwapCount + let label: string + if (displayCount === 0) { + label = 'Activity' + } else if (activeSwapCount > 0 && activities.length > 0) { + label = `${displayCount} event${displayCount > 1 ? 's' : ''}` + } else if (activeSwapCount > 0) { + label = `${activeSwapCount} swap${activeSwapCount > 1 ? 's' : ''}` + } else { + label = `${activities.length} tx${activities.length > 1 ? 's' : ''}` + } + + return ( + <> + + + {/* Floating bubble — always visible */} + + 0 ? "rgba(35,220,200,0.15)" : "rgba(255,255,255,0.05)"} + border="1px solid" + borderColor={displayCount > 0 ? "rgba(35,220,200,0.4)" : "rgba(255,255,255,0.1)"} + borderRadius="full" + px="3" + py="1.5" + cursor="pointer" + _hover={{ bg: displayCount > 0 ? "rgba(35,220,200,0.25)" : "rgba(255,255,255,0.1)", transform: "scale(1.05)" }} + transition="all 0.2s" + onClick={handleOpen} + style={hasNew ? { animation: 'kkActivityPulse 2s ease-in-out infinite' } : {}} + > + {activeSwapCount > 0 ? ( + + ) : ( + 0 ? 1 : 0.5}>⚡ + )} + 0 ? "#23DCC8" : "whiteAlpha.500"}> + {label} + + + + + {/* Activity panel */} + setPanelOpen(false)} + activities={activities} + pendingSwaps={pendingSwaps} + onRefresh={() => { fetchActivities(); fetchSwaps() }} + /> + + ) +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index fd9fb01..73597e3 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, ReportMeta, ReportData, SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult, PendingSwap, SwapStatusUpdate, SwapHistoryRecord, SwapHistoryFilter, SwapHistoryStats } 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, SwapAsset, SwapQuote, SwapQuoteParams, ExecuteSwapParams, SwapResult, PendingSwap, SwapStatusUpdate, SwapHistoryRecord, SwapHistoryFilter, SwapHistoryStats, RecentActivity } from './types' /** * RPC Schema for Bun ↔ WebView communication. @@ -152,6 +152,11 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { getSwapHistoryStats: { params: void; response: SwapHistoryStats } exportSwapReport: { params: { fromDate?: number; toDate?: number; format: 'pdf' | 'csv' }; response: { filePath: string } } + // ── Recent Activity ────────────────────────────────────────────────── + getRecentActivity: { params: { limit?: number } | void; response: RecentActivity[] } + dismissActivity: { params: { id: string }; response: void } + clearRecentActivity: { params: void; response: void } + // ── Balance cache (instant portfolio) ───────────────────────────── getCachedBalances: { params: void; response: { balances: ChainBalance[]; updatedAt: number } | null } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 4abc1d3..fab649f 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -265,6 +265,10 @@ export interface ApiLogEntry { imageUrl?: string requestBody?: any // parsed JSON body (POST requests) responseBody?: any // parsed JSON response + // ── Activity tracking (populated for sign/broadcast operations) ── + txid?: string // blockchain txid (computed from signed tx or from broadcast response) + chain?: string // chain symbol (BTC, ETH, ATOM, etc.) + activityType?: string // sign | broadcast | swap | message } // Supported fiat currencies @@ -531,6 +535,26 @@ export interface SwapHistoryStats { pending: number } +// ── Recent Activity types ────────────────────────────────────────────── + +export type ActivityType = 'send' | 'swap' | 'sign' | 'message' | 'approve' +export type ActivitySource = 'app' | 'api' + +export interface RecentActivity { + id: string + txid?: string // blockchain txid (may be absent for sign-only before broadcast) + chain: string // chain symbol (BTC, ETH, ATOM, etc.) + chainId?: string // internal chain id (bitcoin, ethereum, etc.) — for explorer links + type: ActivityType + source: ActivitySource + to?: string + amount?: string + asset?: string // token symbol if different from chain native + appName?: string // for API-originating activities + status: 'signed' | 'broadcast' | 'failed' + createdAt: number +} + // 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 4a569e60730330adbcb210ee68391ad87feb0c4e Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 13 Mar 2026 20:51:00 -0600 Subject: [PATCH 02/11] feat: network-scoped history scanning with chain selector UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scanChainHistory RPC: calls Pioneer GetTxHistory per chain, stores results as api_log entries with send/receive activity type - UTXO chains query by xpub, account-based chains query by address (both resolved from cached balances table) - Dedupe: skips txids already in api_log - ActivityPanel: horizontal chain pill selector with icons (sorted by USD balance) - "Scan [CHAIN] History" button appears when a network is selected - getRecentActivity now supports optional chainId filter - Add 'receive' to ActivityType, fix 'broadcast' → 'send' mapping - Add apiLogTxidExists() helper for dedup check --- projects/keepkey-vault/src/bun/db.ts | 53 ++- projects/keepkey-vault/src/bun/index.ts | 71 +++- .../src/mainview/components/ActivityPanel.tsx | 379 ++++++++---------- .../keepkey-vault/src/shared/rpc-schema.ts | 3 +- projects/keepkey-vault/src/shared/types.ts | 2 +- 5 files changed, 280 insertions(+), 228 deletions(-) diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 7328334..5dd9c97 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -586,28 +586,43 @@ export function clearApiLogs() { import type { RecentActivity, ActivityType } from '../shared/types' +/** Check if a txid already exists in api_log */ +export function apiLogTxidExists(txid: string): boolean { + try { + if (!db) return false + const row = db.query('SELECT 1 FROM api_log WHERE txid = ? LIMIT 1').get(txid) + return !!row + } catch { return false } +} + +const VALID_ACTIVITY_TYPES = new Set(['send', 'receive', 'swap', 'sign', 'message', 'approve', 'broadcast']) + /** Query api_log entries that have activity_type set + swap_history, merged by timestamp */ -export function getRecentActivityFromLog(limit = 50): RecentActivity[] { +export function getRecentActivityFromLog(limit = 50, chainFilter?: string): RecentActivity[] { try { if (!db) return [] - const logRows = db.query( - `SELECT id, txid, chain, activity_type, app_name, timestamp, route, method - FROM api_log - WHERE activity_type IS NOT NULL - ORDER BY timestamp DESC - LIMIT ?` - ).all(limit) as Array<{ + // Build query with optional chain filter + let logSql = `SELECT id, txid, chain, activity_type, app_name, timestamp, route, method + FROM api_log WHERE activity_type IS NOT NULL` + const logParams: any[] = [] + if (chainFilter) { + logSql += ` AND chain = ?` + logParams.push(chainFilter) + } + logSql += ` ORDER BY timestamp DESC LIMIT ?` + logParams.push(limit) + + const logRows = db.query(logSql).all(...logParams) as Array<{ id: number; txid: string | null; chain: string | null; activity_type: string; app_name: string; timestamp: number; route: string; method: string }> - const VALID_TYPES = new Set(['send', 'swap', 'sign', 'message', 'approve']) const logActivities: RecentActivity[] = logRows.map(r => ({ id: String(r.id), txid: r.txid || undefined, chain: r.chain || '?', - type: (VALID_TYPES.has(r.activity_type) ? r.activity_type : 'sign') as ActivityType, + type: (VALID_ACTIVITY_TYPES.has(r.activity_type) ? (r.activity_type === 'broadcast' ? 'send' : r.activity_type) : 'sign') as ActivityType, source: (r.method === 'RPC' ? 'app' : 'api') as 'app' | 'api', appName: r.method === 'RPC' ? undefined : r.app_name, status: r.activity_type === 'broadcast' || r.activity_type === 'swap' ? 'broadcast' : 'signed', @@ -615,12 +630,18 @@ export function getRecentActivityFromLog(limit = 50): RecentActivity[] { })) // Swap history entries (dedupe by txid against logActivities) - const swapRows = db.query( - `SELECT id, txid, from_symbol, to_symbol, from_chain_id, from_amount, status, created_at - FROM swap_history - ORDER BY created_at DESC - LIMIT ?` - ).all(limit) as Array<{ + let swapSql = `SELECT id, txid, from_symbol, to_symbol, from_chain_id, from_amount, status, created_at + FROM swap_history` + const swapParams: any[] = [] + if (chainFilter) { + // Match swap by from_symbol (chain filter is a symbol like 'BTC') + swapSql += ` WHERE from_symbol = ?` + swapParams.push(chainFilter) + } + swapSql += ` ORDER BY created_at DESC LIMIT ?` + swapParams.push(limit) + + const swapRows = db.query(swapSql).all(...swapParams) as Array<{ id: string; txid: string; from_symbol: string; to_symbol: string; from_chain_id: string; from_amount: string; status: string; created_at: number }> diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 1c41a68..7d3a672 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -19,7 +19,7 @@ import { CHAINS, customChainToChainDef, isChainSupported } from "../shared/chain 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, reportExists, getSwapHistory, getSwapHistoryStats, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog } 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, getSwapHistory, getSwapHistoryStats, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, apiLogTxidExists } from "./db" import { generateReport, reportToPdfBuffer } from "./reports" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import * as os from "os" @@ -1082,8 +1082,10 @@ const rpc = BrowserView.defineRPC({ result = await broadcastTx(pioneer, chain, params.signedTx) } - // Track broadcast in api_log - insertApiLog({ method: 'RPC', route: 'broadcastTx', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: chain.symbol, activityType: 'broadcast' }) + // Track broadcast in api_log + notify frontend + const logEntry: ApiLogEntry = { method: 'RPC', route: 'broadcastTx', timestamp: Date.now(), durationMs: 0, status: 200, appName: 'vault', txid: result.txid, chain: chain.symbol, activityType: 'broadcast' } + insertApiLog(logEntry) + try { rpc.send['api-log'](logEntry) } catch { /* webview not ready */ } return result }, @@ -1746,7 +1748,68 @@ const rpc = BrowserView.defineRPC({ // ── Recent Activity (from api_log + swap_history) ──────── getRecentActivity: async (params) => { - return getRecentActivityFromLog(params?.limit || 50) + return getRecentActivityFromLog(params?.limit || 50, params?.chainId) + }, + scanChainHistory: async (params) => { + const chain = getAllChains().find(c => c.id === params.chainId) + if (!chain) throw new Error(`Unknown chain: ${params.chainId}`) + + // Get the address/xpub for this chain from cached balances + // UTXO chains store xpub, account-based chains store address + const deviceId = engine.getDeviceState().deviceId + if (!deviceId) throw new Error('No device connected') + const cachedBalances = getCachedBalances(deviceId) + const chainBalance = cachedBalances?.balances?.find(b => b.chainId === params.chainId) + const pubkey = chainBalance?.address + if (!pubkey) throw new Error(`No cached address for ${chain.symbol} — load balances first`) + + const pioneer = await getPioneer() + console.log(`[activity] Scanning ${chain.symbol} history for ${chain.chainFamily === 'utxo' ? 'xpub' : 'address'}: ${pubkey.slice(0, 16)}...`) + + const resp = await withTimeout( + pioneer.GetTxHistory({ queries: [{ pubkey, caip: chain.caip }] }), + PIONEER_TIMEOUT_MS, + `GetTxHistory(${chain.symbol})` + ) + const data = resp?.data || resp + const histories = data?.histories || data?.data?.histories || [] + const txs: any[] = histories[0]?.transactions || [] + + if (txs.length === 0) { + console.log(`[activity] No transactions found for ${chain.symbol}`) + return { count: 0 } + } + + // Insert each tx as an api_log entry (dedupe by txid) + let inserted = 0 + for (const tx of txs) { + const txid = tx.txid || tx.hash || tx.txHash + if (!txid) continue + + // Skip if already in api_log + const existing = apiLogTxidExists(txid) + if (existing) continue + + const direction = tx.direction || (tx.value < 0 ? 'sent' : 'received') + const activityType = direction === 'sent' ? 'send' : 'receive' + const ts = tx.timestamp ? tx.timestamp * 1000 : tx.blockTime ? tx.blockTime * 1000 : Date.now() + + insertApiLog({ + method: 'SCAN', + route: `history/${params.chainId}`, + timestamp: ts, + durationMs: 0, + status: 200, + appName: 'vault', + txid, + chain: chain.symbol, + activityType, + }) + inserted++ + } + + console.log(`[activity] Scanned ${chain.symbol}: ${txs.length} txs, ${inserted} new`) + return { count: inserted } }, dismissActivity: async (_params) => { // No-op: api_log entries are audit records, not dismissible diff --git a/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx index cee1eb2..34ed5bf 100644 --- a/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx +++ b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx @@ -1,15 +1,16 @@ /** * ActivityPanel — drawer showing recent transaction activity across all chains. * - * Shows: txids, chain, type (send/swap/sign), timestamp, status. - * Click txid to copy or open in explorer. + * Network selector with chain icons + "Scan" button fetches history from Pioneer. + * Shows: txids, chain, type (send/receive/swap/sign), timestamp, status. */ -import { useState, useMemo } from "react" -import { Box, Text, Flex, VStack, HStack } from "@chakra-ui/react" +import { useState, useEffect, useMemo, useCallback } from "react" +import { Box, Text, Flex, VStack, HStack, Image } from "@chakra-ui/react" import { rpcRequest } from "../lib/rpc" import { Z } from "../lib/z-index" import { CHAINS } from "../../shared/chains" -import type { RecentActivity, PendingSwap } from "../../shared/types" +import { caipToIcon } from "../../shared/assetLookup" +import type { RecentActivity, PendingSwap, ChainBalance } from "../../shared/types" interface ActivityPanelProps { open: boolean @@ -25,7 +26,8 @@ CHAINS.forEach(c => { CHAIN_COLORS[c.symbol] = c.color; CHAIN_COLORS[c.id] = c.c // Type labels and colors const TYPE_CONFIG: Record = { - send: { label: 'Send', color: '#23DCC8' }, + send: { label: 'Sent', color: '#E53E3E' }, + receive: { label: 'Received', color: '#23DCC8' }, swap: { label: 'Swap', color: '#F7931A' }, sign: { label: 'Signed', color: '#627EEA' }, message: { label: 'Message', color: '#8247E5' }, @@ -38,9 +40,9 @@ const STATUS_CONFIG: Record = { failed: { label: 'Failed', color: '#E53E3E' }, } -function getExplorerUrl(chainId: string | undefined, txid: string): string | null { - if (!chainId || !txid) return null - const chain = CHAINS.find(c => c.id === chainId) +function getExplorerUrl(chainSymbol: string, txid: string): string | null { + if (!chainSymbol || !txid) return null + const chain = CHAINS.find(c => c.symbol === chainSymbol || c.id === chainSymbol) if (!chain?.explorerTxUrl) return null return chain.explorerTxUrl.replace('{{txid}}', txid) } @@ -67,11 +69,7 @@ function SwapRow({ swap }: { swap: PendingSwap }) { setTimeout(() => setCopied(false), 1500) } - const explorerUrl = (() => { - const chain = CHAINS.find(c => c.id === swap.fromChainId) - if (!chain?.explorerTxUrl) return null - return chain.explorerTxUrl.replace('{{txid}}', swap.txid) - })() + const explorerUrl = getExplorerUrl(swap.fromSymbol, swap.txid) const statusColor = swap.status === 'completed' ? '#23DCC8' : swap.status === 'failed' ? '#E53E3E' @@ -79,73 +77,27 @@ function SwapRow({ swap }: { swap: PendingSwap }) { : '#627EEA' return ( - + - - - {swap.fromSymbol} → {swap.toSymbol} - - - Swap - + + {swap.fromSymbol} → {swap.toSymbol} + Swap - - {swap.status} - + {swap.status} - {swap.fromAmount && ( - {swap.fromAmount} {swap.fromSymbol} - {swap.expectedOutput ? ` → ${swap.expectedOutput} ${swap.toSymbol}` : ''} + {swap.fromAmount} {swap.fromSymbol}{swap.expectedOutput ? ` → ${swap.expectedOutput} ${swap.toSymbol}` : ''} )} - - handleCopy(swap.txid)} - title={copied ? 'Copied!' : 'Click to copy'} - > + handleCopy(swap.txid)} title={copied ? 'Copied!' : 'Click to copy'}> {copied ? 'Copied!' : truncateTxid(swap.txid)} - {explorerUrl && ( - rpcRequest('openUrl', { url: explorerUrl })} - > - Explorer - - )} - - {timeAgo(swap.createdAt)} - + {explorerUrl && rpcRequest('openUrl', { url: explorerUrl })}>Explorer} + {timeAgo(swap.createdAt)} @@ -163,51 +115,21 @@ function ActivityRow({ activity }: { activity: RecentActivity }) { setTimeout(() => setCopied(false), 1500) } - const explorerUrl = activity.txid ? getExplorerUrl(activity.chainId, activity.txid) : null + const explorerUrl = activity.txid ? getExplorerUrl(activity.chain, activity.txid) : null return ( - + - - - {activity.chain} - - - {typeConf.label} - + + {activity.chain} + {typeConf.label} {activity.source === 'api' && ( - - API - + API )} - - {statusConf.label} - + {statusConf.label} - - {/* Details line */} {(activity.amount || activity.to || activity.appName) && ( {activity.amount && `${activity.amount} ${activity.asset || activity.chain}`} @@ -215,171 +137,218 @@ function ActivityRow({ activity }: { activity: RecentActivity }) { {activity.appName && activity.source === 'api' && ` via ${activity.appName}`} )} - - {/* TXID line */} {activity.txid ? ( - handleCopy(activity.txid!)} - title={copied ? 'Copied!' : 'Click to copy'} - > + handleCopy(activity.txid!)} title={copied ? 'Copied!' : 'Click to copy'}> {copied ? 'Copied!' : truncateTxid(activity.txid)} ) : ( - - no txid - + no txid )} - {explorerUrl && ( - rpcRequest('openUrl', { url: explorerUrl })} - > - Explorer - - )} - - {timeAgo(activity.createdAt)} - + {explorerUrl && rpcRequest('openUrl', { url: explorerUrl })}>Explorer} + {timeAgo(activity.createdAt)} ) } +/** Network selector pill */ +function ChainPill({ chain, selected, onClick }: { chain: { id: string; symbol: string; caip: string; color: string }; selected: boolean; onClick: () => void }) { + return ( + + } /> + {chain.symbol} + + ) +} + export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefresh }: ActivityPanelProps) { - const [tab, setTab] = useState<'all' | 'swaps'>('all') + const [tab, setTab] = useState<'activity' | 'swaps'>('activity') + const [selectedChain, setSelectedChain] = useState(null) + const [scanning, setScanning] = useState(false) + const [scanResult, setScanResult] = useState(null) + const [availableChains, setAvailableChains] = useState([]) + + // Load chains that have balances (these are the ones worth scanning) + useEffect(() => { + if (!open) return + rpcRequest<{ balances: ChainBalance[]; updatedAt: number } | null>('getCachedBalances') + .then(result => { + if (result?.balances) setAvailableChains(result.balances) + }) + .catch(() => {}) + }, [open]) + + // Map available chains to their CHAINS config for icons/colors + const chainOptions = useMemo(() => { + return availableChains + .map(b => { + const def = CHAINS.find(c => c.id === b.chainId) + if (!def) return null + return { id: def.id, symbol: def.symbol, caip: def.caip, color: def.color, balanceUsd: b.balanceUsd } + }) + .filter((c): c is NonNullable => c !== null) + .sort((a, b) => b.balanceUsd - a.balanceUsd) + }, [availableChains]) + + // Filtered activities by selected chain + const filteredActivities = useMemo(() => { + if (!selectedChain) return activities + const chainDef = CHAINS.find(c => c.id === selectedChain) + if (!chainDef) return activities + return activities.filter(a => a.chain === chainDef.symbol) + }, [activities, selectedChain]) - // Merge activities + active swaps into unified timeline + // Filtered swaps const activeSwaps = useMemo(() => pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded'), [pendingSwaps] ) - // Filter out swap-type activities that duplicate pending swaps + // Dedupe swaps from activity list const nonSwapActivities = useMemo(() => { const swapTxids = new Set(pendingSwaps.map(s => s.txid)) - return activities.filter(a => !(a.type === 'swap' && a.txid && swapTxids.has(a.txid))) - }, [activities, pendingSwaps]) + return filteredActivities.filter(a => !(a.type === 'swap' && a.txid && swapTxids.has(a.txid))) + }, [filteredActivities, pendingSwaps]) - const handleClearAll = () => { - rpcRequest('clearRecentActivity').then(onRefresh).catch(() => {}) - } + const handleScan = useCallback(async () => { + if (!selectedChain || scanning) return + setScanning(true) + setScanResult(null) + try { + const result = await rpcRequest<{ count: number }>('scanChainHistory', { chainId: selectedChain }, 60000) + const chainDef = CHAINS.find(c => c.id === selectedChain) + setScanResult(result.count > 0 ? `Found ${result.count} new tx${result.count > 1 ? 's' : ''}` : `No new transactions for ${chainDef?.symbol || selectedChain}`) + onRefresh() + } catch (e: any) { + setScanResult(`Scan failed: ${e.message || 'unknown error'}`) + } finally { + setScanning(false) + } + }, [selectedChain, scanning, onRefresh]) - const handleDismiss = (id: string) => { - rpcRequest('dismissActivity', { id }).then(onRefresh).catch(() => {}) - } + // Clear scan result when chain changes + useEffect(() => { setScanResult(null) }, [selectedChain]) if (!open) return null return ( <> {/* Backdrop */} - + {/* Panel */} {/* Header */} Recent Activity - - Refresh - - {activities.length > 0 && ( - - Clear - - )} - - × - + × {/* Tabs */} setTab('all')} + as="button" fontSize="xs" + fontWeight={tab === 'activity' ? '700' : '500'} + color={tab === 'activity' ? '#23DCC8' : 'whiteAlpha.500'} + borderBottom={tab === 'activity' ? '2px solid #23DCC8' : '2px solid transparent'} + pb="1" onClick={() => setTab('activity')} > - All ({nonSwapActivities.length + activeSwaps.length}) + History {pendingSwaps.length > 0 && ( setTab('swaps')} + pb="1" onClick={() => setTab('swaps')} > Swaps ({pendingSwaps.length}) )} + {/* Network selector (activity tab only) */} + {tab === 'activity' && ( + + + setSelectedChain(null)} + /> + {chainOptions.map(c => ( + setSelectedChain(c.id)} /> + ))} + + + {/* Scan button — only when a specific chain is selected */} + {selectedChain && ( + + + {scanning ? 'Scanning...' : `Scan ${CHAINS.find(c => c.id === selectedChain)?.symbol || ''} History`} + + {scanResult && ( + + {scanResult} + + )} + + )} + + )} + {/* Content */} - {tab === 'all' && ( + {tab === 'activity' && ( <> - {/* Active swaps first */} - {activeSwaps.map(swap => ( + {activeSwaps.filter(s => !selectedChain || CHAINS.find(c => c.id === selectedChain)?.symbol === s.fromSymbol).map(swap => ( ))} - {/* Then recent activities (sorted by time, newest first) */} {nonSwapActivities.map(activity => ( ))} {nonSwapActivities.length === 0 && activeSwaps.length === 0 && ( - No recent activity + {selectedChain ? `No activity for ${CHAINS.find(c => c.id === selectedChain)?.symbol || selectedChain} — try scanning` : 'No recent activity'} )} @@ -390,9 +359,7 @@ export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefre ))} {pendingSwaps.length === 0 && ( - - No swaps - + No swaps )} )} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 73597e3..df4cb24 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -153,7 +153,8 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { exportSwapReport: { params: { fromDate?: number; toDate?: number; format: 'pdf' | 'csv' }; response: { filePath: string } } // ── Recent Activity ────────────────────────────────────────────────── - getRecentActivity: { params: { limit?: number } | void; response: RecentActivity[] } + getRecentActivity: { params: { limit?: number; chainId?: string } | void; response: RecentActivity[] } + scanChainHistory: { params: { chainId: string }; response: { count: number } } dismissActivity: { params: { id: string }; response: void } clearRecentActivity: { params: void; response: void } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index fab649f..51d1c95 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -537,7 +537,7 @@ export interface SwapHistoryStats { // ── Recent Activity types ────────────────────────────────────────────── -export type ActivityType = 'send' | 'swap' | 'sign' | 'message' | 'approve' +export type ActivityType = 'send' | 'receive' | 'swap' | 'sign' | 'message' | 'approve' export type ActivitySource = 'app' | 'api' export interface RecentActivity { From c051beda4c97fa70d047c6642be9e825309621f7 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 13 Mar 2026 21:25:55 -0600 Subject: [PATCH 03/11] fix: replace chain pills with forced select dropdown + refresh icon - Remove "All" option and horizontal pill selector - Single with custom dropdown showing chain icons (native