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,
} from './lib/db/schema'
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()
@@ -110,8 +115,6 @@ const NATIVE_SYMBOLS: Record<string, string> = {
'56': 'BNB',
}
async function fetchAlchemyTransfers(
key: string,
chainid: string,
@@ -227,11 +230,10 @@ async function pairTransfers(
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',
(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,
@@ -253,8 +255,7 @@ async function pairTransfers(
tokenDecimal: tokIn[0].rawContract?.decimal
? String(parseInt(tokIn[0].rawContract.decimal, 16))
: undefined,
contractAddress:
tokIn[0].rawContract?.address ?? undefined,
contractAddress: tokIn[0].rawContract?.address ?? undefined,
pairedValue: String(tokIn[0].value),
pairedSymbol: tokIn[0].asset,
pairedDecimals: tokIn[0].rawContract?.decimal
@@ -282,9 +283,7 @@ async function pairTransfers(
combined.push({
...mapTransfer(tokOut[0]),
pairedValue: String(extIn[0].value),
pairedSymbol: chainid
? NATIVE_SYMBOLS[chainid]
: undefined,
pairedSymbol: chainid ? NATIVE_SYMBOLS[chainid] : undefined,
})
} else if (tokOut.length > 0) {
combined.push(mapTransfer(tokOut[0]))
@@ -305,15 +304,12 @@ async function pairTransfers(
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)
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(),
)
entry.logo = logoMap.get(entry.contractAddress.toLowerCase())
if (entry.pairedContractAddress)
entry.pairedLogo = logoMap.get(
entry.pairedContractAddress.toLowerCase(),
@@ -321,9 +317,7 @@ async function pairTransfers(
}
}
combined.sort(
(a, b) => Number(b.timeStamp) - Number(a.timeStamp),
)
combined.sort((a, b) => Number(b.timeStamp) - Number(a.timeStamp))
return combined
}
@@ -497,6 +491,55 @@ const rpc = BrowserView.defineRPC<RPC>({
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 }) => {
const key = await Bun.secrets.get({
service: 'koins',
@@ -520,20 +563,11 @@ const rpc = BrowserView.defineRPC<RPC>({
.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
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
@@ -666,9 +700,7 @@ const rpc = BrowserView.defineRPC<RPC>({
if (!moneroManager)
throw new Error('Monero wallet RPC not started')
const txs = await moneroManager.getTransactions(accountIndex)
console.log(
`[rpc] moneroGetTransactions: ${txs.length} txs`,
)
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
return txs
},
moneroWalletStatus: async () => {
@@ -710,7 +742,12 @@ const rpc = BrowserView.defineRPC<RPC>({
)
return wallets
},
moneroTransfer: async ({ address, amount, priority, accountIndex }) => {
moneroTransfer: async ({
address,
amount,
priority,
accountIndex,
}) => {
console.log('[rpc] moneroTransfer:', {
address,
amount,
@@ -732,7 +769,10 @@ const rpc = BrowserView.defineRPC<RPC>({
if (!moneroManager)
throw new Error('Monero wallet RPC not started')
const details = await moneroManager.getTransferDetails(txid)
console.log('[rpc] moneroGetTransferDetails complete:', details?.hash)
console.log(
'[rpc] moneroGetTransferDetails complete:',
details?.hash,
)
return details
},
evmCreateWallet: async ({ name, phrase, passwordHash }) => {
@@ -81,7 +81,49 @@
return `/multicoin/${id}`
}
const CHAIN_NETWORKS: Record<string, string> = {
'1': 'eth-mainnet',
'137': 'polygon-mainnet',
'56': 'bnb-mainnet',
}
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(() => {
if (!initStarted) {
initStarted = true
@@ -343,11 +385,17 @@
{w.balance}
{w.symbol}
</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 class="mb-4 space-y-1">
<p class="font-medium text-xs">Tokens</p>
{#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
src={t.logo}
alt=""
@@ -359,7 +407,14 @@
? '<0.0001'
: Number(t.balance).toFixed(4)}
{t.symbol}
</p>
</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>
{/if}
{/each}
</div>
{/if}
+10
View File
@@ -116,6 +116,15 @@ export type TokenBalanceResult = {
logo?: string
}
export type TokenPriceEntry = {
symbol: string
currency: string
value: string
lastUpdatedAt: string
network?: string
address?: string
}
export type EvmWalletInfo = {
id: string
name: string
@@ -159,6 +168,7 @@ export type RPC = {
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[] }
}
messages: {}
}