Switch token price lookup from symbol to address for ERC-20 tokens; keep symbol for native

This commit is contained in:
2026-05-25 15:03:41 +08:00
parent 93dec3b71f
commit f0b567e895
3 changed files with 146 additions and 41 deletions
+79 -39
View File
@@ -38,7 +38,12 @@ import {
evmWallets, evmWallets,
} from './lib/db/schema' } from './lib/db/schema'
import { syncTxDetails } from './lib/sync/sync-tx-details' import { syncTxDetails } from './lib/sync/sync-tx-details'
import type { RPC, TokenBalanceResult, TxEntry } from '../lib/rpc-schema' import type {
RPC,
TokenBalanceResult,
TokenPriceEntry,
TxEntry,
} from '../lib/rpc-schema'
const env = getENV() const env = getENV()
@@ -110,8 +115,6 @@ const NATIVE_SYMBOLS: Record<string, string> = {
'56': 'BNB', '56': 'BNB',
} }
async function fetchAlchemyTransfers( async function fetchAlchemyTransfers(
key: string, key: string,
chainid: string, chainid: string,
@@ -227,11 +230,10 @@ async function pairTransfers(
for (const [, group] of byHash) { for (const [, group] of byHash) {
const native = group.filter( const native = group.filter(
(t: any) => t.category === 'external' || t.category === 'internal', (t: any) =>
) t.category === 'external' || t.category === 'internal',
const tokens = group.filter(
(t: any) => t.category === 'erc20',
) )
const tokens = group.filter((t: any) => t.category === 'erc20')
const extOut = native.filter( const extOut = native.filter(
(t: any) => t.from?.toLowerCase() === addrLower, (t: any) => t.from?.toLowerCase() === addrLower,
@@ -253,8 +255,7 @@ async function pairTransfers(
tokenDecimal: tokIn[0].rawContract?.decimal tokenDecimal: tokIn[0].rawContract?.decimal
? String(parseInt(tokIn[0].rawContract.decimal, 16)) ? String(parseInt(tokIn[0].rawContract.decimal, 16))
: undefined, : undefined,
contractAddress: contractAddress: tokIn[0].rawContract?.address ?? undefined,
tokIn[0].rawContract?.address ?? undefined,
pairedValue: String(tokIn[0].value), pairedValue: String(tokIn[0].value),
pairedSymbol: tokIn[0].asset, pairedSymbol: tokIn[0].asset,
pairedDecimals: tokIn[0].rawContract?.decimal pairedDecimals: tokIn[0].rawContract?.decimal
@@ -282,9 +283,7 @@ async function pairTransfers(
combined.push({ combined.push({
...mapTransfer(tokOut[0]), ...mapTransfer(tokOut[0]),
pairedValue: String(extIn[0].value), pairedValue: String(extIn[0].value),
pairedSymbol: chainid pairedSymbol: chainid ? NATIVE_SYMBOLS[chainid] : undefined,
? NATIVE_SYMBOLS[chainid]
: undefined,
}) })
} else if (tokOut.length > 0) { } else if (tokOut.length > 0) {
combined.push(mapTransfer(tokOut[0])) combined.push(mapTransfer(tokOut[0]))
@@ -305,15 +304,12 @@ async function pairTransfers(
await Promise.all( await Promise.all(
Array.from(addrSet).map(async (addr) => { Array.from(addrSet).map(async (addr) => {
const meta = await getTokenMetadata(key, chainid, addr) const meta = await getTokenMetadata(key, chainid, addr)
if (meta?.result?.logo) if (meta?.result?.logo) logoMap.set(addr, meta.result.logo)
logoMap.set(addr, meta.result.logo)
}), }),
) )
for (const entry of combined) { for (const entry of combined) {
if (entry.contractAddress) if (entry.contractAddress)
entry.logo = logoMap.get( entry.logo = logoMap.get(entry.contractAddress.toLowerCase())
entry.contractAddress.toLowerCase(),
)
if (entry.pairedContractAddress) if (entry.pairedContractAddress)
entry.pairedLogo = logoMap.get( entry.pairedLogo = logoMap.get(
entry.pairedContractAddress.toLowerCase(), entry.pairedContractAddress.toLowerCase(),
@@ -321,9 +317,7 @@ async function pairTransfers(
} }
} }
combined.sort( combined.sort((a, b) => Number(b.timeStamp) - Number(a.timeStamp))
(a, b) => Number(b.timeStamp) - Number(a.timeStamp),
)
return combined return combined
} }
@@ -497,6 +491,55 @@ const rpc = BrowserView.defineRPC<RPC>({
return [] 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 []
}
},
fetchTransactionDetails: async ({ hash, chainid, address }) => { fetchTransactionDetails: async ({ hash, chainid, address }) => {
const key = await Bun.secrets.get({ const key = await Bun.secrets.get({
service: 'koins', service: 'koins',
@@ -520,20 +563,11 @@ const rpc = BrowserView.defineRPC<RPC>({
.all() .all()
if (transfers.length > 0) { if (transfers.length > 0) {
const paired = await pairTransfers( const paired = await pairTransfers(transfers, address)
transfers, if (paired.length > 0 && paired[0].pairedValue) {
address, details.pairedValue = paired[0].pairedValue
) details.pairedSymbol = paired[0].pairedSymbol
if ( details.pairedDecimals = paired[0].pairedDecimals
paired.length > 0 &&
paired[0].pairedValue
) {
details.pairedValue =
paired[0].pairedValue
details.pairedSymbol =
paired[0].pairedSymbol
details.pairedDecimals =
paired[0].pairedDecimals
details.pairedContractAddress = details.pairedContractAddress =
paired[0].pairedContractAddress paired[0].pairedContractAddress
details.pairedLogo = paired[0].pairedLogo details.pairedLogo = paired[0].pairedLogo
@@ -666,9 +700,7 @@ const rpc = BrowserView.defineRPC<RPC>({
if (!moneroManager) if (!moneroManager)
throw new Error('Monero wallet RPC not started') throw new Error('Monero wallet RPC not started')
const txs = await moneroManager.getTransactions(accountIndex) const txs = await moneroManager.getTransactions(accountIndex)
console.log( console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
`[rpc] moneroGetTransactions: ${txs.length} txs`,
)
return txs return txs
}, },
moneroWalletStatus: async () => { moneroWalletStatus: async () => {
@@ -710,7 +742,12 @@ const rpc = BrowserView.defineRPC<RPC>({
) )
return wallets return wallets
}, },
moneroTransfer: async ({ address, amount, priority, accountIndex }) => { moneroTransfer: async ({
address,
amount,
priority,
accountIndex,
}) => {
console.log('[rpc] moneroTransfer:', { console.log('[rpc] moneroTransfer:', {
address, address,
amount, amount,
@@ -732,7 +769,10 @@ const rpc = BrowserView.defineRPC<RPC>({
if (!moneroManager) if (!moneroManager)
throw new Error('Monero wallet RPC not started') throw new Error('Monero wallet RPC not started')
const details = await moneroManager.getTransferDetails(txid) const details = await moneroManager.getTransferDetails(txid)
console.log('[rpc] moneroGetTransferDetails complete:', details?.hash) console.log(
'[rpc] moneroGetTransferDetails complete:',
details?.hash,
)
return details return details
}, },
evmCreateWallet: async ({ name, phrase, passwordHash }) => { evmCreateWallet: async ({ name, phrase, passwordHash }) => {
@@ -81,7 +81,49 @@
return `/multicoin/${id}` return `/multicoin/${id}`
} }
const CHAIN_NETWORKS: Record<string, string> = {
'1': 'eth-mainnet',
'137': 'polygon-mainnet',
'56': 'bnb-mainnet',
}
let initStarted = false let initStarted = false
let nativePrice = $state<{
value: string
currency: string
} | null>(null)
let tokenPrices = $state<Record<string, { value: string; currency: string }>>({})
$effect(() => {
const syms: string[] = w.symbol ? [w.symbol] : []
const addrs: { network: string; address: string }[] = []
const network = CHAIN_NETWORKS[w.chainid]
if (network) {
for (const t of w.tokenBalances) {
if (t.contractAddress) {
addrs.push({ network, address: t.contractAddress })
}
}
}
if (syms.length > 0 || addrs.length > 0) {
electrobun.rpc?.request
.fetchTokenPrices({ symbols: syms.length > 0 ? syms : undefined, addresses: addrs.length > 0 ? addrs : undefined })
.then((prices) => {
const usd = prices.find((p) => p.currency === 'usd' && p.symbol === w.symbol && !p.address)
nativePrice = usd
? { value: usd.value, currency: usd.currency }
: null
const map: Record<string, { value: string; currency: string }> = {}
for (const p of prices) {
if (p.currency === 'usd' && p.address) {
map[p.address.toLowerCase()] = { value: p.value, currency: p.currency }
}
}
tokenPrices = map
})
}
})
$effect(() => { $effect(() => {
if (!initStarted) { if (!initStarted) {
initStarted = true initStarted = true
@@ -343,11 +385,17 @@
{w.balance} {w.balance}
{w.symbol} {w.symbol}
</p> </p>
{#if nativePrice}
{@const balVal = Number(w.balance) * Number(nativePrice.value)}
<p class="font-mono text-xs text-muted-foreground">
${balVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} @ ${Number(nativePrice.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/{w.symbol}
</p>
{/if}
</div> </div>
<div class="mb-4 space-y-1"> <div class="mb-4 space-y-1">
<p class="font-medium text-xs">Tokens</p> <p class="font-medium text-xs">Tokens</p>
{#each w.tokenBalances as t} {#each w.tokenBalances as t}
<p class="flex items-center gap-1.5 font-mono text-sm"> <div class="flex items-center gap-1.5 font-mono text-sm">
<img <img
src={t.logo} src={t.logo}
alt="" alt=""
@@ -359,7 +407,14 @@
? '<0.0001' ? '<0.0001'
: Number(t.balance).toFixed(4)} : Number(t.balance).toFixed(4)}
{t.symbol} {t.symbol}
</div>
{#if t.contractAddress && tokenPrices[t.contractAddress.toLowerCase()]}
{@const val = tokenPrices[t.contractAddress.toLowerCase()]}
{@const balVal = Number(t.balance) * Number(val.value)}
<p class="font-mono text-xs text-muted-foreground -mt-0.5 ml-6">
${balVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} @ ${Number(val.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/{t.symbol}
</p> </p>
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}
+10
View File
@@ -116,6 +116,15 @@ export type TokenBalanceResult = {
logo?: string logo?: string
} }
export type TokenPriceEntry = {
symbol: string
currency: string
value: string
lastUpdatedAt: string
network?: string
address?: string
}
export type EvmWalletInfo = { export type EvmWalletInfo = {
id: string id: string
name: string name: string
@@ -159,6 +168,7 @@ export type RPC = {
evmListWallets: { params: {}; response: EvmWalletInfo[] } evmListWallets: { params: {}; response: EvmWalletInfo[] }
evmGetSeed: { params: { vaultKey: string }; response: string } evmGetSeed: { params: { vaultKey: string }; response: string }
evmDeleteWallet: { params: { id: string }; response: void } evmDeleteWallet: { params: { id: string }; response: void }
fetchTokenPrices: { params: { symbols?: string[]; addresses?: { network: string; address: string }[] }; response: TokenPriceEntry[] }
} }
messages: {} messages: {}
} }