From b5f4a80f5cffb30aaa31da31aa6441b617ddf89b Mon Sep 17 00:00:00 2001 From: TZGyn Date: Mon, 25 May 2026 23:46:36 +0800 Subject: [PATCH] Refactor: split RPC handlers into rpc/evm and rpc/monero; split RPC types into GeneralRpcRequests, EvmRpcRequests, MoneroRpcRequests --- apps/desktop/src/bun/index.ts | 893 +++-------------------- apps/desktop/src/bun/rpc/evm/index.ts | 570 +++++++++++++++ apps/desktop/src/bun/rpc/monero/index.ts | 235 ++++++ apps/desktop/src/lib/rpc-schema.ts | 82 ++- 4 files changed, 932 insertions(+), 848 deletions(-) create mode 100644 apps/desktop/src/bun/rpc/evm/index.ts create mode 100644 apps/desktop/src/bun/rpc/monero/index.ts diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index e664ddd..dcdbc5e 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -6,45 +6,17 @@ import { Updater, Utils, } from 'electrobun/bun' -import { getTokenMetadata } from './lib/tokens' -import { getTokensBalances, type Transaction } from './lib/alchemy' -import { tryCatch } from '@koins/utils' -import { - getTransactionHistory, - getTransactionDetails, - getCachedTransactionHistory, -} from './lib/transactions' -import { syncTransactionHistory } from './lib/sync' import { migrate } from 'drizzle-orm/bun-sqlite/migrator' -import { eq, and } from 'drizzle-orm' import { db } from './lib/db' import { join } from 'path' import { getENV } from './lib/get-env' -import { getClient } from './lib/viem' -import { formatGwei } from 'viem' -import { - isBinaryInstalled, - downloadBinary, - getBinaryPath, - getBinaryDir, - MoneroWalletManager, -} from './lib/monero' +import { createEvmHandlers } from './rpc/evm' +import { createMoneroHandlers } from './rpc/monero' +import { MoneroWalletManager } from './lib/monero' import { canPromptTouchID, promptTouchID } from './lib/biometric' import { sqlite } from './lib/sqlite' -import { - tokenMetadata, - transactions, - transactionReceipts, - txHistory, - evmWallets, -} from './lib/db/schema' -import { syncTxDetails } from './lib/sync/sync-tx-details' -import type { - RPC, - TokenBalanceResult, - TokenPriceEntry, - TxEntry, -} from '../lib/rpc-schema' +import { evmWallets } from './lib/db/schema' +import type { RPC } from '../lib/rpc-schema' const env = getENV() @@ -104,794 +76,93 @@ ApplicationMenu.setApplicationMenu([ const url = await getMainViewUrl() -const ALCHEMY_NETWORKS: Record = { - '1': 'eth-mainnet', - '137': 'polygon-mainnet', - '56': 'bnb-mainnet', +const moneroState = { + manager: null as MoneroWalletManager | null, + downloading: false, } - -const NATIVE_SYMBOLS: Record = { - '1': 'ETH', - '137': 'POL', - '56': 'BNB', +const syncState = { + target: null as { address: string; chainid: string } | null, + timer: null as ReturnType | null, } -async function fetchAlchemyTransfers( - key: string, - chainid: string, - address: string, -) { - const transactions = await getTransactionHistory( - key, - chainid, - address, - { maxCount: 10 }, - ) - - if (!transactions) return [] - - return transactions -} - -function formatUnits(balance: string, decimals: number): string { - const bal = BigInt(balance) - if (bal === 0n) return '0' - const divisor = 10n ** BigInt(decimals) - const intPart = bal / divisor - const fracPart = (bal % divisor) - .toString() - .padStart(decimals, '0') - .replace(/0+$/, '') - return fracPart ? `${intPart}.${fracPart}` : `${intPart}` -} - -async function fetchAlchemyTokenBalances( - key: string, - chainid: string, - address: string, -): Promise { - const [balRes, balResError] = await tryCatch( - getTokensBalances(key, chainid, address), - ) - - if (balResError) { - console.log('Get Tokens Balance Error', balResError) - return [] - } - - if (!balRes) return [] - - const tokens = balRes.result?.tokenBalances ?? [] - const nonZero = tokens.filter((t) => t.tokenBalance !== '0x0') - - const metas = await Promise.allSettled( - nonZero.map((t) => - getTokenMetadata(key, chainid, t.contractAddress), - ), - ) - if (!metas) return [] - - return nonZero - .map((t: any, i: number) => { - const meta = - metas[i].status === 'fulfilled' && metas[i].value - ? metas[i].value.result - : null - return { - symbol: meta?.symbol ?? 'Unknown', - decimals: meta?.decimals ?? 0, - balance: formatUnits(t.tokenBalance, meta?.decimals ?? 18), - contractAddress: t.contractAddress, - logo: meta?.logo ?? undefined, - } - }) - .filter((t) => t.logo && t.decimals !== 0) -} - -function mapTransfer(t: any): TxEntry { - const isExternal = t.category === 'external' - return { - hash: t.hash, - timeStamp: String( - Math.floor( - new Date(t.metadata?.blockTimestamp).getTime() / 1000, - ), - ), - from: t.from, - to: t.to, - value: String(t.value), - ...(isExternal - ? {} - : { - tokenSymbol: t.asset, - tokenDecimal: t.rawContract?.decimal - ? String(parseInt(t.rawContract.decimal, 16)) - : undefined, - contractAddress: t.rawContract?.address ?? undefined, - }), - } -} - -async function pairTransfers( - transfers: any[], - address: string, - key?: string, - chainid?: string, -): Promise { - const addrLower = address.toLowerCase() - const byHash = new Map() - for (const t of transfers) { - if (!t.hash) continue - const h = t.hash - if (!byHash.has(h)) byHash.set(h, []) - byHash.get(h)!.push(t) - } - - const combined: TxEntry[] = [] - - for (const [, group] of byHash) { - const native = group.filter( - (t: any) => - t.category === 'external' || t.category === 'internal', - ) - const tokens = group.filter((t: any) => t.category === 'erc20') - - const extOut = native.filter( - (t: any) => t.from?.toLowerCase() === addrLower, - ) - const extIn = native.filter( - (t: any) => t.to?.toLowerCase() === addrLower, - ) - const tokIn = tokens.filter( - (t: any) => t.to?.toLowerCase() === addrLower, - ) - const tokOut = tokens.filter( - (t: any) => t.from?.toLowerCase() === addrLower, - ) - - if (extOut.length > 0 && tokIn.length > 0) { - combined.push({ - ...mapTransfer(extOut[0]), - tokenSymbol: tokIn[0].asset, - tokenDecimal: tokIn[0].rawContract?.decimal - ? String(parseInt(tokIn[0].rawContract.decimal, 16)) - : undefined, - contractAddress: tokIn[0].rawContract?.address ?? undefined, - pairedValue: String(tokIn[0].value), - pairedSymbol: tokIn[0].asset, - pairedDecimals: tokIn[0].rawContract?.decimal - ? String(parseInt(tokIn[0].rawContract.decimal, 16)) - : undefined, - pairedContractAddress: - tokIn[0].rawContract?.address ?? undefined, - }) - } else if (extOut.length > 0) { - combined.push(mapTransfer(extOut[0])) - } else if (extIn.length > 0) { - combined.push(mapTransfer(extIn[0])) - } else if (tokOut.length > 0 && tokIn.length > 0) { - combined.push({ - ...mapTransfer(tokOut[0]), - pairedValue: String(tokIn[0].value), - pairedSymbol: tokIn[0].asset, - pairedDecimals: tokIn[0].rawContract?.decimal - ? String(parseInt(tokIn[0].rawContract.decimal, 16)) - : undefined, - pairedContractAddress: - tokIn[0].rawContract?.address ?? undefined, - }) - } else if (tokOut.length > 0 && extIn.length > 0) { - combined.push({ - ...mapTransfer(tokOut[0]), - pairedValue: String(extIn[0].value), - pairedSymbol: chainid ? NATIVE_SYMBOLS[chainid] : undefined, - }) - } else if (tokOut.length > 0) { - combined.push(mapTransfer(tokOut[0])) - } else if (tokIn.length > 0) { - combined.push(mapTransfer(tokIn[0])) - } - } - - if (key && chainid) { - const addrSet = new Set() - for (const entry of combined) { - if (entry.contractAddress) - addrSet.add(entry.contractAddress.toLowerCase()) - if (entry.pairedContractAddress) - addrSet.add(entry.pairedContractAddress.toLowerCase()) - } - const logoMap = new Map() - await Promise.all( - Array.from(addrSet).map(async (addr) => { - const meta = await getTokenMetadata(key, chainid, addr) - if (meta?.result?.logo) logoMap.set(addr, meta.result.logo) - }), - ) - for (const entry of combined) { - if (entry.contractAddress) - entry.logo = logoMap.get(entry.contractAddress.toLowerCase()) - if (entry.pairedContractAddress) - entry.pairedLogo = logoMap.get( - entry.pairedContractAddress.toLowerCase(), - ) - } - } - - combined.sort((a, b) => Number(b.timeStamp) - Number(a.timeStamp)) - - return combined -} - -let moneroManager: MoneroWalletManager | null = null -let moneroDownloading = false - -let autoSyncTarget: { address: string; chainid: string } | null = null -let autoSyncTimer: ReturnType | null = null - const rpc = BrowserView.defineRPC({ maxRequestTime: 120000, handlers: { - requests: { - resetApp: async () => { - try { - const wallets = db.select().from(evmWallets).all() - await Promise.all([ - Bun.secrets.delete({ - service: 'koins', - name: 'alchemy_key', - }), - ...wallets.flatMap((w) => [ - Bun.secrets.delete({ - service: 'koins', - name: w.vaultKey, - }), - Bun.secrets.delete({ - service: 'koins', - name: `evm_auth_${w.id}`, - }), - ]), - ]) - sqlite.run('DELETE FROM token_metadata') - sqlite.run('DELETE FROM transactions') - sqlite.run('DELETE FROM transaction_receipts') - sqlite.run('DELETE FROM tx_history') - sqlite.run('DELETE FROM evm_wallets') - console.log('[rpc] resetApp complete') - return true - } catch (e) { - console.log('[rpc] resetApp error:', e) - return false - } - }, - biometricCanAuth: async () => { - console.log('[biometric] checking biometric...') - try { - const canPrompt = canPromptTouchID() - console.log('[biometric] biometric ===', canPrompt) - return canPrompt - } catch { - return false - } - }, - biometricAuth: async ({ reason }) => { - try { - return promptTouchID(reason) - } catch { - return false - } - }, - getSecret: async ({ name, service }) => { - return await Bun.secrets.get({ name, service }) - }, - setSecret: async ({ name, service, value }) => { - await Bun.secrets.set({ name, service, value }) - }, - openExternal: async ({ url }) => { - Utils.openExternal(url) - }, - generateQrCode: async ({ text, size }) => { - const { default: QRCode } = await import('qrcode') - return QRCode.toString(text, { - type: 'svg', - width: size ?? 128, - margin: 1, - }) - }, - fetchTxHistory: async ({ address, chainid }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return [] - - const [all, allError] = await tryCatch( - fetchAlchemyTransfers(key, chainid, address), - ) - - if (allError) { - console.log(allError) - return [] - } - - return pairTransfers(all, address, key, chainid) - }, - fetchCachedTxHistory: async ({ address, chainid }) => { - const transfers = await getCachedTransactionHistory( - chainid, - address, - ) - return pairTransfers(transfers, address) - }, - syncTxHistory: async ({ address, chainid }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return - try { - const count = await syncTransactionHistory( - key, - chainid, - address, - ) - rpc.send.transfersUpdate({ count, chainid, address }) - } catch (e) { - console.error('[sync] error:', e) - } - }, - setAutoSync: async (target) => { - if (autoSyncTimer) clearInterval(autoSyncTimer) - autoSyncTimer = null - autoSyncTarget = null - if (!target) return - autoSyncTarget = target - autoSyncTimer = setInterval(async () => { - if (!autoSyncTarget) return - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return - try { - const count = await syncTransactionHistory( - key, - autoSyncTarget.chainid, - autoSyncTarget.address, - ) - rpc.send.transfersUpdate({ - count, - chainid: autoSyncTarget.chainid, - address: autoSyncTarget.address, - }) - } catch (e) { - console.error('[auto-sync] error:', e) - } - }, 30000) - }, - flushTxCache: async () => { - sqlite.run('DELETE FROM tx_history') - sqlite.run('DELETE FROM tx_sync_status') - console.log('[rpc] flushTxCache complete') - }, - fetchTokenBalances: async ({ address, chainid }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return [] - try { - const result = await fetchAlchemyTokenBalances( - key, - chainid, - address, - ) - return result - } catch (error) { - console.log(error) - return [] - } - }, - fetchTokenPrices: async ({ symbols, addresses }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return [] - try { - const results: TokenPriceEntry[] = [] - - if (symbols && symbols.length > 0) { - const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-symbol?${symbols.map((s) => `symbols=${encodeURIComponent(s)}`).join('&')}` - const res = await fetch(url, { - signal: AbortSignal.timeout(10000), - }) - if (res.ok) { - const body = await res.json() - const data = (body.data ?? []) as any[] - for (const d of data) { - for (const p of d.prices ?? []) { - results.push({ - symbol: d.symbol, - currency: p.currency, - value: p.value, - lastUpdatedAt: p.lastUpdatedAt, - }) - } - } - } - } - - if (addresses && addresses.length > 0) { - const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-address` - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ addresses }), - signal: AbortSignal.timeout(10000), - }) - if (res.ok) { - const body = await res.json() - const data = (body.data ?? []) as any[] - for (const d of data) { - for (const p of d.prices ?? []) { - results.push({ - symbol: d.symbol, - currency: p.currency, - value: p.value, - lastUpdatedAt: p.lastUpdatedAt, - network: d.network, - address: d.address, - }) - } - } - } - } - - console.log('[prices] results:', results.length) - return results - } catch (error) { - console.log('[prices] error:', error) - return [] - } - }, - fetchGasPrice: async ({ chainid }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) { - console.log('[gas] no key') - return null - } - try { - const client = getClient(chainid, key) - const gasPrice = await client.getGasPrice() - console.log( - '[gas] price for chain', - chainid, - ':', - gasPrice.toString(), - ) - return formatGwei(gasPrice) - } catch (error) { - console.log('[gas] error:', error) - return null - } - }, - fetchTransactionDetails: async ({ hash, chainid, address }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return null - try { - const details = await getTransactionDetails(hash, chainid) - if (!details) return null - - if (address) { - const transfers = db - .select() - .from(txHistory) - .where( - and( - eq(txHistory.chainId, chainid), - eq(txHistory.hash, hash.toLowerCase()), - ), - ) - .all() - - if (transfers.length > 0) { - const paired = await pairTransfers(transfers, address) - if (paired.length > 0 && paired[0].pairedValue) { - details.pairedValue = paired[0].pairedValue - details.pairedSymbol = paired[0].pairedSymbol - details.pairedDecimals = paired[0].pairedDecimals - details.pairedContractAddress = - paired[0].pairedContractAddress - details.pairedLogo = paired[0].pairedLogo - } - } - } - - return details - } catch (error) { - console.log(error) - return null - } - }, - moneroBinaryStatus: async () => { - const status = { - installed: isBinaryInstalled(), - downloading: moneroDownloading, - } - console.log('[rpc] moneroBinaryStatus:', status) - return status - }, - moneroDownloadBinary: async () => { - console.log( - '[rpc] moneroDownloadBinary: starting download...', - ) - moneroDownloading = true - try { - await downloadBinary() - console.log('[rpc] moneroDownloadBinary: complete') - } catch (e) { - console.log('[rpc] moneroDownloadBinary failed:', e) - throw e - } finally { - moneroDownloading = false - } - }, - moneroStart: async ({ daemonAddress }) => { - console.log('[rpc] moneroStart:', { daemonAddress }) - if (!moneroManager) { - moneroManager = new MoneroWalletManager() - } - try { - await moneroManager.start(daemonAddress) - } catch (e) { - console.log('[rpc] moneroStart error:', e) - return { - running: false, - walletOpen: false, - connected: false, - } - } - const connected = await moneroManager.isConnected() - console.log( - '[rpc] moneroStart complete, connected:', - connected, - ) - return { running: true, walletOpen: false, connected } - }, - moneroStop: async () => { - console.log('[rpc] moneroStop') - if (moneroManager) { - await moneroManager.stop() - moneroManager = null - } - }, - moneroCreateWallet: async ({ name, password }) => { - console.log('[rpc] moneroCreateWallet:', name) - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - const result = await moneroManager.createWallet( - name, - password, - ) - console.log('[rpc] moneroCreateWallet complete') - return result - }, - moneroRestoreWallet: async ({ - name, - password, - mnemonic, - restoreHeight, - }) => { - console.log('[rpc] moneroRestoreWallet:', name) - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - await moneroManager.restoreWallet( - name, - password, - mnemonic, - restoreHeight, - ) - const address = await moneroManager.getAddress() - console.log( - '[rpc] moneroRestoreWallet complete, address:', - address, - ) - return { address } - }, - moneroOpenWallet: async ({ name, password }) => { - console.log('[rpc] moneroOpenWallet:', name) - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - await moneroManager.openWallet(name, password) - }, - moneroGetBalance: async () => { - console.log('[rpc] moneroGetBalance') - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - const { balance, unlocked } = await moneroManager.getBalance() - const address = await moneroManager.getAddress() - const height = await moneroManager.getHeight() - const daemonHeight = await moneroManager.getDaemonHeight() - console.log('[rpc] balance:', { - balance: balance.toString(), - unlocked: unlocked.toString(), - address, - height, - daemonHeight, - }) - return { - balance: balance.toString(), - unlocked: unlocked.toString(), - address, - height, - daemonHeight, - } - }, - moneroGetTransactions: async ({ accountIndex }) => { - console.log('[rpc] moneroGetTransactions', { accountIndex }) - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - const txs = await moneroManager.getTransactions(accountIndex) - console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`) - return txs - }, - moneroWalletStatus: async () => { - if (!moneroManager) { - console.log('[rpc] moneroWalletStatus: not running') - return { - running: false, - walletOpen: false, - connected: false, - } - } - const connected = await moneroManager.isConnected() - const walletOpen = await moneroManager.isWalletOpen() - console.log( - '[rpc] moneroWalletStatus: running, walletOpen:', - walletOpen, - 'connected:', - connected, - ) - return { running: true, walletOpen, connected } - }, - moneroGetAccounts: async () => { - console.log('[rpc] moneroGetAccounts') - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - const accounts = await moneroManager.getAccounts() - console.log( - `[rpc] moneroGetAccounts: ${accounts.length} accounts`, - ) - return accounts - }, - moneroListWallets: async () => { - console.log('[rpc] moneroListWallets') - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - const wallets = moneroManager.listWallets() - console.log( - `[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`, - ) - return wallets - }, - moneroTransfer: async ({ - address, - amount, - priority, - accountIndex, - }) => { - console.log('[rpc] moneroTransfer:', { - address, - amount, - priority, - }) - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - const result = await moneroManager.transfer( - address, - BigInt(amount), - priority ?? 0, - accountIndex ?? 0, - ) - console.log('[rpc] moneroTransfer complete:', result.txHash) - return result - }, - moneroGetTransferDetails: async ({ txid }) => { - console.log('[rpc] moneroGetTransferDetails:', txid) - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - const details = await moneroManager.getTransferDetails(txid) - console.log( - '[rpc] moneroGetTransferDetails complete:', - details?.hash, - ) - return details - }, - moneroGetFeeEstimate: async () => { - console.log('[rpc] moneroGetFeeEstimate') - if (!moneroManager) - throw new Error('Monero wallet RPC not started') - try { - const fee = await moneroManager.getFeeEstimate() - console.log('[monero][gas][fee]', fee) - return fee - } catch (error) { - console.log('[monero][gas][error]', error) - return - } - }, - evmCreateWallet: async ({ name, phrase, passwordHash }) => { - console.log('[rpc] evmCreateWallet:', name) - const id = crypto.randomUUID() - const vaultKey = `evm_seed_${id}` - await Bun.secrets.set({ - service: 'koins', - name: vaultKey, - value: phrase, - }) - await db.insert(evmWallets).values({ - id, - name, - passwordHash: passwordHash ?? null, - vaultKey, - createdAt: new Date().toISOString(), - }) - console.log('[rpc] evmCreateWallet complete:', id) - return { id, name, createdAt: new Date().toISOString() } - }, - evmListWallets: async () => { - console.log('[rpc] evmListWallets') - const wallets = db.select().from(evmWallets).all() - return wallets.map((w) => ({ - id: w.id, - name: w.name, - hasPassword: !!w.passwordHash, - vaultKey: w.vaultKey, - createdAt: w.createdAt, - })) - }, - evmGetSeed: async ({ vaultKey }) => { - console.log('[rpc] evmGetSeed:', vaultKey) - const seed = await Bun.secrets.get({ - service: 'koins', - name: vaultKey, - }) - if (!seed) throw new Error('Seed not found in keychain') - return seed - }, - evmDeleteWallet: async ({ id }) => { - console.log('[rpc] evmDeleteWallet:', id) - const wallet = db - .select() - .from(evmWallets) - .where(eq(evmWallets.id, id)) - .get() - if (!wallet) throw new Error('Wallet not found') - await Promise.all([ - Bun.secrets.delete({ - service: 'koins', - name: wallet.vaultKey, - }), - Bun.secrets.delete({ - service: 'koins', - name: `evm_auth_${id}`, - }), - ]) - db.delete(evmWallets).where(eq(evmWallets.id, id)).run() - console.log('[rpc] evmDeleteWallet complete') - }, - }, + requests: {}, messages: {}, }, }) +rpc.setRequestHandler({ + resetApp: async () => { + try { + const wallets = db.select().from(evmWallets).all() + await Promise.all([ + Bun.secrets.delete({ + service: 'koins', + name: 'alchemy_key', + }), + ...wallets.flatMap((w) => [ + Bun.secrets.delete({ + service: 'koins', + name: w.vaultKey, + }), + Bun.secrets.delete({ + service: 'koins', + name: `evm_auth_${w.id}`, + }), + ]), + ]) + sqlite.run('DELETE FROM token_metadata') + sqlite.run('DELETE FROM transactions') + sqlite.run('DELETE FROM transaction_receipts') + sqlite.run('DELETE FROM tx_history') + sqlite.run('DELETE FROM evm_wallets') + console.log('[rpc] resetApp complete') + return true + } catch (e) { + console.log('[rpc] resetApp error:', e) + return false + } + }, + biometricCanAuth: async () => { + console.log('[biometric] checking biometric...') + try { + const canPrompt = canPromptTouchID() + console.log('[biometric] biometric ===', canPrompt) + return canPrompt + } catch { + return false + } + }, + biometricAuth: async ({ reason }) => { + try { + return promptTouchID(reason) + } catch { + return false + } + }, + getSecret: async ({ name, service }) => { + return await Bun.secrets.get({ name, service }) + }, + setSecret: async ({ name, service, value }) => { + await Bun.secrets.set({ name, service, value }) + }, + openExternal: async ({ url }) => { + Utils.openExternal(url) + }, + generateQrCode: async ({ text, size }) => { + const { default: QRCode } = await import('qrcode') + return QRCode.toString(text, { + type: 'svg', + width: size ?? 128, + margin: 1, + }) + }, + ...createEvmHandlers(rpc, syncState), + ...createMoneroHandlers(moneroState), +}) + const win = new BrowserWindow({ title: 'Koins', url, diff --git a/apps/desktop/src/bun/rpc/evm/index.ts b/apps/desktop/src/bun/rpc/evm/index.ts new file mode 100644 index 0000000..3d41ecd --- /dev/null +++ b/apps/desktop/src/bun/rpc/evm/index.ts @@ -0,0 +1,570 @@ +import { getTokenMetadata } from '../../lib/tokens' +import { getTokensBalances } from '../../lib/alchemy' +import { tryCatch } from '@koins/utils' +import { + getTransactionHistory, + getTransactionDetails, + getCachedTransactionHistory, +} from '../../lib/transactions' +import { syncTransactionHistory } from '../../lib/sync' +import { eq, and } from 'drizzle-orm' +import { db } from '../../lib/db' +import { getClient } from '../../lib/viem' +import { formatGwei } from 'viem' +import { sqlite } from '../../lib/sqlite' +import { txHistory, evmWallets } from '../../lib/db/schema' +import type { + TokenBalanceResult, + TokenPriceEntry, + TxEntry, +} from '../../../lib/rpc-schema' + +export function createEvmHandlers( + rpc: { + send: { + transfersUpdate: (p: { + count: number + chainid: string + address: string + }) => void + } + }, + syncState: { + target: { address: string; chainid: string } | null + timer: ReturnType | null + }, +) { + const ALCHEMY_NETWORKS: Record = { + '1': 'eth-mainnet', + '137': 'polygon-mainnet', + '56': 'bnb-mainnet', + } + + const NATIVE_SYMBOLS: Record = { + '1': 'ETH', + '137': 'POL', + '56': 'BNB', + } + + async function fetchAlchemyTransfers( + key: string, + chainid: string, + address: string, + ) { + const transactions = await getTransactionHistory( + key, + chainid, + address, + { maxCount: 10 }, + ) + if (!transactions) return [] + return transactions + } + + function formatUnits(balance: string, decimals: number): string { + const bal = BigInt(balance) + if (bal === 0n) return '0' + const divisor = 10n ** BigInt(decimals) + const intPart = bal / divisor + const fracPart = (bal % divisor) + .toString() + .padStart(decimals, '0') + .replace(/0+$/, '') + return fracPart ? `${intPart}.${fracPart}` : `${intPart}` + } + + async function fetchAlchemyTokenBalances( + key: string, + chainid: string, + address: string, + ): Promise { + const [balRes, balResError] = await tryCatch( + getTokensBalances(key, chainid, address), + ) + if (balResError) { + console.log('Get Tokens Balance Error', balResError) + return [] + } + if (!balRes) return [] + const tokens = balRes.result?.tokenBalances ?? [] + const nonZero = tokens.filter( + (t: any) => t.tokenBalance !== '0x0', + ) + const metas = await Promise.allSettled( + nonZero.map((t: any) => + getTokenMetadata(key, chainid, t.contractAddress), + ), + ) + if (!metas) return [] + return nonZero + .map((t: any, i: number) => { + const meta = + metas[i].status === 'fulfilled' && metas[i].value + ? metas[i].value.result + : null + return { + symbol: meta?.symbol ?? 'Unknown', + decimals: meta?.decimals ?? 0, + balance: formatUnits(t.tokenBalance, meta?.decimals ?? 18), + contractAddress: t.contractAddress, + logo: meta?.logo ?? undefined, + } + }) + .filter((t) => t.logo && t.decimals !== 0) + } + + function mapTransfer(t: any): TxEntry { + const isExternal = t.category === 'external' + return { + hash: t.hash, + timeStamp: String( + Math.floor( + new Date(t.metadata?.blockTimestamp).getTime() / 1000, + ), + ), + from: t.from, + to: t.to, + value: String(t.value), + ...(isExternal + ? {} + : { + tokenSymbol: t.asset, + tokenDecimal: t.rawContract?.decimal + ? String(parseInt(t.rawContract.decimal, 16)) + : undefined, + contractAddress: t.rawContract?.address ?? undefined, + }), + } + } + + async function pairTransfers( + transfers: any[], + address: string, + key?: string, + chainid?: string, + ): Promise { + const addrLower = address.toLowerCase() + const byHash = new Map() + for (const t of transfers) { + if (!t.hash) continue + const h = t.hash + if (!byHash.has(h)) byHash.set(h, []) + byHash.get(h)!.push(t) + } + + const combined: TxEntry[] = [] + + for (const [, group] of byHash) { + const native = group.filter( + (t: any) => + t.category === 'external' || t.category === 'internal', + ) + const tokens = group.filter((t: any) => t.category === 'erc20') + + const extOut = native.filter( + (t: any) => t.from?.toLowerCase() === addrLower, + ) + const extIn = native.filter( + (t: any) => t.to?.toLowerCase() === addrLower, + ) + const tokIn = tokens.filter( + (t: any) => t.to?.toLowerCase() === addrLower, + ) + const tokOut = tokens.filter( + (t: any) => t.from?.toLowerCase() === addrLower, + ) + + if (extOut.length > 0 && tokIn.length > 0) { + combined.push({ + ...mapTransfer(extOut[0]), + tokenSymbol: tokIn[0].asset, + tokenDecimal: tokIn[0].rawContract?.decimal + ? String(parseInt(tokIn[0].rawContract.decimal, 16)) + : undefined, + contractAddress: tokIn[0].rawContract?.address ?? undefined, + pairedValue: String(tokIn[0].value), + pairedSymbol: tokIn[0].asset, + pairedDecimals: tokIn[0].rawContract?.decimal + ? String(parseInt(tokIn[0].rawContract.decimal, 16)) + : undefined, + pairedContractAddress: + tokIn[0].rawContract?.address ?? undefined, + }) + } else if (extOut.length > 0) { + combined.push(mapTransfer(extOut[0])) + } else if (extIn.length > 0) { + combined.push(mapTransfer(extIn[0])) + } else if (tokOut.length > 0 && tokIn.length > 0) { + combined.push({ + ...mapTransfer(tokOut[0]), + pairedValue: String(tokIn[0].value), + pairedSymbol: tokIn[0].asset, + pairedDecimals: tokIn[0].rawContract?.decimal + ? String(parseInt(tokIn[0].rawContract.decimal, 16)) + : undefined, + pairedContractAddress: + tokIn[0].rawContract?.address ?? undefined, + }) + } else if (tokOut.length > 0 && extIn.length > 0) { + combined.push({ + ...mapTransfer(tokOut[0]), + pairedValue: String(extIn[0].value), + pairedSymbol: chainid ? NATIVE_SYMBOLS[chainid] : undefined, + }) + } else if (tokOut.length > 0) { + combined.push(mapTransfer(tokOut[0])) + } else if (tokIn.length > 0) { + combined.push(mapTransfer(tokIn[0])) + } + } + + if (key && chainid) { + const addrSet = new Set() + for (const entry of combined) { + if (entry.contractAddress) + addrSet.add(entry.contractAddress.toLowerCase()) + if (entry.pairedContractAddress) + addrSet.add(entry.pairedContractAddress.toLowerCase()) + } + const logoMap = new Map() + await Promise.all( + Array.from(addrSet).map(async (addr) => { + const meta = await getTokenMetadata(key, chainid, addr) + if (meta?.result?.logo) logoMap.set(addr, meta.result.logo) + }), + ) + for (const entry of combined) { + if (entry.contractAddress) + entry.logo = logoMap.get( + entry.contractAddress.toLowerCase(), + ) + if (entry.pairedContractAddress) + entry.pairedLogo = logoMap.get( + entry.pairedContractAddress.toLowerCase(), + ) + } + } + + combined.sort((a, b) => Number(b.timeStamp) - Number(a.timeStamp)) + return combined + } + + return { + fetchTxHistory: async ({ + address, + chainid, + }: { + address: string + chainid: string + }) => { + const key = await Bun.secrets.get({ + service: 'koins', + name: 'alchemy_key', + }) + if (!key) return [] + const [all, allError] = await tryCatch( + fetchAlchemyTransfers(key, chainid, address), + ) + if (allError) { + console.log(allError) + return [] + } + return pairTransfers(all, address, key, chainid) + }, + fetchCachedTxHistory: async ({ + address, + chainid, + }: { + address: string + chainid: string + }) => { + const transfers = await getCachedTransactionHistory( + chainid, + address, + ) + return pairTransfers(transfers, address) + }, + syncTxHistory: async ({ + address, + chainid, + }: { + address: string + chainid: string + }) => { + const key = await Bun.secrets.get({ + service: 'koins', + name: 'alchemy_key', + }) + if (!key) return + try { + const count = await syncTransactionHistory( + key, + chainid, + address, + ) + rpc.send.transfersUpdate({ count, chainid, address }) + } catch (e) { + console.error('[sync] error:', e) + } + }, + setAutoSync: async ( + target: { address: string; chainid: string } | null, + ) => { + if (syncState.timer) clearInterval(syncState.timer) + syncState.timer = null + syncState.target = null + if (!target) return + syncState.target = target + syncState.timer = setInterval(async () => { + if (!syncState.target) return + const key = await Bun.secrets.get({ + service: 'koins', + name: 'alchemy_key', + }) + if (!key) return + try { + const count = await syncTransactionHistory( + key, + syncState.target.chainid, + syncState.target.address, + ) + rpc.send.transfersUpdate({ + count, + chainid: syncState.target.chainid, + address: syncState.target.address, + }) + } catch (e) { + console.error('[auto-sync] error:', e) + } + }, 30000) + }, + flushTxCache: async () => { + sqlite.run('DELETE FROM tx_history') + sqlite.run('DELETE FROM tx_sync_status') + console.log('[rpc] flushTxCache complete') + }, + fetchTokenBalances: async ({ + address, + chainid, + }: { + address: string + chainid: string + }) => { + const key = await Bun.secrets.get({ + service: 'koins', + name: 'alchemy_key', + }) + if (!key) return [] + try { + return await fetchAlchemyTokenBalances(key, chainid, address) + } catch (error) { + console.log(error) + return [] + } + }, + fetchTokenPrices: async ({ + symbols, + addresses, + }: { + symbols?: string[] + addresses?: { network: string; address: string }[] + }) => { + const key = await Bun.secrets.get({ + service: 'koins', + name: 'alchemy_key', + }) + if (!key) return [] + try { + const results: TokenPriceEntry[] = [] + if (symbols && symbols.length > 0) { + const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-symbol?${symbols.map((s) => `symbols=${encodeURIComponent(s)}`).join('&')}` + const res = await fetch(url, { + signal: AbortSignal.timeout(10000), + }) + if (res.ok) { + const body = await res.json() + for (const d of body.data ?? []) { + for (const p of d.prices ?? []) { + results.push({ + symbol: d.symbol, + currency: p.currency, + value: p.value, + lastUpdatedAt: p.lastUpdatedAt, + }) + } + } + } + } + if (addresses && addresses.length > 0) { + const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-address` + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addresses }), + signal: AbortSignal.timeout(10000), + }) + if (res.ok) { + const body = await res.json() + for (const d of body.data ?? []) { + for (const p of d.prices ?? []) { + results.push({ + symbol: d.symbol, + currency: p.currency, + value: p.value, + lastUpdatedAt: p.lastUpdatedAt, + network: d.network, + address: d.address, + }) + } + } + } + } + console.log('[prices] results:', results.length) + return results + } catch (error) { + console.log('[prices] error:', error) + return [] + } + }, + fetchGasPrice: async ({ chainid }: { chainid: string }) => { + const key = await Bun.secrets.get({ + service: 'koins', + name: 'alchemy_key', + }) + if (!key) { + console.log('[gas] no key') + return null + } + try { + const client = getClient(chainid, key) + const gasPrice = await client.getGasPrice() + console.log( + '[gas] price for chain', + chainid, + ':', + gasPrice.toString(), + ) + return formatGwei(gasPrice) + } catch (error) { + console.log('[gas] error:', error) + return null + } + }, + fetchTransactionDetails: async ({ + hash, + chainid, + address, + }: { + hash: string + chainid: string + address?: string + }) => { + const key = await Bun.secrets.get({ + service: 'koins', + name: 'alchemy_key', + }) + if (!key) return null + try { + const details = await getTransactionDetails(hash, chainid) + if (!details) return null + if (address) { + const transfers = db + .select() + .from(txHistory) + .where( + and( + eq(txHistory.chainId, chainid), + eq(txHistory.hash, hash.toLowerCase()), + ), + ) + .all() + if (transfers.length > 0) { + const paired = await pairTransfers(transfers, address) + if (paired.length > 0 && paired[0].pairedValue) { + details.pairedValue = paired[0].pairedValue + details.pairedSymbol = paired[0].pairedSymbol + details.pairedDecimals = paired[0].pairedDecimals + details.pairedContractAddress = + paired[0].pairedContractAddress + details.pairedLogo = paired[0].pairedLogo + } + } + } + return details + } catch (error) { + console.log(error) + return null + } + }, + evmCreateWallet: async ({ + name, + phrase, + passwordHash, + }: { + name: string + phrase: string + passwordHash?: string + }) => { + console.log('[rpc] evmCreateWallet:', name) + const id = crypto.randomUUID() + const vaultKey = `evm_seed_${id}` + await Bun.secrets.set({ + service: 'koins', + name: vaultKey, + value: phrase, + }) + await db + .insert(evmWallets) + .values({ + id, + name, + passwordHash: passwordHash ?? null, + vaultKey, + createdAt: new Date().toISOString(), + }) + console.log('[rpc] evmCreateWallet complete:', id) + return { id, name, createdAt: new Date().toISOString() } + }, + evmListWallets: async () => { + console.log('[rpc] evmListWallets') + const wallets = db.select().from(evmWallets).all() + return wallets.map((w: any) => ({ + id: w.id, + name: w.name, + hasPassword: !!w.passwordHash, + vaultKey: w.vaultKey, + createdAt: w.createdAt, + })) + }, + evmGetSeed: async ({ vaultKey }: { vaultKey: string }) => { + console.log('[rpc] evmGetSeed:', vaultKey) + const seed = await Bun.secrets.get({ + service: 'koins', + name: vaultKey, + }) + if (!seed) throw new Error('Seed not found in keychain') + return seed + }, + evmDeleteWallet: async ({ id }: { id: string }) => { + console.log('[rpc] evmDeleteWallet:', id) + const wallet = db + .select() + .from(evmWallets) + .where(eq(evmWallets.id, id)) + .get() + if (!wallet) throw new Error('Wallet not found') + await Promise.all([ + Bun.secrets.delete({ + service: 'koins', + name: wallet.vaultKey, + }), + Bun.secrets.delete({ + service: 'koins', + name: `evm_auth_${id}`, + }), + ]) + db.delete(evmWallets).where(eq(evmWallets.id, id)).run() + console.log('[rpc] evmDeleteWallet complete') + }, + } +} diff --git a/apps/desktop/src/bun/rpc/monero/index.ts b/apps/desktop/src/bun/rpc/monero/index.ts new file mode 100644 index 0000000..0ab4aa0 --- /dev/null +++ b/apps/desktop/src/bun/rpc/monero/index.ts @@ -0,0 +1,235 @@ +import { + isBinaryInstalled, + downloadBinary, + MoneroWalletManager, +} from '../../lib/monero' + +export function createMoneroHandlers(state: { + manager: MoneroWalletManager | null + downloading: boolean +}) { + return { + moneroBinaryStatus: async () => { + const status = { + installed: isBinaryInstalled(), + downloading: state.downloading, + error: undefined, + } + console.log('[rpc] moneroBinaryStatus:', status) + return status + }, + moneroDownloadBinary: async () => { + console.log('[rpc] moneroDownloadBinary: starting download...') + state.downloading = true + try { + await downloadBinary() + console.log('[rpc] moneroDownloadBinary: complete') + } catch (e) { + console.log('[rpc] moneroDownloadBinary failed:', e) + throw e + } finally { + state.downloading = false + } + }, + moneroStart: async ({ + daemonAddress, + }: { + daemonAddress?: string + }) => { + console.log('[rpc] moneroStart:', { daemonAddress }) + if (!state.manager) { + state.manager = new MoneroWalletManager() + } + try { + await state.manager.start(daemonAddress) + } catch (e) { + console.log('[rpc] moneroStart error:', e) + return { running: false, walletOpen: false, connected: false } + } + const connected = await state.manager.isConnected() + console.log('[rpc] moneroStart complete, connected:', connected) + return { running: true, walletOpen: false, connected } + }, + moneroStop: async () => { + console.log('[rpc] moneroStop') + if (state.manager) { + await state.manager.stop() + state.manager = null + } + }, + moneroCreateWallet: async ({ + name, + password, + }: { + name: string + password: string + }) => { + console.log('[rpc] moneroCreateWallet:', name) + if (!state.manager) + throw new Error('Monero wallet RPC not started') + const result = await state.manager.createWallet(name, password) + console.log('[rpc] moneroCreateWallet complete') + return result + }, + moneroRestoreWallet: async ({ + name, + password, + mnemonic, + restoreHeight, + }: { + name: string + password: string + mnemonic: string + restoreHeight?: number + }) => { + console.log('[rpc] moneroRestoreWallet:', name) + if (!state.manager) + throw new Error('Monero wallet RPC not started') + await state.manager.restoreWallet( + name, + password, + mnemonic, + restoreHeight, + ) + const address = await state.manager.getAddress() + console.log( + '[rpc] moneroRestoreWallet complete, address:', + address, + ) + return { address } + }, + moneroOpenWallet: async ({ + name, + password, + }: { + name: string + password: string + }) => { + console.log('[rpc] moneroOpenWallet:', name) + if (!state.manager) + throw new Error('Monero wallet RPC not started') + await state.manager.openWallet(name, password) + }, + moneroGetBalance: async () => { + console.log('[rpc] moneroGetBalance') + if (!state.manager) + throw new Error('Monero wallet RPC not started') + const { balance, unlocked } = await state.manager.getBalance() + const address = await state.manager.getAddress() + const height = await state.manager.getHeight() + const daemonHeight = await state.manager.getDaemonHeight() + console.log('[rpc] balance:', { + balance: balance.toString(), + unlocked: unlocked.toString(), + address, + height, + daemonHeight, + }) + return { + balance: balance.toString(), + unlocked: unlocked.toString(), + address, + height, + daemonHeight, + } + }, + moneroGetTransactions: async ({ + accountIndex, + }: { + accountIndex?: number + }) => { + console.log('[rpc] moneroGetTransactions', { accountIndex }) + if (!state.manager) + throw new Error('Monero wallet RPC not started') + const txs = await state.manager.getTransactions(accountIndex) + console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`) + return txs + }, + moneroWalletStatus: async () => { + if (!state.manager) { + console.log('[rpc] moneroWalletStatus: not running') + return { running: false, walletOpen: false, connected: false } + } + const connected = await state.manager.isConnected() + const walletOpen = await state.manager.isWalletOpen() + console.log( + '[rpc] moneroWalletStatus: running, walletOpen:', + walletOpen, + 'connected:', + connected, + ) + return { running: true, walletOpen, connected } + }, + moneroGetAccounts: async () => { + console.log('[rpc] moneroGetAccounts') + if (!state.manager) + throw new Error('Monero wallet RPC not started') + const accounts = await state.manager.getAccounts() + console.log( + `[rpc] moneroGetAccounts: ${accounts.length} accounts`, + ) + return accounts + }, + moneroListWallets: async () => { + console.log('[rpc] moneroListWallets') + if (!state.manager) + throw new Error('Monero wallet RPC not started') + const wallets = state.manager.listWallets() + console.log( + `[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`, + ) + return wallets + }, + moneroTransfer: async ({ + address, + amount, + priority, + accountIndex, + }: { + address: string + amount: string + priority?: number + accountIndex?: number + }) => { + console.log('[rpc] moneroTransfer:', { + address, + amount, + priority, + }) + if (!state.manager) + throw new Error('Monero wallet RPC not started') + const result = await state.manager.transfer( + address, + BigInt(amount), + priority ?? 0, + accountIndex ?? 0, + ) + console.log('[rpc] moneroTransfer complete:', result.txHash) + return result + }, + moneroGetTransferDetails: async ({ txid }: { txid: string }) => { + console.log('[rpc] moneroGetTransferDetails:', txid) + if (!state.manager) + throw new Error('Monero wallet RPC not started') + const details = await state.manager.getTransferDetails(txid) + console.log( + '[rpc] moneroGetTransferDetails complete:', + details?.hash, + ) + return details + }, + moneroGetFeeEstimate: async () => { + console.log('[rpc] moneroGetFeeEstimate') + if (!state.manager) + throw new Error('Monero wallet RPC not started') + try { + const fee = await state.manager.getFeeEstimate() + console.log('[monero][gas][fee]', fee) + return fee + } catch (error) { + console.log('[monero][gas][error]', error) + return null + } + }, + } +} diff --git a/apps/desktop/src/lib/rpc-schema.ts b/apps/desktop/src/lib/rpc-schema.ts index c8233fb..e1bec27 100644 --- a/apps/desktop/src/lib/rpc-schema.ts +++ b/apps/desktop/src/lib/rpc-schema.ts @@ -139,45 +139,53 @@ export type EvmWalletInfo = { createdAt: string } +export type GeneralRpcRequests = { + resetApp: { params: {}; response: boolean } + biometricCanAuth: { params: {}; response: boolean } + biometricAuth: { params: { reason: string }; response: boolean } + getSecret: { params: { service: string; name: string }; response: string | null } + setSecret: { params: { service: string; name: string; value: string }; response: void } + openExternal: { params: { url: string }; response: void } + generateQrCode: { params: { text: string; size?: number }; response: string } +} + +export type EvmRpcRequests = { + fetchTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] } + fetchCachedTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] } + syncTxHistory: { params: { address: string; chainid: string }; response: void } + flushTxCache: { params: {}; response: void } + setAutoSync: { params: { address: string; chainid: string } | null; response: void } + fetchTokenBalances: { params: { address: string; chainid: string }; response: TokenBalanceResult[] } + fetchTransactionDetails: { params: { hash: string; chainid: string; address?: string }; response: TransactionDetails | null } + fetchTokenPrices: { params: { symbols?: string[]; addresses?: { network: string; address: string }[] }; response: TokenPriceEntry[] } + fetchGasPrice: { params: { chainid: string }; response: string | null } + evmCreateWallet: { params: { name: string; phrase: string; passwordHash?: string }; response: { id: string; name: string; createdAt: string } } + evmListWallets: { params: {}; response: EvmWalletInfo[] } + evmGetSeed: { params: { vaultKey: string }; response: string } + evmDeleteWallet: { params: { id: string }; response: void } +} + +export type MoneroRpcRequests = { + moneroBinaryStatus: { params: {}; response: MoneroBinaryStatus } + moneroDownloadBinary: { params: {}; response: void } + moneroStart: { params: { daemonAddress?: string }; response: MoneroWalletStatus } + moneroStop: { params: {}; response: void } + moneroCreateWallet: { params: { name: string; password: string }; response: { mnemonic: string; address: string } } + moneroRestoreWallet: { params: { name: string; password: string; mnemonic: string; restoreHeight?: number }; response: { address: string } } + moneroOpenWallet: { params: { name: string; password: string }; response: void } + moneroGetBalance: { params: {}; response: MoneroBalance } + moneroGetTransactions: { params: { accountIndex?: number }; response: MoneroTxEntry[] } + moneroWalletStatus: { params: {}; response: MoneroWalletStatus } + moneroGetAccounts: { params: {}; response: MoneroAccountEntry[] } + moneroListWallets: { params: {}; response: string[] } + moneroTransfer: { params: { address: string; amount: string; priority?: number; accountIndex?: number }; response: MoneroSendResult } + moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null } + moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | null } +} + export type RPC = { bun: { - requests: { - resetApp: { params: {}; response: boolean } - biometricCanAuth: { params: {}; response: boolean } - biometricAuth: { params: { reason: string }; response: boolean } - getSecret: { params: { service: string; name: string }; response: string | null } - setSecret: { params: { service: string; name: string; value: string }; response: void } - fetchTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] } - fetchCachedTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] } - syncTxHistory: { params: { address: string; chainid: string }; response: void } - flushTxCache: { params: {}; response: void } - setAutoSync: { params: { address: string; chainid: string } | null; response: void } - fetchTokenBalances: { params: { address: string; chainid: string }; response: TokenBalanceResult[] } - fetchTransactionDetails: { params: { hash: string; chainid: string; address?: string }; response: TransactionDetails | null } - openExternal: { params: { url: string }; response: void } - generateQrCode: { params: { text: string; size?: number }; response: string } - moneroBinaryStatus: { params: {}; response: MoneroBinaryStatus } - moneroDownloadBinary: { params: {}; response: void } - moneroStart: { params: { daemonAddress?: string }; response: MoneroWalletStatus } - moneroStop: { params: {}; response: void } - moneroCreateWallet: { params: { name: string; password: string }; response: { mnemonic: string; address: string } } - moneroRestoreWallet: { params: { name: string; password: string; mnemonic: string; restoreHeight?: number }; response: { address: string } } - moneroOpenWallet: { params: { name: string; password: string }; response: void } - moneroGetBalance: { params: {}; response: MoneroBalance } - moneroGetTransactions: { params: { accountIndex?: number }; response: MoneroTxEntry[] } - moneroWalletStatus: { params: {}; response: MoneroWalletStatus } - moneroGetAccounts: { params: {}; response: MoneroAccountEntry[] } - moneroListWallets: { params: {}; response: string[] } - moneroTransfer: { params: { address: string; amount: string; priority?: number; accountIndex?: number }; response: MoneroSendResult } - moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null } - moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | null } - evmCreateWallet: { params: { name: string; phrase: string; passwordHash?: string }; response: { id: string; name: string; createdAt: string } } - evmListWallets: { params: {}; response: EvmWalletInfo[] } - evmGetSeed: { params: { vaultKey: string }; response: string } - evmDeleteWallet: { params: { id: string }; response: void } - fetchTokenPrices: { params: { symbols?: string[]; addresses?: { network: string; address: string }[] }; response: TokenPriceEntry[] } - fetchGasPrice: { params: { chainid: string }; response: string | null } - } + requests: GeneralRpcRequests & EvmRpcRequests & MoneroRpcRequests messages: {} } webview: {