From ba9ab47dad91d270b8ae39c410f72e4112660352 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 15 Mar 2026 02:13:32 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20SPL=20token=20support=20=E2=80=94?= =?UTF-8?q?=20display,=20send,=20and=20build=20via=20Pioneer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix case-insensitive networkId lookup (Pioneer returns lowercase CAIP hashes) - Broaden contract address regex to match spl:/token:/trc20: (was erc20-only) - Fix isTokenSend in SendForm to recognize SPL/TRC20 token CAIPs - Add SPL token build path in txbuilder routing to Pioneer build-transfer-token - Handle MAX send for SPL tokens using frontend tokenBalance --- projects/keepkey-vault/src/bun/index.ts | 119 +++++++++++++++--- .../keepkey-vault/src/bun/txbuilder/index.ts | 105 +++++++++++----- .../src/mainview/components/SendForm.tsx | 2 +- 3 files changed, 179 insertions(+), 47 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index adbc6ff..5b404cd 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, updateCachedBalance, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, apiLogTxidExists, updateApiLogTxMeta } 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, updateCachedBalance, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, apiLogTxidExists, updateApiLogTxMeta, getPioneerServers, addPioneerServerDb, removePioneerServerDb } from "./db" import { generateReport, reportToPdfBuffer } from "./reports" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import * as os from "os" @@ -183,9 +183,15 @@ let appVersionCache = '' let restServer: ReturnType | null = null function getAppSettings() { + const servers = getPioneerServers() + const activeBase = getPioneerApiBase() + // If the active URL matches a server in the list, use it; otherwise fall back to the first server + const activePioneerServer = servers.find(s => s.url === activeBase)?.url || servers[0]?.url || activeBase return { restApiEnabled, - pioneerApiBase: getPioneerApiBase(), + pioneerApiBase: activeBase, + pioneerServers: servers, + activePioneerServer, fiatCurrency: getSetting('fiat_currency') || 'USD', numberLocale: getSetting('number_locale') || 'en-US', swapsEnabled, @@ -648,10 +654,10 @@ const rpc = BrowserView.defineRPC({ console.log(`[getBalances] ${pubkeys.length} pubkeys (${btcPubkeyEntries.length} BTC xpubs) → single GetPortfolioBalances call`) - // Build networkId → chainId lookup for token grouping + // Build networkId → chainId lookup for token grouping (lowercase keys — Pioneer may return different casing) const networkToChain = new Map() for (const chain of allChains) { - if (chain.networkId) networkToChain.set(chain.networkId, chain.id) + if (chain.networkId) networkToChain.set(chain.networkId.toLowerCase(), chain.id) } // 3. Single API call — GetPortfolioBalances returns natives + tokens in one flat array @@ -659,9 +665,10 @@ const rpc = BrowserView.defineRPC({ try { if (!pioneer) throw new Error('Pioneer client not available') const resp = await withTimeout( - pioneer.GetPortfolioBalances({ - pubkeys: pubkeys.map(p => ({ caip: p.caip, pubkey: p.pubkey })) - }), + pioneer.GetPortfolioBalances( + { pubkeys: pubkeys.map(p => ({ caip: p.caip, pubkey: p.pubkey })) }, + { forceRefresh: true } + ), PIONEER_TIMEOUT_MS, 'GetPortfolioBalances' ) @@ -702,6 +709,11 @@ const rpc = BrowserView.defineRPC({ console.log(`[getBalances] After classification: ${pureNatives.length} natives, ${tokenEntries.length} tokens`) + // Log Solana-specific entries for debugging + const solanaEntries = allEntries.filter((d: any) => d.caip?.includes('solana') || d.networkId?.includes('solana')) + console.log(`[getBalances] Solana entries from Pioneer: ${solanaEntries.length}`) + for (const s of solanaEntries) console.log(` SOL: caip=${s.caip}, type=${s.type}, symbol=${s.symbol}, balance=${s.balance}, usd=${s.valueUsd}, networkId=${s.networkId}, contract=${s.contract}`) + // Group tokens by their parent chain (via networkId or CAIP prefix) // Also log the networkToChain map so we can audit matching console.log(`[getBalances] networkToChain map (${networkToChain.size} entries): ${JSON.stringify(Object.fromEntries(networkToChain))}`) @@ -712,9 +724,9 @@ const rpc = BrowserView.defineRPC({ const bal = parseFloat(String(tok.balance ?? '0')) if (bal <= 0) { tokensSkippedZero++; continue } - // Determine parent chainId from networkId or CAIP-2 prefix - const tokNetworkId = tok.networkId || '' - const caipPrefix = (tok.caip || '').split('/')[0] // e.g. "eip155:1" + // Determine parent chainId from networkId or CAIP-2 prefix (lowercase — Pioneer may return different casing) + const tokNetworkId = (tok.networkId || '').toLowerCase() + const caipPrefix = ((tok.caip || '').split('/')[0]).toLowerCase() // e.g. "eip155:1" const parentChainId = networkToChain.get(tokNetworkId) || networkToChain.get(caipPrefix) || null if (!parentChainId) { tokensSkippedNoChain++ @@ -722,16 +734,30 @@ const rpc = BrowserView.defineRPC({ continue } - // Extract contract address from CAIP: "eip155:1/erc20:0xdac17..." → "0xdac17..." - const contractMatch = (tok.caip || '').match(/\/erc20:(0x[a-fA-F0-9]+)/) - const contractAddress = contractMatch?.[1] || tok.contract || undefined + // Extract contract address from CAIP: + // ERC-20: "eip155:1/erc20:0xdac17..." → "0xdac17..." + // SPL: "solana:5eykt4.../spl:TokenMint..." → "TokenMint..." + // TRC-20: "tron:27Lqcw/trc20:T..." → "T..." + const contractMatch = (tok.caip || '').match(/\/(erc20|spl|trc20|token):([^\s]+)/) + const contractAddress = contractMatch?.[2] || tok.contract || undefined + + const rawValueUsd = tok.valueUsd + const rawPriceUsd = tok.priceUsd + const parsedBalanceUsd = Number(rawValueUsd ?? 0) + const parsedPriceUsd = Number(rawPriceUsd ?? 0) + + // DEBUG: log first 5 tokens per chain to verify USD parsing + const existingCount = (tokensByChainId.get(parentChainId) || []).length + if (existingCount < 5) { + console.log(`[token-usd-debug] ${tok.symbol}: raw valueUsd=${JSON.stringify(rawValueUsd)} (${typeof rawValueUsd}) → parsed=${parsedBalanceUsd} | raw priceUsd=${JSON.stringify(rawPriceUsd)} → parsed=${parsedPriceUsd}`) + } const token: TokenBalance = { symbol: tok.symbol || '???', name: tok.name || tok.symbol || 'Unknown Token', balance: String(tok.balance ?? '0'), - balanceUsd: Number(tok.valueUsd ?? 0), - priceUsd: Number(tok.priceUsd ?? 0), + balanceUsd: parsedBalanceUsd, + priceUsd: parsedPriceUsd, caip: tok.caip || '', contractAddress, networkId: tokNetworkId || caipPrefix, @@ -748,7 +774,8 @@ const rpc = BrowserView.defineRPC({ console.log(`[getBalances] Token grouping: ${tokensGrouped} grouped, ${tokensSkippedZero} skipped (zero bal), ${tokensSkippedNoChain} DROPPED (no parent chain)`) for (const [chainId, toks] of tokensByChainId) { - console.log(`[getBalances] ${chainId}: ${toks.length} tokens, $${toks.reduce((s, t) => s + t.balanceUsd, 0).toFixed(2)} — ${toks.map(t => t.symbol).join(', ')}`) + const topTokens = toks.slice(0, 5).map(t => `${t.symbol}=$${t.balanceUsd.toFixed(2)}(p=$${(t.priceUsd ?? 0).toFixed(4)})`).join(', ') + console.log(`[getBalances] ${chainId}: ${toks.length} tokens, $${toks.reduce((s, t) => s + t.balanceUsd, 0).toFixed(2)} — ${topTokens}`) } // Merge user-added custom tokens as placeholders @@ -940,7 +967,7 @@ const rpc = BrowserView.defineRPC({ // Single portfolio call let balance = '0', balanceUsd = 0 try { - const resp = await withTimeout(pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey }] }), PIONEER_TIMEOUT_MS, 'GetPortfolioBalances') + const resp = await withTimeout(pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey }] }, { forceRefresh: true }), PIONEER_TIMEOUT_MS, 'GetPortfolioBalances') const match = (resp?.data?.balances || [])[0] if (match) { balance = String(match.balance ?? '0'); balanceUsd = Number(match.valueUsd ?? 0) } } catch (e: any) { @@ -1437,6 +1464,64 @@ const rpc = BrowserView.defineRPC({ console.log('[settings] BIP-85 enabled:', params.enabled) return getAppSettings() }, + addPioneerServer: async (params) => { + const url = (params.url || '').trim().replace(/\/+$/, '') + const label = (params.label || '').trim() + if (!url || !/^https?:\/\//i.test(url)) throw new Error('URL must start with http:// or https://') + if (!label) throw new Error('Label is required') + // Health-check the server before adding + try { + const resp = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(10000) }) + if (!resp.ok) throw new Error(`Server returned ${resp.status}`) + } catch (e: any) { + throw new Error(`Server health check failed: ${e.message}`) + } + addPioneerServerDb(url, label) + console.log('[settings] Pioneer server added:', label, url) + return getAppSettings() + }, + removePioneerServer: async (params) => { + const url = (params.url || '').trim() + if (!url) throw new Error('URL is required') + removePioneerServerDb(url) + // If the removed server was the active one, reset to default + const currentBase = getPioneerApiBase() + if (currentBase === url) { + setSetting('pioneer_api_base', '') + resetPioneer() + chainCatalog = [] + catalogLoadedAt = 0 + console.log('[settings] Active server removed, reset to default') + } + console.log('[settings] Pioneer server removed:', url) + return getAppSettings() + }, + setActivePioneerServer: async (params) => { + const url = (params.url || '').trim().replace(/\/+$/, '') + if (!url) throw new Error('URL is required') + // Verify the server exists in our list + const servers = getPioneerServers() + if (!servers.find(s => s.url === url)) throw new Error('Server not found in saved list') + // Health-check before switching + try { + const resp = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(10000) }) + if (!resp.ok) throw new Error(`Server returned ${resp.status}`) + } catch (e: any) { + throw new Error(`Server health check failed: ${e.message}`) + } + // Find the default server — if switching to default, clear the override + const defaultServer = servers.find(s => s.isDefault) + if (defaultServer && defaultServer.url === url) { + setSetting('pioneer_api_base', '') + } else { + setSetting('pioneer_api_base', url) + } + resetPioneer() + chainCatalog = [] + catalogLoadedAt = 0 + console.log('[settings] Active Pioneer server set to:', url) + return getAppSettings() + }, // ── API Audit Log ──────────────────────────────────────── getApiLogs: async (params) => { diff --git a/projects/keepkey-vault/src/bun/txbuilder/index.ts b/projects/keepkey-vault/src/bun/txbuilder/index.ts index ed7fef3..ca8ed45 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/index.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/index.ts @@ -89,38 +89,85 @@ export async function buildTx( // Solana — Pioneer builds the raw tx (with dummy signature header), device signs if (!params.fromAddress) throw new Error('fromAddress required for Solana') - // Convert SOL amount to lamports (9 decimals) - const solAmountLamports = (() => { - const parts = params.amount.split('.') - const whole = parts[0] || '0' - const frac = (parts[1] || '').slice(0, 9).padEnd(9, '0') - return String(BigInt(whole) * 1000000000n + BigInt(frac)) - })() + // Detect SPL token send from CAIP: "solana:.../token:MintAddress" or "solana:.../spl:MintAddress" + const splMintMatch = params.caip?.match(/\/(token|spl):([A-Za-z0-9]+)/) + const isSplToken = !!splMintMatch + const splMintAddress = splMintMatch?.[2] let rawTx: string - try { - // Direct HTTP call to Pioneer — do NOT use pioneer.BuildTransferN() as the - // swagger-codegen suffix is non-deterministic (shifts when new chains add - // endpoints with the same operationId "BuildTransfer"). - const base = getPioneerApiBase() - const resp = await fetch(`${base}/api/v1/solana/build-transfer`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from: params.fromAddress, - to: params.to, - amount: solAmountLamports, - memo: params.memo || undefined, - }), - }) - const data = await resp.json() as any - if (!resp.ok || data?.success === false) { - throw new Error(data?.error || data?.message || `HTTP ${resp.status}`) + const base = getPioneerApiBase() + + if (isSplToken && splMintAddress) { + // SPL token transfer — Pioneer builds the ATA-aware transfer instruction + const tokenDecimals = params.tokenDecimals ?? 6 // default to 6 (USDC) + // For MAX send, use frontend-provided tokenBalance (same pattern as EVM) + const sendAmount = params.isMax && params.tokenBalance && parseFloat(params.tokenBalance) > 0 + ? params.tokenBalance + : params.amount + if (params.isMax && (!sendAmount || parseFloat(sendAmount) <= 0)) { + throw new Error('Token balance is zero — cannot send max') + } + const tokenAmountBase = (() => { + const parts = sendAmount.split('.') + const whole = parts[0] || '0' + const frac = (parts[1] || '').slice(0, tokenDecimals).padEnd(tokenDecimals, '0') + return String(BigInt(whole) * BigInt(10 ** tokenDecimals) + BigInt(frac)) + })() + + try { + const resp = await fetch(`${base}/api/v1/solana/build-transfer-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from: params.fromAddress, + to: params.to, + amount: tokenAmountBase, + token: splMintAddress, + decimals: tokenDecimals, + memo: params.memo || undefined, + }), + }) + const data = await resp.json() as any + if (!resp.ok || data?.success === false) { + throw new Error(data?.error || data?.message || `HTTP ${resp.status}`) + } + rawTx = data?.serialized + if (!rawTx) throw new Error('Pioneer did not return serialized tx for SPL token transfer') + } catch (e: any) { + throw new Error(`SPL token tx build failed: ${e.message}`) + } + } else { + // Native SOL transfer — convert to lamports (9 decimals) + const solAmountLamports = (() => { + const parts = params.amount.split('.') + const whole = parts[0] || '0' + const frac = (parts[1] || '').slice(0, 9).padEnd(9, '0') + return String(BigInt(whole) * 1000000000n + BigInt(frac)) + })() + + try { + // Direct HTTP call to Pioneer — do NOT use pioneer.BuildTransferN() as the + // swagger-codegen suffix is non-deterministic (shifts when new chains add + // endpoints with the same operationId "BuildTransfer"). + const resp = await fetch(`${base}/api/v1/solana/build-transfer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from: params.fromAddress, + to: params.to, + amount: solAmountLamports, + memo: params.memo || undefined, + }), + }) + const data = await resp.json() as any + if (!resp.ok || data?.success === false) { + throw new Error(data?.error || data?.message || `HTTP ${resp.status}`) + } + rawTx = data?.serialized + if (!rawTx) throw new Error('Pioneer did not return serialized tx for Solana') + } catch (e: any) { + throw new Error(`Solana tx build failed: ${e.message}`) } - rawTx = data?.serialized - if (!rawTx) throw new Error('Pioneer did not return serialized tx for Solana') - } catch (e: any) { - throw new Error(`Solana tx build failed: ${e.message}`) } const unsignedTx = { diff --git a/projects/keepkey-vault/src/mainview/components/SendForm.tsx b/projects/keepkey-vault/src/mainview/components/SendForm.tsx index a07d1dd..4ba57da 100644 --- a/projects/keepkey-vault/src/mainview/components/SendForm.tsx +++ b/projects/keepkey-vault/src/mainview/components/SendForm.tsx @@ -76,7 +76,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve }, [tokenCaip]) // Derived display values — token mode vs native mode - const isTokenSend = !!(token && token.caip?.includes('erc20')) + const isTokenSend = !!(token && token.caip && !token.caip.endsWith('/slip44:501') && (token.caip.includes('erc20') || token.caip.includes('/token:') || token.caip.includes('/spl:') || token.caip.includes('/trc20:'))) const displaySymbol = isTokenSend ? token!.symbol : chain.symbol const displayBalance = isTokenSend ? token!.balance : (balance?.balance || '0') From ed55ede3a32be92cb07a79208b8b58cf4b0a8c17 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 15 Mar 2026 03:01:12 -0600 Subject: [PATCH 2/2] feat: Pioneer server management, spam filter refinements, forceRefresh - Add Pioneer server CRUD (SQLite table, RPC methods, settings UI support) - Refine spam filter: $5 value floor, only auto-hide confirmed spam, lower dust threshold from $1 to $0.01, remove false-positive rule - Disable spam filter in Dashboard/AssetPage (was hiding legitimate tokens) - Add forceRefresh to all GetPortfolioBalances calls - Add BIP-85 enabled flag to AppSettings --- projects/keepkey-vault/src/bun/db.ts | 60 ++++++++++++++++++- .../keepkey-vault/src/bun/evm-addresses.ts | 2 +- .../keepkey-vault/src/bun/txbuilder/cosmos.ts | 2 +- .../src/mainview/components/AssetPage.tsx | 27 ++------- .../src/mainview/components/Dashboard.tsx | 23 +++---- .../keepkey-vault/src/shared/rpc-schema.ts | 6 +- .../keepkey-vault/src/shared/spamFilter.ts | 46 ++++++++------ projects/keepkey-vault/src/shared/types.ts | 20 +++++-- 8 files changed, 126 insertions(+), 60 deletions(-) diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index 932ed53..974f453 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, ReportMeta, ReportData, SwapHistoryRecord, SwapHistoryFilter, SwapTrackingStatus, SwapHistoryStats, Bip85SeedMeta } from '../shared/types' +import type { ChainBalance, CustomToken, CustomChain, PairedAppInfo, ApiLogEntry, ReportMeta, ReportData, SwapHistoryRecord, SwapHistoryFilter, SwapTrackingStatus, SwapHistoryStats, Bip85SeedMeta, PioneerServer } from '../shared/types' const SCHEMA_VERSION = '8' @@ -208,6 +208,24 @@ export function initDb() { ) `) + db.exec(` + CREATE TABLE IF NOT EXISTS pioneer_servers ( + url TEXT PRIMARY KEY, + label TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ) + `) + + // Seed default Pioneer server if table is empty + const serverCount = db.query('SELECT COUNT(*) as c FROM pioneer_servers').get() as { c: number } | null + if (!serverCount || serverCount.c === 0) { + db.run( + 'INSERT INTO pioneer_servers (url, label, is_default, created_at) VALUES (?, ?, 1, ?)', + ['https://api.keepkey.info', 'KeepKey Official', Date.now()] + ) + } + // 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 */ } @@ -405,6 +423,46 @@ export function setSetting(key: string, value: string) { } } +// ── Pioneer Servers ────────────────────────────────────────────────── + +export function getPioneerServers(): PioneerServer[] { + try { + if (!db) return [] + const rows = db.query('SELECT url, label, is_default FROM pioneer_servers ORDER BY is_default DESC, created_at ASC').all() as Array<{ + url: string; label: string; is_default: number + }> + return rows.map(r => ({ url: r.url, label: r.label, isDefault: r.is_default === 1 })) + } catch (e: any) { + console.warn('[db] getPioneerServers failed:', e.message) + return [] + } +} + +export function addPioneerServerDb(url: string, label: string) { + try { + if (!db) return + db.run( + 'INSERT OR REPLACE INTO pioneer_servers (url, label, is_default, created_at) VALUES (?, ?, 0, ?)', + [url, label, Date.now()] + ) + } catch (e: any) { + console.warn('[db] addPioneerServer failed:', e.message) + } +} + +export function removePioneerServerDb(url: string) { + try { + if (!db) return + // Prevent removing the default server + const row = db.query('SELECT is_default FROM pioneer_servers WHERE url = ?').get(url) as { is_default: number } | null + if (row?.is_default === 1) throw new Error('Cannot remove the default server') + db.run('DELETE FROM pioneer_servers WHERE url = ?', [url]) + } catch (e: any) { + console.warn('[db] removePioneerServer failed:', e.message) + throw e + } +} + // ── Token Visibility (spam filter user overrides) ───────────────────── export type TokenVisibilityStatus = 'visible' | 'hidden' diff --git a/projects/keepkey-vault/src/bun/evm-addresses.ts b/projects/keepkey-vault/src/bun/evm-addresses.ts index ab49a16..d29394f 100644 --- a/projects/keepkey-vault/src/bun/evm-addresses.ts +++ b/projects/keepkey-vault/src/bun/evm-addresses.ts @@ -175,7 +175,7 @@ export class EvmAddressManager extends EventEmitter { // Check if any EVM chain has a balance for this address const pubkeys = evmChains.map(c => ({ caip: c.caip, pubkey: address })) try { - const resp = await pioneer.GetPortfolioBalances({ pubkeys }) + const resp = await pioneer.GetPortfolioBalances({ pubkeys }, { forceRefresh: true }) const balances = resp?.data?.balances || resp?.data || [] const hasBalance = (Array.isArray(balances) ? balances : []).some( (b: any) => parseFloat(String(b?.balance ?? '0')) > 0 || Number(b?.valueUsd ?? 0) > 0, diff --git a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts index bf9dd73..bce4159 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/cosmos.ts @@ -96,7 +96,7 @@ export async function buildCosmosTx( let baseAmount: bigint if (isMax) { - const balResp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: fromAddress }] }) + const balResp = await pioneer.GetPortfolioBalances({ pubkeys: [{ caip: chain.caip, pubkey: fromAddress }] }, { forceRefresh: true }) const balStr = String((balResp?.data?.balances || [])[0]?.balance ?? '0') const feeDisplay = FEES[chain.id] || 0 const balBase = toBaseUnits(balStr, chain.decimals) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 5778d6d..95a1897 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -194,34 +194,19 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage .catch(() => {}) }, []) - // Categorize tokens: clean (shown), spam (hidden by default), zeroValue (hidden by default) + // Spam filter OFF — show all tokens as clean const { cleanTokens, spamTokens, zeroValueTokens, spamResults } = useMemo(() => { - const clean: TokenBalance[] = [] - const spam: TokenBalance[] = [] - const zero: TokenBalance[] = [] const results = new Map() - for (const t of tokens) { - const override = visibilityMap[t.caip?.toLowerCase()] ?? null - const result = detectSpamToken(t, override) - results.set(t.caip, result) - - if (result.isSpam) { - spam.push(t) - } else if ((t.balanceUsd ?? 0) === 0) { - zero.push(t) - } else { - clean.push(t) - } + results.set(t.caip, { isSpam: false, level: null, reason: 'Filter disabled' }) } - return { - cleanTokens: clean.sort((a, b) => (b.balanceUsd || 0) - (a.balanceUsd || 0)), - spamTokens: spam.sort((a, b) => (b.balanceUsd || 0) - (a.balanceUsd || 0)), - zeroValueTokens: zero.sort((a, b) => a.symbol.localeCompare(b.symbol)), + cleanTokens: [...tokens].sort((a, b) => (b.balanceUsd || 0) - (a.balanceUsd || 0)), + spamTokens: [] as TokenBalance[], + zeroValueTokens: [] as TokenBalance[], spamResults: results, } - }, [tokens, visibilityMap]) + }, [tokens]) const hiddenCount = spamTokens.length + zeroValueTokens.length const tokenTotalUsd = useMemo(() => cleanTokens.reduce((sum, t) => sum + (t.balanceUsd || 0), 0), [cleanTokens]) diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index b1cf526..70362dc 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -172,6 +172,15 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings, firmwareVersion const tokenTotal = result.reduce((n, b) => n + (b.tokens?.length || 0), 0) const balTotal = result.reduce((n, b) => n + (b.balanceUsd || 0), 0) console.log(`[Dashboard] Live: ${result.length} chains, ${tokenTotal} tokens, $${balTotal.toFixed(2)}`) + // DEBUG: dump token data that arrived via RPC + for (const b of result) { + if (b.tokens && b.tokens.length > 0) { + console.log(`[Dashboard:rpc-tokens] ${b.chainId}: ${b.tokens.length} tokens`) + for (const t of b.tokens.slice(0, 3)) { + console.log(` ${t.symbol}: balanceUsd=${t.balanceUsd}, priceUsd=${t.priceUsd}, balance=${t.balance}, caip=${t.caip?.substring(0, 40)}`) + } + } + } const map = new Map() for (const b of result) map.set(b.chainId, b) setBalances(map) @@ -219,22 +228,14 @@ export function Dashboard({ onLoaded, watchOnly, onOpenSettings, firmwareVersion }) }, []) - // Compute spam-filtered USD per chain: subtract spam token values from chain totals + // Token count per chain — spam filter OFF for now const cleanBalanceUsd = useMemo(() => { - const overrides = new Map(Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v])) const result = new Map() for (const [chainId, bal] of balances) { - if (!bal.tokens || bal.tokens.length === 0) { - result.set(chainId, { usd: bal.balanceUsd || 0, cleanTokenCount: 0 }) - continue - } - const { spam } = categorizeTokens(bal.tokens, overrides) - const spamUsd = spam.reduce((s, t) => s + (t.balanceUsd || 0), 0) - const cleanTokens = (bal.tokens?.length || 0) - spam.length - result.set(chainId, { usd: (bal.balanceUsd || 0) - spamUsd, cleanTokenCount: cleanTokens }) + result.set(chainId, { usd: bal.balanceUsd || 0, cleanTokenCount: bal.tokens?.length || 0 }) } return result - }, [balances, visibilityMap]) + }, [balances]) const totalUsd = useMemo(() => Array.from(cleanBalanceUsd.values()).reduce((sum, b) => sum + b.usd, 0), [cleanBalanceUsd]) diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 0259466..ba9e714 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, RecentActivity } from './types' +import type { DeviceStateInfo, FirmwareProgress, FirmwareAnalysis, PinRequest, CharacterRequest, ChainBalance, BuildTxParams, BuildTxResult, BroadcastResult, BtcAccountSet, BtcScriptType, EvmAddressSet, CustomToken, CustomChain, AppSettings, PioneerServer, 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. @@ -132,6 +132,10 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { setFiatCurrency: { params: { currency: string }; response: AppSettings } setNumberLocale: { params: { locale: string }; response: AppSettings } setSwapsEnabled: { params: { enabled: boolean }; response: AppSettings } + setBip85Enabled: { params: { enabled: boolean }; response: AppSettings } + addPioneerServer: { params: { url: string; label: string }; response: AppSettings } + removePioneerServer: { params: { url: string }; response: AppSettings } + setActivePioneerServer: { params: { url: string }; response: AppSettings } // ── Reports ────────────────────────────────────────────────────── generateReport: { params: void; response: ReportMeta } diff --git a/projects/keepkey-vault/src/shared/spamFilter.ts b/projects/keepkey-vault/src/shared/spamFilter.ts index 545dc6d..77d879b 100644 --- a/projects/keepkey-vault/src/shared/spamFilter.ts +++ b/projects/keepkey-vault/src/shared/spamFilter.ts @@ -3,12 +3,13 @@ * * Detection order (first match wins): * 1. User override (SQLite-persisted 'visible'/'hidden') — absolute precedence - * 2. Name/symbol contains URL or phishing keywords → CONFIRMED spam - * 3. Symbol has suspicious characters or excessive length → CONFIRMED spam - * 4. Known stablecoin symbol with value < $0.50 → CONFIRMED spam - * 5. Dust airdrop: huge quantity (>1M) + near-zero unit price (<$0.0001) → CONFIRMED spam - * 6. Value < $1 → POSSIBLE spam - * 7. Otherwise → clean + * 2. Value floor: tokens worth >= $5 skip heuristic tiers 3-6 (real value = not spam) + * 3. Name/symbol contains URL or phishing keywords → CONFIRMED spam + * 4. Symbol has suspicious characters or excessive length → CONFIRMED spam + * 5. Known stablecoin symbol with value < $0.50 → CONFIRMED spam + * 6. Dust airdrop: huge quantity (>1M) + near-zero unit price + low value → CONFIRMED spam + * 7. Value < $0.01 → POSSIBLE spam (only "possible" — not auto-hidden) + * 8. Otherwise → clean */ import type { TokenBalance } from './types' @@ -49,6 +50,9 @@ export interface SpamResult { // ── Heuristic helpers ──────────────────────────────────────────────── +/** Tokens worth at least this much skip heuristic spam checks (tiers 3-6) */ +const VALUE_FLOOR_USD = 5 + /** URL-like patterns in name or symbol — nearly always phishing */ const URL_PATTERN = /(?:\.[a-z]{2,6}(?:\/|$))|https?:|www\./i @@ -84,6 +88,7 @@ export function detectSpamToken( const name = token.name || '' // ── Tier 1: Name/symbol contains URL → CONFIRMED spam ──────────── + // (Always check regardless of value — phishing tokens can be high-value) if (URL_PATTERN.test(name) || URL_PATTERN.test(token.symbol || '')) { return { isSpam: true, @@ -101,6 +106,15 @@ export function detectSpamToken( } } + // ── Value floor: tokens worth >= $5 are not spam ───────────────── + // A token with real USD value is not a dust airdrop or worthless spam. + // Pioneer may return priceUsd: "0.00" for LP tokens where per-unit + // price rounds to zero, but the total valueUsd is significant. + // Skip all remaining heuristic tiers for tokens with real value. + if (usd >= VALUE_FLOOR_USD) { + return { isSpam: false, level: null, reason: `Value $${usd.toFixed(2)} above floor` } + } + // ── Tier 3: Suspicious symbol characters or length → CONFIRMED ─── if (SUSPICIOUS_SYMBOL_CHARS.test(token.symbol || '') || (token.symbol || '').length > MAX_SYMBOL_LENGTH) { return { @@ -133,23 +147,14 @@ export function detectSpamToken( reason: `Dust airdrop — ${qty.toLocaleString()} units at $${price.toFixed(8)}/unit`, } } - - // Moderate quantity + zero price but somehow has USD value (manipulated) - if (qty > 10_000 && price === 0 && usd > 0) { - return { - isSpam: true, - level: 'confirmed', - reason: `Suspicious — large quantity with $0 price but non-zero value`, - } - } } - // ── Tier 6: Low value → POSSIBLE spam ──────────────────────────── - if (usd < 1) { + // ── Tier 6: Near-zero value → POSSIBLE spam ────────────────────── + if (usd < 0.01) { return { isSpam: true, level: 'possible', - reason: `Low value ($${usd.toFixed(4)}) — common airdrop spam pattern`, + reason: `Near-zero value ($${usd.toFixed(4)})`, } } @@ -163,6 +168,9 @@ export function detectSpamToken( * @param tokens - token array from ChainBalance.tokens * @param overrides - Map from DB * @returns { clean, spam, zeroValue } — mutually exclusive buckets + * + * Only "confirmed" spam is auto-hidden. "Possible" spam stays visible + * so the user can decide (and mark hidden via token_visibility if desired). */ export function categorizeTokens( tokens: TokenBalance[], @@ -176,7 +184,7 @@ export function categorizeTokens( const override = overrides?.get(t.caip?.toLowerCase()) ?? null const result = detectSpamToken(t, override) - if (result.isSpam) { + if (result.isSpam && result.level === 'confirmed') { spam.push(t) } else if ((t.balanceUsd ?? 0) === 0) { zeroValue.push(t) diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 4b4c798..2f5473b 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -274,13 +274,23 @@ export interface ApiLogEntry { // Supported fiat currencies export type FiatCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'CHF' | 'CAD' | 'AUD' | 'CNY' | 'KRW' | 'BRL' | 'RUB' | 'INR' | 'MXN' | 'SEK' | 'NOK' | 'DKK' | 'PLN' | 'CZK' | 'HUF' | 'TRY' +// Pioneer API server entry (persisted in SQLite) +export interface PioneerServer { + url: string + label: string + isDefault: boolean +} + // Application-level settings (persisted in SQLite) export interface AppSettings { - restApiEnabled: boolean // controls entire REST API server on/off - pioneerApiBase: string // current Pioneer API base URL - fiatCurrency: FiatCurrency // display currency (default 'USD') - numberLocale: string // number formatting locale (default 'en-US') - swapsEnabled: boolean // feature flag: cross-chain swaps (default OFF) + restApiEnabled: boolean // controls entire REST API server on/off + pioneerApiBase: string // current Pioneer API base URL + pioneerServers: PioneerServer[] // all configured Pioneer servers + activePioneerServer: string // URL of the active server + fiatCurrency: FiatCurrency // display currency (default 'USD') + numberLocale: string // number formatting locale (default 'en-US') + swapsEnabled: boolean // feature flag: cross-chain swaps (default OFF) + bip85Enabled: boolean // feature flag: BIP-85 derived seeds (default OFF) } // ── RPC param/response types for top-use endpoints ──────────────────────