fix biometric, tx dedup, loading crash; add flush cache; remove monero startup from evm init

- biometric-helper: use .deviceOwnerAuthentication so passcode can
  unlock Touch ID when biometrics are locked out
- get-transaction-history: dedup transfers by hash+category+contract
  instead of hash alone (was dropping token transfers sharing a tx hash)
- evm-wallet.svelte.ts: add missing loading  declaration (caused
  ReferenceError on createWallet); add flushTxCache method; comment out
  monero startup from evm init to speed up loading
- get-history.ts: remove dead caching logic (now handled by sync engine);
  import getTransactionHistory from alchemy directly
- backend RPC + settings UI: add flushTxCache to clear tx cache tables
This commit is contained in:
2026-05-22 03:03:03 +08:00
parent edd4f6fff0
commit dd57a72e11
9 changed files with 262 additions and 122 deletions
+67 -13
View File
@@ -190,6 +190,10 @@ type RPC = {
params: { address: string; chainid: string } params: { address: string; chainid: string }
response: void response: void
} }
flushTxCache: {
params: {}
response: void
}
fetchTokenBalances: { fetchTokenBalances: {
params: { params: {
address: string address: string
@@ -281,12 +285,22 @@ type RPC = {
} }
} }
evmCreateWallet: { evmCreateWallet: {
params: { name: string; phrase: string; passwordHash?: string } params: {
name: string
phrase: string
passwordHash?: string
}
response: { id: string; name: string; createdAt: string } response: { id: string; name: string; createdAt: string }
} }
evmListWallets: { evmListWallets: {
params: {} params: {}
response: Array<{ id: string; name: string; hasPassword: boolean; vaultKey: string; createdAt: string }> response: Array<{
id: string
name: string
hasPassword: boolean
vaultKey: string
createdAt: string
}>
} }
evmGetSeed: { evmGetSeed: {
params: { vaultKey: string } params: { vaultKey: string }
@@ -412,10 +426,19 @@ const rpc = BrowserView.defineRPC<RPC>({
try { try {
const wallets = db.select().from(evmWallets).all() const wallets = db.select().from(evmWallets).all()
await Promise.all([ await Promise.all([
Bun.secrets.delete({ service: 'koins', name: 'alchemy_key' }), Bun.secrets.delete({
service: 'koins',
name: 'alchemy_key',
}),
...wallets.flatMap((w) => [ ...wallets.flatMap((w) => [
Bun.secrets.delete({ service: 'koins', name: w.vaultKey }), Bun.secrets.delete({
Bun.secrets.delete({ service: 'koins', name: `evm_auth_${w.id}` }), 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 token_metadata')
@@ -431,8 +454,11 @@ const rpc = BrowserView.defineRPC<RPC>({
} }
}, },
biometricCanAuth: async () => { biometricCanAuth: async () => {
console.log('[biometric] checking biometric...')
try { try {
return canPromptTouchID() const canPrompt = canPromptTouchID()
console.log('[biometric] biometric ===', canPrompt)
return canPrompt
} catch { } catch {
return false return false
} }
@@ -575,16 +601,27 @@ const rpc = BrowserView.defineRPC<RPC>({
return combined return combined
}, },
fetchCachedTxHistory: async ({ address, chainid }) => { fetchCachedTxHistory: async ({ address, chainid }) => {
const transfers = getCachedTransactionHistory(chainid, address) const transfers = await getCachedTransactionHistory(
chainid,
address,
)
return transfers.map(mapTransfer) return transfers.map(mapTransfer)
}, },
syncTxHistory: async ({ address, chainid }) => { syncTxHistory: async ({ address, chainid }) => {
const key = await Bun.secrets.get({ service: 'koins', name: 'alchemy_key' }) const key = await Bun.secrets.get({
service: 'koins',
name: 'alchemy_key',
})
if (!key) return if (!key) return
syncTransactionHistory(key, chainid, address).catch((e) => syncTransactionHistory(key, chainid, address).catch((e) =>
console.error('[sync] error:', e), console.error('[sync] error:', e),
) )
}, },
flushTxCache: async () => {
sqlite.run('DELETE FROM tx_history')
sqlite.run('DELETE FROM tx_sync_status')
console.log('[rpc] flushTxCache complete')
},
fetchTokenBalances: async ({ address, chainid }) => { fetchTokenBalances: async ({ address, chainid }) => {
const key = await Bun.secrets.get({ const key = await Bun.secrets.get({
service: 'koins', service: 'koins',
@@ -799,7 +836,11 @@ const rpc = BrowserView.defineRPC<RPC>({
console.log('[rpc] evmCreateWallet:', name) console.log('[rpc] evmCreateWallet:', name)
const id = crypto.randomUUID() const id = crypto.randomUUID()
const vaultKey = `evm_seed_${id}` const vaultKey = `evm_seed_${id}`
await Bun.secrets.set({ service: 'koins', name: vaultKey, value: phrase }) await Bun.secrets.set({
service: 'koins',
name: vaultKey,
value: phrase,
})
await db.insert(evmWallets).values({ await db.insert(evmWallets).values({
id, id,
name, name,
@@ -823,17 +864,30 @@ const rpc = BrowserView.defineRPC<RPC>({
}, },
evmGetSeed: async ({ vaultKey }) => { evmGetSeed: async ({ vaultKey }) => {
console.log('[rpc] evmGetSeed:', vaultKey) console.log('[rpc] evmGetSeed:', vaultKey)
const seed = await Bun.secrets.get({ service: 'koins', name: vaultKey }) const seed = await Bun.secrets.get({
service: 'koins',
name: vaultKey,
})
if (!seed) throw new Error('Seed not found in keychain') if (!seed) throw new Error('Seed not found in keychain')
return seed return seed
}, },
evmDeleteWallet: async ({ id }) => { evmDeleteWallet: async ({ id }) => {
console.log('[rpc] evmDeleteWallet:', id) console.log('[rpc] evmDeleteWallet:', id)
const wallet = db.select().from(evmWallets).where(eq(evmWallets.id, id)).get() const wallet = db
.select()
.from(evmWallets)
.where(eq(evmWallets.id, id))
.get()
if (!wallet) throw new Error('Wallet not found') if (!wallet) throw new Error('Wallet not found')
await Promise.all([ await Promise.all([
Bun.secrets.delete({ service: 'koins', name: wallet.vaultKey }), Bun.secrets.delete({
Bun.secrets.delete({ service: 'koins', name: `evm_auth_${id}` }), service: 'koins',
name: wallet.vaultKey,
}),
Bun.secrets.delete({
service: 'koins',
name: `evm_auth_${id}`,
}),
]) ])
db.delete(evmWallets).where(eq(evmWallets.id, id)).run() db.delete(evmWallets).where(eq(evmWallets.id, id)).run()
console.log('[rpc] evmDeleteWallet complete') console.log('[rpc] evmDeleteWallet complete')
@@ -68,8 +68,9 @@ export const getTransactionHistory = async (
let lastTimestamp = '' let lastTimestamp = ''
for (const t of transfers) { for (const t of transfers) {
if (!t.hash) continue if (!t.hash) continue
if (seen.has(t.hash)) continue const dedupKey = `${t.hash}_${t.category}_${t.rawContract?.address ?? ''}`
seen.add(t.hash) if (seen.has(dedupKey)) continue
seen.add(dedupKey)
all.push(t) all.push(t)
if (t.blockNum) { if (t.blockNum) {
@@ -87,7 +88,7 @@ export const getTransactionHistory = async (
console.log( console.log(
`[alchemy] ${direction} page ${page + 1}: ${transfers.length} transfers, ` + `[alchemy] ${direction} page ${page + 1}: ${transfers.length} transfers, ` +
`block ${pageMaxBlock}, ` + `block ${pageMaxBlock}, ` +
`${new Date(firstTimestamp).toISOString()} → ${new Date(lastTimestamp).toISOString()}`, `${firstTimestamp ? new Date(firstTimestamp).toISOString() : '?'} → ${lastTimestamp ? new Date(lastTimestamp).toISOString() : '?'}`,
) )
if (pageMaxBlock === 0) break if (pageMaxBlock === 0) break
@@ -1,7 +1,6 @@
import { eq, and, or, desc } from 'drizzle-orm' import { eq, and, or, desc } from 'drizzle-orm'
import { db } from '../db' import { db } from '../db'
import { txHistory } from '../db/schema' import { txHistory } from '../db/schema'
import { getTransactionHistory as fetchFromAlchemy } from '../alchemy/get-transaction-history'
import type { Transaction } from '../alchemy/types' import type { Transaction } from '../alchemy/types'
export const getTransactionHistory = async ( export const getTransactionHistory = async (
@@ -10,58 +9,7 @@ export const getTransactionHistory = async (
address: string, address: string,
opts: { maxCount: number }, opts: { maxCount: number },
) => { ) => {
const result = await fetchFromAlchemy(key, chainid, address, opts) return getCachedTransactionHistory(chainid, address)
if (result) {
for (const t of result) {
if (!t.hash) continue
const existing = await db.query.txHistory.findFirst({
where: {
hash: t.hash,
chainId: chainid,
},
})
if (!existing) {
await db.insert(txHistory).values({
chainId: chainid,
hash: t.hash,
blockNum: t.blockNum
? String(parseInt(t.blockNum, 16))
: null,
from: t.from?.toLowerCase() ?? null,
to: t.to?.toLowerCase() ?? null,
value: t.value?.toString() ?? null,
asset: t.asset ?? null,
category: t.category ?? null,
rawContractAddress:
t.rawContract?.address?.toLowerCase() ?? null,
rawContractDecimal: t.rawContract?.decimal ?? null,
metadataBlockTimestamp: t.metadata?.blockTimestamp ?? null,
raw: JSON.stringify(t),
syncedAt: new Date().toISOString(),
})
}
}
}
const addrLower = address.toLowerCase()
const rows = db
.select()
.from(txHistory)
.where(
and(
eq(txHistory.chainId, chainid),
or(
eq(txHistory.from, addrLower),
eq(txHistory.to, addrLower),
),
),
)
.orderBy(desc(txHistory.blockNum))
.all()
return rows.map(toTransfer)
} }
export const getCachedTransactionHistory = ( export const getCachedTransactionHistory = (
@@ -1,2 +1,5 @@
export { getTransactionHistory, getCachedTransactionHistory } from './get-history' export {
getTransactionHistory,
getCachedTransactionHistory,
} from './get-history'
export { getTransactionDetails } from './get-details' export { getTransactionDetails } from './get-details'
Binary file not shown.
@@ -3,7 +3,7 @@ import LocalAuthentication
let context = LAContext() let context = LAContext()
var error: NSError? var error: NSError?
let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) let available = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
let isCheck = CommandLine.arguments.contains("--check") let isCheck = CommandLine.arguments.contains("--check")
if isCheck { if isCheck {
@@ -19,7 +19,7 @@ guard available else {
let reason = CommandLine.arguments.last ?? "Authenticate" let reason = CommandLine.arguments.last ?? "Authenticate"
let sem = DispatchSemaphore(value: 0) let sem = DispatchSemaphore(value: 0)
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authError in context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
if success { if success {
exit(0) exit(0)
} }
+4
View File
@@ -145,6 +145,10 @@ type RPC = {
params: { address: string; chainid: string } params: { address: string; chainid: string }
response: void response: void
} }
flushTxCache: {
params: {}
response: void
}
fetchTokenBalances: { fetchTokenBalances: {
params: { params: {
address: string address: string
+162 -50
View File
@@ -1,6 +1,10 @@
import { mnemonicToAccount } from 'viem/accounts' import { mnemonicToAccount } from 'viem/accounts'
import { createPublicClient, http, formatEther } from 'viem' import { createPublicClient, http, formatEther } from 'viem'
import { electrobun, type TxEntry, type EvmWalletInfo } from '$lib/electrobun' import {
electrobun,
type TxEntry,
type EvmWalletInfo,
} from '$lib/electrobun'
import { tryCatch } from '@koins/utils' import { tryCatch } from '@koins/utils'
import { moneroWallet } from './monero-wallet.svelte.js' import { moneroWallet } from './monero-wallet.svelte.js'
@@ -55,6 +59,7 @@ export const EvmWallet = () => {
let transactions = $state<TxEntry[]>([]) let transactions = $state<TxEntry[]>([])
let loadingBalance = $state(false) let loadingBalance = $state(false)
let loadingTransactions = $state(false) let loadingTransactions = $state(false)
let loading = $state(false)
let error = $state('') let error = $state('')
let wallets = $state<EvmWalletInfo[]>([]) let wallets = $state<EvmWalletInfo[]>([])
let currentWalletId = $state<string | null>(null) let currentWalletId = $state<string | null>(null)
@@ -62,12 +67,36 @@ export const EvmWallet = () => {
const net = () => networks.find((n) => n.id === network)! const net = () => networks.find((n) => n.id === network)!
async function hashPassword(password: string, salt?: string): Promise<{ salt: string; hash: string }> { async function hashPassword(
const s = salt ?? Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('') password: string,
salt?: string,
): Promise<{ salt: string; hash: string }> {
const s =
salt ??
Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) =>
b.toString(16).padStart(2, '0'),
).join('')
const enc = new TextEncoder() const enc = new TextEncoder()
const key = await crypto.subtle.importKey('raw', enc.encode(password + s), 'PBKDF2', false, ['deriveBits']) const key = await crypto.subtle.importKey(
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100_000, hash: 'SHA-256' }, key, 256) 'raw',
const hash = Array.from(new Uint8Array(bits), b => b.toString(16).padStart(2, '0')).join('') enc.encode(password + s),
'PBKDF2',
false,
['deriveBits'],
)
const bits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: enc.encode(s),
iterations: 100_000,
hash: 'SHA-256',
},
key,
256,
)
const hash = Array.from(new Uint8Array(bits), (b) =>
b.toString(16).padStart(2, '0'),
).join('')
return { salt: s, hash } return { salt: s, hash }
} }
@@ -93,15 +122,19 @@ export const EvmWallet = () => {
} }
const [key] = await tryCatch( const [key] = await tryCatch(
electrobun.rpc.request.getSecret({ service: 'koins', name: 'alchemy_key' }), electrobun.rpc.request.getSecret({
service: 'koins',
name: 'alchemy_key',
}),
) )
if (key) apiKey = key if (key) apiKey = key
await moneroWallet.checkStatus() // await moneroWallet.checkStatus()
if (moneroWallet.installed && !moneroWallet.running && !moneroWallet.downloading) { // if (moneroWallet.installed && !moneroWallet.running && !moneroWallet.downloading) {
await moneroWallet.start() // await moneroWallet.start()
await moneroWallet.checkStatus() // await moneroWallet.checkStatus()
} // }
await checkBiometric() await checkBiometric()
ready = true ready = true
} }
@@ -141,7 +174,9 @@ export const EvmWallet = () => {
if (!wallet) return false if (!wallet) return false
currentWalletId = walletId currentWalletId = walletId
const [vaultSeed] = await tryCatch( const [vaultSeed] = await tryCatch(
electrobun.rpc.request.evmGetSeed({ vaultKey: wallet.vaultKey }), electrobun.rpc.request.evmGetSeed({
vaultKey: wallet.vaultKey,
}),
) )
if (!vaultSeed) return false if (!vaultSeed) return false
seed = vaultSeed seed = vaultSeed
@@ -152,7 +187,9 @@ export const EvmWallet = () => {
const unlockWithBiometrics = async () => { const unlockWithBiometrics = async () => {
if (!electrobun.rpc) return false if (!electrobun.rpc) return false
const [authed] = await tryCatch( const [authed] = await tryCatch(
electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }), electrobun.rpc.request.biometricAuth({
reason: 'Unlock wallet',
}),
) )
if (!authed) return false if (!authed) return false
if (!currentWalletId) return false if (!currentWalletId) return false
@@ -177,7 +214,9 @@ export const EvmWallet = () => {
const wallet = wallets.find((w) => w.id === walletId) const wallet = wallets.find((w) => w.id === walletId)
if (!wallet) return false if (!wallet) return false
const [vaultSeed] = await tryCatch( const [vaultSeed] = await tryCatch(
electrobun.rpc.request.evmGetSeed({ vaultKey: wallet.vaultKey }), electrobun.rpc.request.evmGetSeed({
vaultKey: wallet.vaultKey,
}),
) )
if (!vaultSeed) return false if (!vaultSeed) return false
seed = vaultSeed seed = vaultSeed
@@ -213,7 +252,11 @@ export const EvmWallet = () => {
return false return false
} }
const createWallet = async (name: string, phrase: string, password?: string) => { const createWallet = async (
name: string,
phrase: string,
password?: string,
) => {
loading = true loading = true
error = '' error = ''
try { try {
@@ -288,7 +331,6 @@ export const EvmWallet = () => {
const client = createPublicClient({ transport: http(net().rpc) }) const client = createPublicClient({ transport: http(net().rpc) })
syncTxHistory() syncTxHistory()
;(async () => { ;(async () => {
const [nativeBal] = await tryCatch( const [nativeBal] = await tryCatch(
client.getBalance({ address: account.address }), client.getBalance({ address: account.address }),
@@ -296,13 +338,13 @@ export const EvmWallet = () => {
if (nativeBal) balance = formatEther(nativeBal) if (nativeBal) balance = formatEther(nativeBal)
const [bals] = await tryCatch( const [bals] = await tryCatch(
electrobun.rpc.request.fetchTokenBalances({ electrobun.rpc.request.fetchTokenBalances({
address, chainid: net().chainid, address,
chainid: net().chainid,
}), }),
) )
if (bals) tokenBalances = bals if (bals) tokenBalances = bals
loadingBalance = false loadingBalance = false
})() })()
;(async () => { ;(async () => {
await fetchTxHistory() await fetchTxHistory()
loadingTransactions = false loadingTransactions = false
@@ -311,12 +353,22 @@ export const EvmWallet = () => {
const syncTxHistory = () => { const syncTxHistory = () => {
if (!address || !apiKey || !electrobun.rpc) return if (!address || !apiKey || !electrobun.rpc) return
electrobun.rpc.request.syncTxHistory({ address, chainid: net().chainid }) electrobun.rpc.request.syncTxHistory({
address,
chainid: net().chainid,
})
}
const flushTxCache = async () => {
await electrobun.rpc?.request.flushTxCache({})
await fetchTxHistory()
} }
const saveApiKey = async (key: string) => { const saveApiKey = async (key: string) => {
await electrobun.rpc?.request.setSecret({ await electrobun.rpc?.request.setSecret({
service: 'koins', name: 'alchemy_key', value: key, service: 'koins',
name: 'alchemy_key',
value: key,
}) })
apiKey = key apiKey = key
syncTxHistory() syncTxHistory()
@@ -329,7 +381,8 @@ export const EvmWallet = () => {
return return
} }
const txs = await electrobun.rpc?.request.fetchCachedTxHistory({ const txs = await electrobun.rpc?.request.fetchCachedTxHistory({
address, chainid: net().chainid, address,
chainid: net().chainid,
}) })
transactions = txs ?? [] transactions = txs ?? []
} }
@@ -354,34 +407,92 @@ export const EvmWallet = () => {
} }
return { return {
get accountType() { return accountType }, get accountType() {
get ready() { return ready }, return accountType
get biometricAvailable() { return biometricAvailable }, },
get seed() { return seed }, get ready() {
get address() { return address }, return ready
get balance() { return balance }, },
get tokenBalances() { return tokenBalances }, get biometricAvailable() {
get network() { return network }, return biometricAvailable
get chainid() { return net().chainid }, },
get networkName() { return net().name }, get seed() {
get symbol() { return net().symbol }, return seed
get explorerUrl() { return net().explorerUrl }, },
get explorerAddressUrl() { return address ? `${net().explorerUrl.replace('/tx/', '/address/')}${address}` : '' }, get address() {
get networks() { return networks }, return address
get apiKey() { return apiKey }, },
get transactions() { return transactions }, get balance() {
get loading() { return loadingBalance || loadingTransactions }, return balance
get loadingBalance() { return loadingBalance }, },
get loadingTransactions() { return loadingTransactions }, get tokenBalances() {
get error() { return error }, return tokenBalances
get wallets() { return wallets }, },
get currentWalletId() { return currentWalletId }, get network() {
get currentWallet() { return wallets.find((w) => w.id === currentWalletId) ?? null }, return network
get isLocked() { return !!currentWalletId && !seed }, },
get hasWallets() { return wallets.length > 0 }, get chainid() {
get currentPasswordHash() { return currentPasswordHash }, return net().chainid
set apiKey(v: string) { apiKey = v }, },
set error(v: string) { error = v }, get networkName() {
return net().name
},
get symbol() {
return net().symbol
},
get explorerUrl() {
return net().explorerUrl
},
get explorerAddressUrl() {
return address
? `${net().explorerUrl.replace('/tx/', '/address/')}${address}`
: ''
},
get networks() {
return networks
},
get apiKey() {
return apiKey
},
get transactions() {
return transactions
},
get loading() {
return loading || loadingBalance || loadingTransactions
},
get loadingBalance() {
return loadingBalance
},
get loadingTransactions() {
return loadingTransactions
},
get error() {
return error
},
get wallets() {
return wallets
},
get currentWalletId() {
return currentWalletId
},
get currentWallet() {
return wallets.find((w) => w.id === currentWalletId) ?? null
},
get isLocked() {
return !!currentWalletId && !seed
},
get hasWallets() {
return wallets.length > 0
},
get currentPasswordHash() {
return currentPasswordHash
},
set apiKey(v: string) {
apiKey = v
},
set error(v: string) {
error = v
},
init, init,
login, login,
logout, logout,
@@ -401,6 +512,7 @@ export const EvmWallet = () => {
deleteWallet, deleteWallet,
clearSelection, clearSelection,
syncTxHistory, syncTxHistory,
flushTxCache,
} }
} }
+18
View File
@@ -17,6 +17,7 @@
let confirm = $state(false) let confirm = $state(false)
let resetting = $state(false) let resetting = $state(false)
let deletingWalletId = $state<string | null>(null) let deletingWalletId = $state<string | null>(null)
let flushing = $state(false)
async function handleReset() { async function handleReset() {
resetting = true resetting = true
@@ -31,6 +32,12 @@
deletingWalletId = null deletingWalletId = null
if (w.wallets.length === 0) navigate('/multicoin') if (w.wallets.length === 0) navigate('/multicoin')
} }
async function handleFlushCache() {
flushing = true
await w.flushTxCache()
flushing = false
}
</script> </script>
<div class="mx-auto mt-16 max-w-md"> <div class="mx-auto mt-16 max-w-md">
@@ -84,6 +91,17 @@
<CardDescription>App preferences and data management</CardDescription> <CardDescription>App preferences and data management</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div>
<p class="font-medium text-xs">Transaction Cache</p>
<p class="text-muted-foreground text-xs mt-1">
Clears all cached transaction history and sync status. Data will be re-fetched from Alchemy on next refresh.
</p>
<div class="mt-3">
<Button variant="outline" onclick={handleFlushCache} disabled={flushing}>
{flushing ? 'Flushing...' : 'Flush Transaction Cache'}
</Button>
</div>
</div>
<div> <div>
<p class="font-medium text-xs">Reset App</p> <p class="font-medium text-xs">Reset App</p>
<p class="text-muted-foreground text-xs mt-1"> <p class="text-muted-foreground text-xs mt-1">