Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion projects/keepkey-vault/src/bun/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 */ }
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion projects/keepkey-vault/src/bun/evm-addresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
119 changes: 102 additions & 17 deletions projects/keepkey-vault/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -183,9 +183,15 @@ let appVersionCache = ''
let restServer: ReturnType<typeof startRestApi> | 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,
Expand Down Expand Up @@ -648,20 +654,21 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({

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<string, string>()
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
const results: ChainBalance[] = []
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'
)
Expand Down Expand Up @@ -702,6 +709,11 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({

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))}`)
Expand All @@ -712,26 +724,40 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
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++
console.warn(`[getBalances] Token DROPPED (no parent chain): ${tok.symbol} caip=${tok.caip} networkId=${tokNetworkId} caipPrefix=${caipPrefix} bal=${bal} usd=${tok.valueUsd}`)
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,
Expand All @@ -748,7 +774,8 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({

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
Expand Down Expand Up @@ -940,7 +967,7 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
// 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) {
Expand Down Expand Up @@ -1437,6 +1464,64 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
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) => {
Expand Down
2 changes: 1 addition & 1 deletion projects/keepkey-vault/src/bun/txbuilder/cosmos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading