add transaction view page

This commit is contained in:
2026-05-16 14:21:49 +08:00
parent cae5d4023f
commit f5ddc8df1b
9 changed files with 365 additions and 24 deletions
+80 -1
View File
@@ -16,6 +16,7 @@ import { migrate } from 'drizzle-orm/bun-sqlite/migrator'
import { db } from './lib/db'
import { join } from 'path'
import { getENV } from './lib/get-env'
import { getClient, getTransactionDetails } from './lib/viem'
const env = getENV()
@@ -88,6 +89,20 @@ type TokenBalanceResult = {
logo?: string
}
type TxDetailsResult = {
hash: string
from: string
to: string | null
value: string
blockNumber: string | null
fee: string
gasPrice: string
status: 'success' | 'reverted'
type: string
nonce: number
input: string
}
type TxEntry = {
hash: string
timeStamp: string
@@ -97,9 +112,12 @@ type TxEntry = {
tokenSymbol?: string
tokenDecimal?: string
contractAddress?: string
logo?: string
pairedValue?: string
pairedSymbol?: string
pairedDecimals?: string
pairedContractAddress?: string
pairedLogo?: string
}
type RPC = {
@@ -134,6 +152,13 @@ type RPC = {
}
response: TokenBalanceResult[]
}
fetchTransactionDetails: {
params: {
hash: string
chainid: string
}
response: TxDetailsResult | null
}
openExternal: {
params: {
url: string
@@ -265,7 +290,14 @@ const rpc = BrowserView.defineRPC<RPC>({
})
if (!key) return []
const all = await fetchAlchemyTransfers(key, chainid, address)
const [all, allError] = await tryCatch(
fetchAlchemyTransfers(key, chainid, address),
)
if (allError) {
console.log(allError)
return []
}
const addrLower = address.toLowerCase()
const byHash = new Map<string, any[]>()
@@ -312,6 +344,8 @@ const rpc = BrowserView.defineRPC<RPC>({
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]))
@@ -325,6 +359,8 @@ const rpc = BrowserView.defineRPC<RPC>({
pairedDecimals: tokIn[0].rawContract?.decimal
? String(parseInt(tokIn[0].rawContract.decimal, 16))
: undefined,
pairedContractAddress:
tokIn[0].rawContract?.address ?? undefined,
})
} else if (tokOut.length > 0) {
combined.push(mapTransfer(tokOut[0]))
@@ -333,6 +369,34 @@ const rpc = BrowserView.defineRPC<RPC>({
}
}
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),
)
@@ -357,6 +421,21 @@ const rpc = BrowserView.defineRPC<RPC>({
return []
}
},
fetchTransactionDetails: async ({ hash, chainid }) => {
const key = await Bun.secrets.get({
service: 'koins',
name: 'alchemy_key',
})
if (!key) return null
try {
const client = getClient(chainid, key)
const details = await getTransactionDetails(client, hash)
return details
} catch (error) {
console.log(error)
return null
}
},
},
messages: {},
},
@@ -1,6 +1,5 @@
import { post } from './call'
import { getChainID, getURL } from './network'
import { inspect } from 'util'
import type { Transaction } from './types'
import { numberToHex } from 'viem'
@@ -0,0 +1,50 @@
import {
isHex,
formatEther,
type PublicClient,
formatGwei,
decodeFunctionData,
} from 'viem'
export type TransactionDetails = {
hash: string
from: string
to: string | null
value: string
blockNumber: string | null
fee: string
gasPrice: string
status: 'success' | 'reverted'
type: string
nonce: number
input: string
}
export const getTransactionDetails = async (
client: PublicClient,
hash: string,
): Promise<TransactionDetails | null> => {
if (!isHex(hash)) return null
const [tx, receipt] = await Promise.all([
client.getTransaction({ hash }),
client.getTransactionReceipt({ hash }),
])
return {
hash: tx.hash,
from: tx.from,
to: tx.to,
value: formatEther(tx.value),
blockNumber: receipt.blockNumber?.toString() ?? null,
fee: (
parseFloat(formatGwei(receipt.gasUsed)) *
parseFloat(formatGwei(tx.gasPrice || BigInt(0)))
).toString(),
gasPrice: formatGwei(tx.gasPrice || BigInt(0)),
status: receipt.status === 'success' ? 'success' : 'reverted',
type: tx.type,
nonce: tx.nonce,
input: tx.input,
}
}
+12
View File
@@ -0,0 +1,12 @@
import { bsc, mainnet, polygon, type Chain } from 'viem/chains'
import type { ChainID } from '../alchemy/network'
const chains: Record<ChainID, Chain> = {
'1': mainnet,
'56': bsc,
'137': polygon,
}
export const getChain = (id: ChainID) => {
return chains[id]
}
@@ -0,0 +1,19 @@
import { createPublicClient, http } from 'viem'
import { getChainID, getURL } from '../alchemy/network'
import { getChain } from './getChain'
export const getClient = (chainId: string, apiKey: string) => {
const id = getChainID(chainId)
if (!id) {
throw new Error()
}
const url = getURL({ chainId: id, key: apiKey })
const publicClient = createPublicClient({
chain: getChain(id),
transport: http(url),
})
return publicClient
}
+2
View File
@@ -0,0 +1,2 @@
export * from './getClient'
export * from './get-transaction-details'
+23
View File
@@ -9,9 +9,25 @@ export type TxEntry = {
tokenSymbol?: string
tokenDecimal?: string
contractAddress?: string
logo?: string
pairedValue?: string
pairedSymbol?: string
pairedDecimals?: string
pairedLogo?: string
}
export type TransactionDetails = {
hash: string
from: string
to: string | null
value: string
blockNumber: string | null
fee: string
gasPrice: string
status: 'success' | 'reverted'
type: string
nonce: number
input: string
}
export type TokenBalanceResult = {
@@ -53,6 +69,13 @@ type RPC = {
}
response: TokenBalanceResult[]
}
fetchTransactionDetails: {
params: {
hash: string
chainid: string
}
response: TransactionDetails | null
}
openExternal: {
params: {
url: string
+16 -22
View File
@@ -2,6 +2,7 @@
import { electrobun, type TxEntry } from '$lib/electrobun.js'
import { Wallet } from '$lib/states/wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { navigate } from 'sv-router/generated'
import { Textarea } from '$lib/components/ui/textarea/index.js'
import {
Card,
@@ -188,18 +189,18 @@
{:else}
<div class="max-h-96 space-y-1 overflow-y-auto">
{#each w.transactions as tx}
<div
class="flex items-center justify-between rounded-md bg-muted px-3 py-2 text-xs">
<button
onclick={() =>
navigate('/tx/:chainid/:hash', {
params: {
chainid: w.chainid,
hash: tx.hash,
},
})}
class="flex w-full cursor-pointer items-center justify-between rounded-md bg-muted px-3 py-2 text-xs text-left hover:bg-muted/80 transition-colors">
<div class="min-w-0 flex-1 space-y-0.5">
<p>
<button
onclick={() =>
electrobun.rpc?.request.openExternal({
url: w.explorerUrl + tx.hash,
})}
class="hover:underline cursor-pointer font-medium">
{txAction(tx, w.address)}
</button>
<p class="font-medium">
{txAction(tx, w.address)}
</p>
<p
class="flex items-center gap-1 text-muted-foreground font-mono">
@@ -215,7 +216,7 @@
{w.symbol}
<span class="mx-0.5">→</span>
<img
src={tokenIcon(
src={tx.pairedLogo || tokenIcon(
w.network,
tx.contractAddress,
)}
@@ -228,7 +229,7 @@
{tx.pairedSymbol}
{:else if tx.tokenSymbol}
<img
src={tokenIcon(
src={tx.logo || tokenIcon(
w.network,
tx.contractAddress,
)}
@@ -264,17 +265,10 @@
</p>
<p
class="text-muted-foreground/50 truncate font-mono">
<button
onclick={() =>
electrobun.rpc?.request.openExternal({
url: w.explorerUrl + tx.hash,
})}
class="hover:underline cursor-pointer">
{tx.hash.slice(0, 10)}...
</button>
{tx.hash.slice(0, 10)}...
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
@@ -0,0 +1,163 @@
<script lang="ts">
import {
electrobun,
type TransactionDetails,
} from '$lib/electrobun.js'
import { networks } from '$lib/states/wallet.svelte.js'
import { navigate, route } from 'sv-router/generated'
import { Button } from '$lib/components/ui/button/index.js'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '$lib/components/ui/card/index.js'
let details = $state<TransactionDetails | null>(null)
let loading = $state(true)
let error = $state('')
const chainid = $derived(route.params.chainid)
const hash = $derived(route.params.hash)
const network = $derived(
networks.find((n) => n.chainid === chainid),
)
$effect(() => {
if (!hash || !chainid) return
loading = true
error = ''
electrobun.rpc?.request
.fetchTransactionDetails({ hash, chainid })
.then((res) => {
details = res
loading = false
})
.catch((e) => {
error =
e instanceof Error ? e.message : 'Failed to fetch details'
loading = false
})
})
function explorerUrl() {
if (!network) return ''
return network.explorerUrl + hash
}
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() => navigate('/')}
class="mb-4">
&larr; Back
</Button>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Transaction Details</CardTitle>
{#if network}
<span
class="rounded-md bg-muted px-2 py-0.5 text-xs font-medium">
{network.name}
</span>
{/if}
</div>
</CardHeader>
<CardContent>
{#if loading}
<p class="text-muted-foreground text-xs">Loading...</p>
{:else if error}
<p class="text-red-500 text-xs">{error}</p>
{:else if details}
<div class="space-y-2 font-mono text-xs">
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Hash</p>
<p class="break-all">{details.hash}</p>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Status</p>
<p
class={details.status === 'success'
? 'text-green-500'
: 'text-red-500'}>
{details.status}
</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Block</p>
<p>{details.blockNumber ?? 'N/A'}</p>
</div>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">From</p>
<p class="break-all">{details.from}</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">To</p>
<p class="break-all">{details.to ?? 'N/A'}</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">
Value ({network?.symbol ?? 'ETH'})
</p>
<p>{details.value}</p>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">
Tx Fee ({network?.symbol ?? 'ETH'})
</p>
<p>{details.fee}</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">
Gas Price (Gwei)
</p>
<p>{details.gasPrice}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Nonce</p>
<p>{details.nonce}</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Type</p>
<p>{details.type}</p>
</div>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">
Input Data (raw)
</p>
<p class="break-all truncate text-[10px]">
{details.input}
</p>
</div>
</div>
<div class="mt-4">
<Button
variant="outline"
size="sm"
class="w-full"
onclick={() =>
electrobun.rpc?.request.openExternal({
url: explorerUrl(),
})}>
View on Explorer
</Button>
</div>
{:else}
<p class="text-muted-foreground text-xs">
No details available
</p>
{/if}
</CardContent>
</Card>
</div>