Files
koins/apps/desktop/src/bun/index.ts
T

808 lines
20 KiB
TypeScript

import {
ApplicationMenu,
BrowserView,
BrowserWindow,
PATHS,
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 {
isBinaryInstalled,
downloadBinary,
getBinaryPath,
getBinaryDir,
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, TxEntry } from '../lib/rpc-schema'
const env = getENV()
console.log(env)
if (env.env === 'prod') {
migrate(db, {
migrationsFolder: join(PATHS.RESOURCES_FOLDER, 'app', 'drizzle'),
})
}
const DEV_SERVER_PORT = 5173
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
async function getMainViewUrl(): Promise<string> {
const channel = await Updater.localInfo.channel()
if (channel === 'dev') {
try {
await fetch(DEV_SERVER_URL, { method: 'HEAD' })
console.log(
`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`,
)
return DEV_SERVER_URL
} catch {
console.log(
"Vite dev server not running. Run 'bun run dev:hmr' for HMR support.",
)
}
}
return 'views://mainview/index.html'
}
ApplicationMenu.setApplicationMenu([
{
label: 'File',
submenu: [{ role: 'quit' }],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'divider' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'divider' },
{ role: 'selectAll' },
],
},
{
label: 'Window',
submenu: [{ role: 'minimize' }, { role: 'zoom' }],
},
])
const url = await getMainViewUrl()
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) => 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>({
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 []
}
},
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
},
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({
title: 'Koins',
url,
rpc,
frame: {
width: 900,
height: 700,
x: 200,
y: 200,
},
})
console.log('Koins app started!')