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 }
response: void
}
flushTxCache: {
params: {}
response: void
}
fetchTokenBalances: {
params: {
address: string
@@ -281,12 +285,22 @@ type RPC = {
}
}
evmCreateWallet: {
params: { name: string; phrase: string; passwordHash?: string }
params: {
name: string
phrase: string
passwordHash?: string
}
response: { id: string; name: string; createdAt: string }
}
evmListWallets: {
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: {
params: { vaultKey: string }
@@ -412,10 +426,19 @@ const rpc = BrowserView.defineRPC<RPC>({
try {
const wallets = db.select().from(evmWallets).all()
await Promise.all([
Bun.secrets.delete({ service: 'koins', name: 'alchemy_key' }),
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}` }),
Bun.secrets.delete({
service: 'koins',
name: w.vaultKey,
}),
Bun.secrets.delete({
service: 'koins',
name: `evm_auth_${w.id}`,
}),
]),
])
sqlite.run('DELETE FROM token_metadata')
@@ -431,8 +454,11 @@ const rpc = BrowserView.defineRPC<RPC>({
}
},
biometricCanAuth: async () => {
console.log('[biometric] checking biometric...')
try {
return canPromptTouchID()
const canPrompt = canPromptTouchID()
console.log('[biometric] biometric ===', canPrompt)
return canPrompt
} catch {
return false
}
@@ -575,16 +601,27 @@ const rpc = BrowserView.defineRPC<RPC>({
return combined
},
fetchCachedTxHistory: async ({ address, chainid }) => {
const transfers = getCachedTransactionHistory(chainid, address)
const transfers = await getCachedTransactionHistory(
chainid,
address,
)
return transfers.map(mapTransfer)
},
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
syncTransactionHistory(key, chainid, address).catch((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 }) => {
const key = await Bun.secrets.get({
service: 'koins',
@@ -799,7 +836,11 @@ const rpc = BrowserView.defineRPC<RPC>({
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 Bun.secrets.set({
service: 'koins',
name: vaultKey,
value: phrase,
})
await db.insert(evmWallets).values({
id,
name,
@@ -823,17 +864,30 @@ const rpc = BrowserView.defineRPC<RPC>({
},
evmGetSeed: async ({ 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')
return seed
},
evmDeleteWallet: async ({ 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')
await Promise.all([
Bun.secrets.delete({ service: 'koins', name: wallet.vaultKey }),
Bun.secrets.delete({ service: 'koins', name: `evm_auth_${id}` }),
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')
@@ -68,8 +68,9 @@ export const getTransactionHistory = async (
let lastTimestamp = ''
for (const t of transfers) {
if (!t.hash) continue
if (seen.has(t.hash)) continue
seen.add(t.hash)
const dedupKey = `${t.hash}_${t.category}_${t.rawContract?.address ?? ''}`
if (seen.has(dedupKey)) continue
seen.add(dedupKey)
all.push(t)
if (t.blockNum) {
@@ -87,7 +88,7 @@ export const getTransactionHistory = async (
console.log(
`[alchemy] ${direction} page ${page + 1}: ${transfers.length} transfers, ` +
`block ${pageMaxBlock}, ` +
`${new Date(firstTimestamp).toISOString()} → ${new Date(lastTimestamp).toISOString()}`,
`${firstTimestamp ? new Date(firstTimestamp).toISOString() : '?'} → ${lastTimestamp ? new Date(lastTimestamp).toISOString() : '?'}`,
)
if (pageMaxBlock === 0) break
@@ -1,7 +1,6 @@
import { eq, and, or, desc } from 'drizzle-orm'
import { db } from '../db'
import { txHistory } from '../db/schema'
import { getTransactionHistory as fetchFromAlchemy } from '../alchemy/get-transaction-history'
import type { Transaction } from '../alchemy/types'
export const getTransactionHistory = async (
@@ -10,58 +9,7 @@ export const getTransactionHistory = async (
address: string,
opts: { maxCount: number },
) => {
const result = await fetchFromAlchemy(key, chainid, address, opts)
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)
return getCachedTransactionHistory(chainid, address)
}
export const getCachedTransactionHistory = (
@@ -1,2 +1,5 @@
export { getTransactionHistory, getCachedTransactionHistory } from './get-history'
export {
getTransactionHistory,
getCachedTransactionHistory,
} from './get-history'
export { getTransactionDetails } from './get-details'
Binary file not shown.
@@ -3,7 +3,7 @@ import LocalAuthentication
let context = LAContext()
var error: NSError?
let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
let available = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
let isCheck = CommandLine.arguments.contains("--check")
if isCheck {
@@ -19,7 +19,7 @@ guard available else {
let reason = CommandLine.arguments.last ?? "Authenticate"
let sem = DispatchSemaphore(value: 0)
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authError in
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
if success {
exit(0)
}
+4
View File
@@ -145,6 +145,10 @@ type RPC = {
params: { address: string; chainid: string }
response: void
}
flushTxCache: {
params: {}
response: void
}
fetchTokenBalances: {
params: {
address: string
+162 -50
View File
@@ -1,6 +1,10 @@
import { mnemonicToAccount } from 'viem/accounts'
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 { moneroWallet } from './monero-wallet.svelte.js'
@@ -55,6 +59,7 @@ export const EvmWallet = () => {
let transactions = $state<TxEntry[]>([])
let loadingBalance = $state(false)
let loadingTransactions = $state(false)
let loading = $state(false)
let error = $state('')
let wallets = $state<EvmWalletInfo[]>([])
let currentWalletId = $state<string | null>(null)
@@ -62,12 +67,36 @@ export const EvmWallet = () => {
const net = () => networks.find((n) => n.id === network)!
async function hashPassword(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('')
async function hashPassword(
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 key = await crypto.subtle.importKey('raw', 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('')
const key = await crypto.subtle.importKey(
'raw',
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 }
}
@@ -93,15 +122,19 @@ export const EvmWallet = () => {
}
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
await moneroWallet.checkStatus()
if (moneroWallet.installed && !moneroWallet.running && !moneroWallet.downloading) {
await moneroWallet.start()
await moneroWallet.checkStatus()
}
// await moneroWallet.checkStatus()
// if (moneroWallet.installed && !moneroWallet.running && !moneroWallet.downloading) {
// await moneroWallet.start()
// await moneroWallet.checkStatus()
// }
await checkBiometric()
ready = true
}
@@ -141,7 +174,9 @@ export const EvmWallet = () => {
if (!wallet) return false
currentWalletId = walletId
const [vaultSeed] = await tryCatch(
electrobun.rpc.request.evmGetSeed({ vaultKey: wallet.vaultKey }),
electrobun.rpc.request.evmGetSeed({
vaultKey: wallet.vaultKey,
}),
)
if (!vaultSeed) return false
seed = vaultSeed
@@ -152,7 +187,9 @@ export const EvmWallet = () => {
const unlockWithBiometrics = async () => {
if (!electrobun.rpc) return false
const [authed] = await tryCatch(
electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }),
electrobun.rpc.request.biometricAuth({
reason: 'Unlock wallet',
}),
)
if (!authed) return false
if (!currentWalletId) return false
@@ -177,7 +214,9 @@ export const EvmWallet = () => {
const wallet = wallets.find((w) => w.id === walletId)
if (!wallet) return false
const [vaultSeed] = await tryCatch(
electrobun.rpc.request.evmGetSeed({ vaultKey: wallet.vaultKey }),
electrobun.rpc.request.evmGetSeed({
vaultKey: wallet.vaultKey,
}),
)
if (!vaultSeed) return false
seed = vaultSeed
@@ -213,7 +252,11 @@ export const EvmWallet = () => {
return false
}
const createWallet = async (name: string, phrase: string, password?: string) => {
const createWallet = async (
name: string,
phrase: string,
password?: string,
) => {
loading = true
error = ''
try {
@@ -288,7 +331,6 @@ export const EvmWallet = () => {
const client = createPublicClient({ transport: http(net().rpc) })
syncTxHistory()
;(async () => {
const [nativeBal] = await tryCatch(
client.getBalance({ address: account.address }),
@@ -296,13 +338,13 @@ export const EvmWallet = () => {
if (nativeBal) balance = formatEther(nativeBal)
const [bals] = await tryCatch(
electrobun.rpc.request.fetchTokenBalances({
address, chainid: net().chainid,
address,
chainid: net().chainid,
}),
)
if (bals) tokenBalances = bals
loadingBalance = false
})()
;(async () => {
await fetchTxHistory()
loadingTransactions = false
@@ -311,12 +353,22 @@ export const EvmWallet = () => {
const syncTxHistory = () => {
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) => {
await electrobun.rpc?.request.setSecret({
service: 'koins', name: 'alchemy_key', value: key,
service: 'koins',
name: 'alchemy_key',
value: key,
})
apiKey = key
syncTxHistory()
@@ -329,7 +381,8 @@ export const EvmWallet = () => {
return
}
const txs = await electrobun.rpc?.request.fetchCachedTxHistory({
address, chainid: net().chainid,
address,
chainid: net().chainid,
})
transactions = txs ?? []
}
@@ -354,34 +407,92 @@ export const EvmWallet = () => {
}
return {
get accountType() { return accountType },
get ready() { return ready },
get biometricAvailable() { return biometricAvailable },
get seed() { return seed },
get address() { return address },
get balance() { return balance },
get tokenBalances() { return tokenBalances },
get network() { return network },
get chainid() { return net().chainid },
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 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 },
get accountType() {
return accountType
},
get ready() {
return ready
},
get biometricAvailable() {
return biometricAvailable
},
get seed() {
return seed
},
get address() {
return address
},
get balance() {
return balance
},
get tokenBalances() {
return tokenBalances
},
get network() {
return network
},
get chainid() {
return net().chainid
},
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,
login,
logout,
@@ -401,6 +512,7 @@ export const EvmWallet = () => {
deleteWallet,
clearSelection,
syncTxHistory,
flushTxCache,
}
}
+18
View File
@@ -17,6 +17,7 @@
let confirm = $state(false)
let resetting = $state(false)
let deletingWalletId = $state<string | null>(null)
let flushing = $state(false)
async function handleReset() {
resetting = true
@@ -31,6 +32,12 @@
deletingWalletId = null
if (w.wallets.length === 0) navigate('/multicoin')
}
async function handleFlushCache() {
flushing = true
await w.flushTxCache()
flushing = false
}
</script>
<div class="mx-auto mt-16 max-w-md">
@@ -84,6 +91,17 @@
<CardDescription>App preferences and data management</CardDescription>
</CardHeader>
<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>
<p class="font-medium text-xs">Reset App</p>
<p class="text-muted-foreground text-xs mt-1">