Refactor: split RPC handlers into rpc/evm and rpc/monero; split RPC types into GeneralRpcRequests, EvmRpcRequests, MoneroRpcRequests

This commit is contained in:
2026-05-25 23:46:36 +08:00
parent 3925f3a4aa
commit b5f4a80f5c
4 changed files with 932 additions and 848 deletions
+19 -748
View File
@@ -6,45 +6,17 @@ import {
Updater, Updater,
Utils, Utils,
} from 'electrobun/bun' } 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 { migrate } from 'drizzle-orm/bun-sqlite/migrator'
import { eq, and } from 'drizzle-orm'
import { db } from './lib/db' import { db } from './lib/db'
import { join } from 'path' import { join } from 'path'
import { getENV } from './lib/get-env' import { getENV } from './lib/get-env'
import { getClient } from './lib/viem' import { createEvmHandlers } from './rpc/evm'
import { formatGwei } from 'viem' import { createMoneroHandlers } from './rpc/monero'
import { import { MoneroWalletManager } from './lib/monero'
isBinaryInstalled,
downloadBinary,
getBinaryPath,
getBinaryDir,
MoneroWalletManager,
} from './lib/monero'
import { canPromptTouchID, promptTouchID } from './lib/biometric' import { canPromptTouchID, promptTouchID } from './lib/biometric'
import { sqlite } from './lib/sqlite' import { sqlite } from './lib/sqlite'
import { import { evmWallets } from './lib/db/schema'
tokenMetadata, import type { RPC } from '../lib/rpc-schema'
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'
const env = getENV() const env = getENV()
@@ -104,235 +76,24 @@ ApplicationMenu.setApplicationMenu([
const url = await getMainViewUrl() const url = await getMainViewUrl()
const ALCHEMY_NETWORKS: Record<string, string> = { const moneroState = {
'1': 'eth-mainnet', manager: null as MoneroWalletManager | null,
'137': 'polygon-mainnet', downloading: false,
'56': 'bnb-mainnet',
} }
const syncState = {
const NATIVE_SYMBOLS: Record<string, string> = { target: null as { address: string; chainid: string } | null,
'1': 'ETH', timer: null as ReturnType<typeof setInterval> | null,
'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<TokenBalanceResult[]> {
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<TxEntry[]> {
const addrLower = address.toLowerCase()
const byHash = new Map<string, any[]>()
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<string>()
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<string, string>()
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<typeof setInterval> | null = null
const rpc = BrowserView.defineRPC<RPC>({ const rpc = BrowserView.defineRPC<RPC>({
maxRequestTime: 120000, maxRequestTime: 120000,
handlers: { handlers: {
requests: { requests: {},
messages: {},
},
})
rpc.setRequestHandler({
resetApp: async () => { resetApp: async () => {
try { try {
const wallets = db.select().from(evmWallets).all() const wallets = db.select().from(evmWallets).all()
@@ -398,498 +159,8 @@ const rpc = BrowserView.defineRPC<RPC>({
margin: 1, margin: 1,
}) })
}, },
fetchTxHistory: async ({ address, chainid }) => { ...createEvmHandlers(rpc, syncState),
const key = await Bun.secrets.get({ ...createMoneroHandlers(moneroState),
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')
},
},
messages: {},
},
}) })
const win = new BrowserWindow({ const win = new BrowserWindow({
+570
View File
@@ -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<typeof setInterval> | null
},
) {
const ALCHEMY_NETWORKS: Record<string, string> = {
'1': 'eth-mainnet',
'137': 'polygon-mainnet',
'56': 'bnb-mainnet',
}
const NATIVE_SYMBOLS: Record<string, string> = {
'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<TokenBalanceResult[]> {
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<TxEntry[]> {
const addrLower = address.toLowerCase()
const byHash = new Map<string, any[]>()
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<string>()
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<string, string>()
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')
},
}
}
+235
View File
@@ -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
}
},
}
}
+19 -11
View File
@@ -139,14 +139,17 @@ export type EvmWalletInfo = {
createdAt: string createdAt: string
} }
export type RPC = { export type GeneralRpcRequests = {
bun: {
requests: {
resetApp: { params: {}; response: boolean } resetApp: { params: {}; response: boolean }
biometricCanAuth: { params: {}; response: boolean } biometricCanAuth: { params: {}; response: boolean }
biometricAuth: { params: { reason: string }; response: boolean } biometricAuth: { params: { reason: string }; response: boolean }
getSecret: { params: { service: string; name: string }; response: string | null } getSecret: { params: { service: string; name: string }; response: string | null }
setSecret: { params: { service: string; name: string; value: string }; response: void } 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[] } fetchTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] }
fetchCachedTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] } fetchCachedTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] }
syncTxHistory: { params: { address: string; chainid: string }; response: void } syncTxHistory: { params: { address: string; chainid: string }; response: void }
@@ -154,8 +157,15 @@ export type RPC = {
setAutoSync: { params: { address: string; chainid: string } | null; response: void } setAutoSync: { params: { address: string; chainid: string } | null; response: void }
fetchTokenBalances: { params: { address: string; chainid: string }; response: TokenBalanceResult[] } fetchTokenBalances: { params: { address: string; chainid: string }; response: TokenBalanceResult[] }
fetchTransactionDetails: { params: { hash: string; chainid: string; address?: string }; response: TransactionDetails | null } fetchTransactionDetails: { params: { hash: string; chainid: string; address?: string }; response: TransactionDetails | null }
openExternal: { params: { url: string }; response: void } fetchTokenPrices: { params: { symbols?: string[]; addresses?: { network: string; address: string }[] }; response: TokenPriceEntry[] }
generateQrCode: { params: { text: string; size?: number }; response: string } 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 } moneroBinaryStatus: { params: {}; response: MoneroBinaryStatus }
moneroDownloadBinary: { params: {}; response: void } moneroDownloadBinary: { params: {}; response: void }
moneroStart: { params: { daemonAddress?: string }; response: MoneroWalletStatus } moneroStart: { params: { daemonAddress?: string }; response: MoneroWalletStatus }
@@ -171,13 +181,11 @@ export type RPC = {
moneroTransfer: { params: { address: string; amount: string; priority?: number; accountIndex?: number }; response: MoneroSendResult } moneroTransfer: { params: { address: string; amount: string; priority?: number; accountIndex?: number }; response: MoneroSendResult }
moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null } moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null }
moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | 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 }
} }
export type RPC = {
bun: {
requests: GeneralRpcRequests & EvmRpcRequests & MoneroRpcRequests
messages: {} messages: {}
} }
webview: { webview: {