add etherscan to add transaction history
This commit is contained in:
@@ -55,6 +55,24 @@ ApplicationMenu.setApplicationMenu([
|
|||||||
|
|
||||||
const url = await getMainViewUrl()
|
const url = await getMainViewUrl()
|
||||||
|
|
||||||
|
const ETHERSCAN_API = 'https://api.etherscan.io/v2/api'
|
||||||
|
|
||||||
|
type EtherscanTx = {
|
||||||
|
blockNumber: string
|
||||||
|
timeStamp: string
|
||||||
|
hash: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
value: string
|
||||||
|
gas: string
|
||||||
|
gasPrice: string
|
||||||
|
isError: string
|
||||||
|
txreceipt_status: string
|
||||||
|
input: string
|
||||||
|
contractAddress: string
|
||||||
|
gasUsed: string
|
||||||
|
}
|
||||||
|
|
||||||
type RPC = {
|
type RPC = {
|
||||||
bun: RPCSchema<{
|
bun: RPCSchema<{
|
||||||
requests: {
|
requests: {
|
||||||
@@ -73,6 +91,15 @@ type RPC = {
|
|||||||
}
|
}
|
||||||
response: Promise<void>
|
response: Promise<void>
|
||||||
}
|
}
|
||||||
|
fetchTxHistory: {
|
||||||
|
params: {
|
||||||
|
address: string
|
||||||
|
chainid: string
|
||||||
|
page?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
response: Promise<EtherscanTx[]>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messages: {}
|
messages: {}
|
||||||
}>
|
}>
|
||||||
@@ -91,6 +118,39 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
setSecret: async ({ name, service, value }) => {
|
setSecret: async ({ name, service, value }) => {
|
||||||
await Bun.secrets.set({ name, service, value })
|
await Bun.secrets.set({ name, service, value })
|
||||||
},
|
},
|
||||||
|
fetchTxHistory: async ({
|
||||||
|
address,
|
||||||
|
chainid,
|
||||||
|
page = 1,
|
||||||
|
offset = 20,
|
||||||
|
}) => {
|
||||||
|
const key = await Bun.secrets.get({
|
||||||
|
service: 'koins',
|
||||||
|
name: 'etherscan_key',
|
||||||
|
})
|
||||||
|
if (!key) return []
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
module: 'account',
|
||||||
|
action: 'txlist',
|
||||||
|
address,
|
||||||
|
page: String(page),
|
||||||
|
offset: String(offset),
|
||||||
|
sort: 'desc',
|
||||||
|
apikey: key,
|
||||||
|
chainid,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const url = `${ETHERSCAN_API}?${params}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
const data = await res.json()
|
||||||
|
console.log(data)
|
||||||
|
if (data.status !== '1') return []
|
||||||
|
return data.result as EtherscanTx[]
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
messages: {},
|
messages: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { Electroview } from 'electrobun/view'
|
import { Electroview } from 'electrobun/view'
|
||||||
|
|
||||||
|
export type EtherscanTx = {
|
||||||
|
blockNumber: string
|
||||||
|
timeStamp: string
|
||||||
|
hash: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
value: string
|
||||||
|
isError: string
|
||||||
|
txreceipt_status: string
|
||||||
|
gas: string
|
||||||
|
gasPrice: string
|
||||||
|
input: string
|
||||||
|
contractAddress: string
|
||||||
|
gasUsed: string
|
||||||
|
}
|
||||||
|
|
||||||
type RPC = {
|
type RPC = {
|
||||||
bun: {
|
bun: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -18,6 +34,15 @@ type RPC = {
|
|||||||
}
|
}
|
||||||
response: Promise<void>
|
response: Promise<void>
|
||||||
}
|
}
|
||||||
|
fetchTxHistory: {
|
||||||
|
params: {
|
||||||
|
address: string
|
||||||
|
chainid: string
|
||||||
|
page?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
response: Promise<EtherscanTx[]>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messages: {}
|
messages: {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,102 @@
|
|||||||
import { JsonRpcProvider, HDNodeWallet, formatEther } from 'ethers'
|
import {
|
||||||
import { electrobun } from '$lib/electrobun'
|
JsonRpcProvider,
|
||||||
|
HDNodeWallet,
|
||||||
|
formatEther,
|
||||||
|
formatUnits,
|
||||||
|
Contract,
|
||||||
|
} from 'ethers'
|
||||||
|
import { electrobun, type EtherscanTx } from '$lib/electrobun'
|
||||||
|
|
||||||
const BNB_RPC = 'https://bsc-dataseed.binance.org'
|
const ERC20_ABI = [
|
||||||
|
'function balanceOf(address) view returns (uint256)',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const networks = [
|
||||||
|
{
|
||||||
|
id: 'bsc',
|
||||||
|
name: 'BNB Smart Chain',
|
||||||
|
rpc: 'https://bsc-dataseed.binance.org',
|
||||||
|
symbol: 'BNB',
|
||||||
|
chainid: '56',
|
||||||
|
explorerUrl: 'https://bscscan.com/tx/',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
symbol: 'USDC',
|
||||||
|
address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d',
|
||||||
|
decimals: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
symbol: 'USDT',
|
||||||
|
address: '0x55d398326f99059fF775485246999027B3197955',
|
||||||
|
decimals: 18,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'polygon',
|
||||||
|
name: 'Polygon',
|
||||||
|
rpc: 'https://polygon.drpc.org',
|
||||||
|
symbol: 'POL',
|
||||||
|
chainid: '137',
|
||||||
|
explorerUrl: 'https://polygonscan.com/tx/',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
symbol: 'USDC',
|
||||||
|
address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
|
||||||
|
decimals: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
symbol: 'USDT',
|
||||||
|
address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
|
||||||
|
decimals: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eth',
|
||||||
|
name: 'Ethereum',
|
||||||
|
rpc: 'https://ethereum-rpc.publicnode.com',
|
||||||
|
symbol: 'ETH',
|
||||||
|
chainid: '1',
|
||||||
|
explorerUrl: 'https://etherscan.io/tx/',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
symbol: 'USDC',
|
||||||
|
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
||||||
|
decimals: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
symbol: 'USDT',
|
||||||
|
address: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
||||||
|
decimals: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
symbol: 'BAT',
|
||||||
|
address: '0x0D8775F648430679A709E98d2b0Cb6250d2887EF',
|
||||||
|
decimals: 18,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type NetworkId = (typeof networks)[number]['id']
|
||||||
|
|
||||||
|
export type TokenBalance = { symbol: string; balance: string }
|
||||||
|
|
||||||
export const Wallet = () => {
|
export const Wallet = () => {
|
||||||
let seed = $state('')
|
let seed = $state('')
|
||||||
let address = $state('')
|
let address = $state('')
|
||||||
let balance = $state('0')
|
let balance = $state('0')
|
||||||
|
let tokenBalances = $state<TokenBalance[]>([])
|
||||||
|
let network = $state<NetworkId>('bsc')
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let vaultExists = $state(false)
|
let vaultExists = $state(false)
|
||||||
let ready = $state(false)
|
let ready = $state(false)
|
||||||
|
let etherscanKey = $state('')
|
||||||
|
let transactions = $state<EtherscanTx[]>([])
|
||||||
|
|
||||||
|
const net = () => networks.find((n) => n.id === network)!
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -19,6 +105,11 @@ export const Wallet = () => {
|
|||||||
name: 'vault',
|
name: 'vault',
|
||||||
})
|
})
|
||||||
vaultExists = raw !== null && raw !== undefined
|
vaultExists = raw !== null && raw !== undefined
|
||||||
|
const key = await electrobun.rpc?.request.getSecret({
|
||||||
|
service: 'koins',
|
||||||
|
name: 'etherscan_key',
|
||||||
|
})
|
||||||
|
if (key) etherscanKey = key
|
||||||
if (raw) {
|
if (raw) {
|
||||||
seed = raw
|
seed = raw
|
||||||
await refresh()
|
await refresh()
|
||||||
@@ -32,6 +123,11 @@ export const Wallet = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const switchNetwork = async (id: NetworkId) => {
|
||||||
|
network = id
|
||||||
|
if (seed) await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
const saveVault = async (phrase: string) => {
|
const saveVault = async (phrase: string) => {
|
||||||
loading = true
|
loading = true
|
||||||
error = ''
|
error = ''
|
||||||
@@ -57,18 +153,37 @@ export const Wallet = () => {
|
|||||||
address = ''
|
address = ''
|
||||||
balance = '0'
|
balance = '0'
|
||||||
error = ''
|
error = ''
|
||||||
|
tokenBalances = []
|
||||||
|
transactions = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (!seed) return
|
if (!seed) return
|
||||||
loading = true
|
loading = true
|
||||||
error = ''
|
error = ''
|
||||||
|
tokenBalances = []
|
||||||
try {
|
try {
|
||||||
const wallet = HDNodeWallet.fromPhrase(seed)
|
const wallet = HDNodeWallet.fromPhrase(seed)
|
||||||
address = wallet.address
|
address = wallet.address
|
||||||
const provider = new JsonRpcProvider(BNB_RPC)
|
const provider = new JsonRpcProvider(net().rpc)
|
||||||
const bal = await provider.getBalance(wallet.address)
|
const [nativeBal, ...tokenBals] = await Promise.all([
|
||||||
balance = formatEther(bal)
|
provider.getBalance(wallet.address),
|
||||||
|
...net().tokens.map(async (t) => {
|
||||||
|
try {
|
||||||
|
const c = new Contract(t.address, ERC20_ABI, provider)
|
||||||
|
const bal = await c.balanceOf(wallet.address)
|
||||||
|
return {
|
||||||
|
symbol: t.symbol,
|
||||||
|
balance: formatUnits(bal, t.decimals),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { symbol: t.symbol, balance: '0' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
balance = formatEther(nativeBal)
|
||||||
|
tokenBalances = tokenBals
|
||||||
|
fetchTxHistory()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error =
|
error =
|
||||||
e instanceof Error ? e.message : 'Failed to fetch balance'
|
e instanceof Error ? e.message : 'Failed to fetch balance'
|
||||||
@@ -77,6 +192,28 @@ export const Wallet = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveEtherscanKey = async (key: string) => {
|
||||||
|
await electrobun.rpc?.request.setSecret({
|
||||||
|
service: 'koins',
|
||||||
|
name: 'etherscan_key',
|
||||||
|
value: key,
|
||||||
|
})
|
||||||
|
etherscanKey = key
|
||||||
|
if (address) await fetchTxHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTxHistory = async () => {
|
||||||
|
if (!address || !etherscanKey) {
|
||||||
|
transactions = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const txs = await electrobun.rpc?.request.fetchTxHistory({
|
||||||
|
address,
|
||||||
|
chainid: net().chainid,
|
||||||
|
})
|
||||||
|
transactions = txs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get isLocked() {
|
get isLocked() {
|
||||||
return vaultExists && !seed
|
return vaultExists && !seed
|
||||||
@@ -90,6 +227,27 @@ export const Wallet = () => {
|
|||||||
get balance() {
|
get balance() {
|
||||||
return 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 tokens() {
|
||||||
|
return net().tokens
|
||||||
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading
|
return loading
|
||||||
},
|
},
|
||||||
@@ -102,9 +260,21 @@ export const Wallet = () => {
|
|||||||
get ready() {
|
get ready() {
|
||||||
return ready
|
return ready
|
||||||
},
|
},
|
||||||
|
get networks() {
|
||||||
|
return networks
|
||||||
|
},
|
||||||
|
get etherscanKey() {
|
||||||
|
return etherscanKey
|
||||||
|
},
|
||||||
|
get transactions() {
|
||||||
|
return transactions
|
||||||
|
},
|
||||||
refresh,
|
refresh,
|
||||||
init,
|
init,
|
||||||
lock,
|
lock,
|
||||||
saveVault,
|
saveVault,
|
||||||
|
switchNetwork,
|
||||||
|
saveEtherscanKey,
|
||||||
|
fetchTxHistory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '$lib/components/ui/card/index.js'
|
} from '$lib/components/ui/card/index.js'
|
||||||
|
import { formatEther } from 'ethers'
|
||||||
|
|
||||||
let w = Wallet()
|
let w = Wallet()
|
||||||
let inputSeed = $state('')
|
let inputSeed = $state('')
|
||||||
|
let apiKeyInput = $state('')
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
w.init()
|
w.init()
|
||||||
@@ -53,34 +55,97 @@
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<Card>
|
<div class="flex flex-col gap-4">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Wallet</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Your wallet is unlocked</CardDescription>
|
<div class="flex items-center justify-between">
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent>
|
<CardTitle>Wallet</CardTitle>
|
||||||
{#if w.address}
|
<CardDescription>Your wallet is unlocked</CardDescription>
|
||||||
<div class="mb-4 space-y-1">
|
</div>
|
||||||
<p class="font-medium text-xs">Address</p>
|
<div class="flex gap-1" role="group">
|
||||||
<p class="font-mono text-xs break-all">{w.address}</p>
|
{#each w.networks as net}
|
||||||
<p class="mt-2 font-medium text-xs">BNB Balance</p>
|
<button
|
||||||
<p class="font-mono text-lg">{w.balance} BNB</p>
|
class="cursor-pointer rounded-md px-2.5 py-1 text-xs font-medium transition-colors {w.network === net.id ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||||
|
onclick={() => w.switchNetwork(net.id)}>{net.name}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if w.address}
|
||||||
|
<div class="mb-4 space-y-1">
|
||||||
|
<p class="font-medium text-xs">Address</p>
|
||||||
|
<p class="font-mono text-xs break-all">{w.address}</p>
|
||||||
|
<p class="mt-2 font-medium text-xs">{w.symbol} Balance</p>
|
||||||
|
<p class="font-mono text-lg">{w.balance} {w.symbol}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 space-y-1">
|
||||||
|
<p class="font-medium text-xs">Tokens</p>
|
||||||
|
{#each w.tokenBalances as t}
|
||||||
|
<p class="font-mono text-sm">
|
||||||
|
{Number(t.balance) < 0.0001 && Number(t.balance) > 0 ? '<0.0001' : Number(t.balance).toFixed(4)} {t.symbol}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if w.error}
|
{#if w.error}
|
||||||
<p class="mb-3 text-red-500">{w.error}</p>
|
<p class="mb-3 text-red-500">{w.error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button onclick={() => w.refresh()} disabled={w.loading}>
|
<Button onclick={() => w.refresh()} disabled={w.loading}>
|
||||||
{w.loading ? 'Refreshing...' : 'Refresh Balance'}
|
{w.loading ? 'Refreshing...' : 'Refresh'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onclick={() => w.lock()}>
|
<Button variant="outline" onclick={() => w.lock()}>
|
||||||
Lock
|
Lock
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Transactions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if !w.etherscanKey}
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
||||||
|
placeholder="Etherscan API key"
|
||||||
|
bind:value={apiKeyInput} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => w.saveEtherscanKey(apiKeyInput)}
|
||||||
|
disabled={!apiKeyInput.trim()}>Save</Button>
|
||||||
|
</div>
|
||||||
|
{:else if w.transactions.length === 0}
|
||||||
|
<p class="text-muted-foreground text-xs">No transactions found</p>
|
||||||
|
{:else}
|
||||||
|
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{#each w.transactions.slice(0, 10) as tx}
|
||||||
|
<div class="flex items-center justify-between rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate" title={tx.hash}>
|
||||||
|
<a
|
||||||
|
href="{w.explorerUrl}{tx.hash}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="hover:underline">{tx.hash.slice(0, 10)}...</a>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">{Number(formatEther(tx.value)).toFixed(4)} {w.symbol}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 {tx.isError === '0' ? 'text-green-500' : 'text-red-500'}">
|
||||||
|
{tx.isError === '0' ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user