diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 64f241c..c0999ed 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -55,6 +55,24 @@ ApplicationMenu.setApplicationMenu([ 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 = { bun: RPCSchema<{ requests: { @@ -73,6 +91,15 @@ type RPC = { } response: Promise } + fetchTxHistory: { + params: { + address: string + chainid: string + page?: number + offset?: number + } + response: Promise + } } messages: {} }> @@ -91,6 +118,39 @@ const rpc = BrowserView.defineRPC({ setSecret: async ({ 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: {}, }, diff --git a/apps/desktop/src/lib/electrobun.ts b/apps/desktop/src/lib/electrobun.ts index cae1e25..f8166b4 100644 --- a/apps/desktop/src/lib/electrobun.ts +++ b/apps/desktop/src/lib/electrobun.ts @@ -1,5 +1,21 @@ 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 = { bun: { requests: { @@ -18,6 +34,15 @@ type RPC = { } response: Promise } + fetchTxHistory: { + params: { + address: string + chainid: string + page?: number + offset?: number + } + response: Promise + } } messages: {} } diff --git a/apps/desktop/src/lib/states/wallet.svelte.ts b/apps/desktop/src/lib/states/wallet.svelte.ts index 68c066b..62d5dcd 100644 --- a/apps/desktop/src/lib/states/wallet.svelte.ts +++ b/apps/desktop/src/lib/states/wallet.svelte.ts @@ -1,16 +1,102 @@ -import { JsonRpcProvider, HDNodeWallet, formatEther } from 'ethers' -import { electrobun } from '$lib/electrobun' +import { + 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 = () => { let seed = $state('') let address = $state('') let balance = $state('0') + let tokenBalances = $state([]) + let network = $state('bsc') let loading = $state(false) let error = $state('') let vaultExists = $state(false) let ready = $state(false) + let etherscanKey = $state('') + let transactions = $state([]) + + const net = () => networks.find((n) => n.id === network)! const init = async () => { try { @@ -19,6 +105,11 @@ export const Wallet = () => { name: 'vault', }) vaultExists = raw !== null && raw !== undefined + const key = await electrobun.rpc?.request.getSecret({ + service: 'koins', + name: 'etherscan_key', + }) + if (key) etherscanKey = key if (raw) { seed = raw 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) => { loading = true error = '' @@ -57,18 +153,37 @@ export const Wallet = () => { address = '' balance = '0' error = '' + tokenBalances = [] + transactions = [] } const refresh = async () => { if (!seed) return loading = true error = '' + tokenBalances = [] try { const wallet = HDNodeWallet.fromPhrase(seed) address = wallet.address - const provider = new JsonRpcProvider(BNB_RPC) - const bal = await provider.getBalance(wallet.address) - balance = formatEther(bal) + const provider = new JsonRpcProvider(net().rpc) + const [nativeBal, ...tokenBals] = await Promise.all([ + 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) { error = 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 { get isLocked() { return vaultExists && !seed @@ -90,6 +227,27 @@ export const Wallet = () => { 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 tokens() { + return net().tokens + }, get loading() { return loading }, @@ -102,9 +260,21 @@ export const Wallet = () => { get ready() { return ready }, + get networks() { + return networks + }, + get etherscanKey() { + return etherscanKey + }, + get transactions() { + return transactions + }, refresh, init, lock, saveVault, + switchNetwork, + saveEtherscanKey, + fetchTxHistory, } } diff --git a/apps/desktop/src/routes/index.svelte b/apps/desktop/src/routes/index.svelte index fe5f517..bca1aaf 100644 --- a/apps/desktop/src/routes/index.svelte +++ b/apps/desktop/src/routes/index.svelte @@ -9,9 +9,11 @@ CardHeader, CardTitle, } from '$lib/components/ui/card/index.js' + import { formatEther } from 'ethers' let w = Wallet() let inputSeed = $state('') + let apiKeyInput = $state('') $effect(() => { w.init() @@ -53,34 +55,97 @@ {:else} - - - Wallet - Your wallet is unlocked - - - {#if w.address} -
-

Address

-

{w.address}

-

BNB Balance

-

{w.balance} BNB

+
+ + +
+
+ Wallet + Your wallet is unlocked +
+
+ {#each w.networks as net} + + {/each} +
- {/if} +
+ + {#if w.address} +
+

Address

+

{w.address}

+

{w.symbol} Balance

+

{w.balance} {w.symbol}

+
+
+

Tokens

+ {#each w.tokenBalances as t} +

+ {Number(t.balance) < 0.0001 && Number(t.balance) > 0 ? '<0.0001' : Number(t.balance).toFixed(4)} {t.symbol} +

+ {/each} +
+ {/if} - {#if w.error} -

{w.error}

- {/if} + {#if w.error} +

{w.error}

+ {/if} -
- - -
-
-
+
+ + +
+ + + + + + Transactions + + + {#if !w.etherscanKey} +
+ + +
+ {:else if w.transactions.length === 0} +

No transactions found

+ {:else} +
+ {#each w.transactions.slice(0, 10) as tx} +
+
+

+ {tx.hash.slice(0, 10)}... +

+

{Number(formatEther(tx.value)).toFixed(4)} {w.symbol}

+
+ + {tx.isError === '0' ? '✓' : '✗'} + +
+ {/each} +
+ {/if} +
+
+
{/if}