remove evm + add xrp

This commit is contained in:
2026-08-01 00:58:22 +08:00
parent f46e61ed5e
commit 0d3acef182
55 changed files with 1457 additions and 4148 deletions
@@ -1,799 +0,0 @@
<script lang="ts">
import { electrobun, type TxEntry } from '$lib/electrobun.js'
import {
evmWallet as wallet,
type NetworkId,
} from '$lib/states/evm-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Textarea } from '$lib/components/ui/textarea/index.js'
import { generateMnemonic } from 'viem/accounts'
import { english } from 'viem/accounts'
import { navigate } from 'sv-router/generated'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '$lib/components/ui/card/index.js'
import ArrowDown from '@lucide/svelte/icons/arrow-down'
import ArrowUp from '@lucide/svelte/icons/arrow-up'
import ArrowLeftRight from '@lucide/svelte/icons/arrow-left-right'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import SettingsIcon from '@lucide/svelte/icons/settings'
import Fingerprint from '@lucide/svelte/icons/fingerprint'
import Trash2 from '@lucide/svelte/icons/trash-2'
import Plus from '@lucide/svelte/icons/plus'
import WalletIcon from '@lucide/svelte/icons/wallet'
import LockKeyhole from '@lucide/svelte/icons/lock-keyhole'
import ExternalLink from '@lucide/svelte/icons/external-link'
import * as Dialog from '$lib/components/ui/dialog/index.js'
import QRCode from 'qrcode'
import Loader from './loader.svelte'
import { LineChart } from 'layerchart'
import { scaleUtc } from 'd3-scale'
import { curveLinear } from 'd3-shape'
import * as Chart from '$lib/components/ui/chart/index.js'
import { formatEther, parseEther } from 'viem'
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
let { networkId }: { networkId: NetworkId } = $props()
const CDN =
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains'
const chainDir: Record<string, string> = {
eth: 'ethereum',
bsc: 'smartchain',
polygon: 'polygon',
}
function nativeIcon(id: string) {
return `${CDN}/${chainDir[id]}/info/logo.png`
}
function tokenIcon(id: string, address?: string) {
if (!address) return ''
return `${CDN}/${chainDir[id]}/assets/${address}/logo.png`
}
function txAction(tx: TxEntry, address: string): string {
if (tx.pairedValue) return 'Swap'
const dir =
tx.from.toLowerCase() === address.toLowerCase()
? 'Sent'
: 'Received'
if (tx.tokenSymbol) return `${dir} ${tx.tokenSymbol}`
return dir
}
const w = wallet
let inputSeed = $state('')
let inputPassword = $state('')
let inputUnlockPassword = $state('')
let inputWalletName = $state('')
let apiKeyInput = $state('')
let qrDataUrl = $state('')
let qrDialogOpen = $state(false)
let showCreateForm = $state(false)
$effect(() => {
if (w.address) {
QRCode.toDataURL(w.address, { width: 256, margin: 1 }).then(
(url) => (qrDataUrl = url),
)
}
})
function navTarget(id: NetworkId): string {
if (id === 'eth') return '/multicoin'
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 }>
>({})
let gasPrice = $state<string | null>(null)
$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 (w.chainid) {
electrobun.rpc?.request
.fetchGasPrice({ chainid: w.chainid })
.then((gp) => (gasPrice = gp))
}
})
$effect(() => {
if (!initStarted) {
initStarted = true
w.init().then(async () => {
if (w.accountType === 'multi') {
await w.switchNetwork(networkId)
}
})
}
})
function isNative(tx: TxEntry): boolean {
return !tx.contractAddress
}
const balanceHistory = $derived.by(() => {
const native = w.transactions.filter(isNative)
const sorted = [...native].reverse()
if (sorted.length === 0) return []
let cum = 0n
const addr = w.address.toLowerCase()
const points: { date: Date; balance: number }[] = []
for (const tx of sorted) {
console.log(tx.value)
const val = parseEther(tx.value)
if (tx.from.toLowerCase() === addr) cum -= val
else cum += val
points.push({
date: new Date(Number(tx.timeStamp) * 1000),
balance: Number(formatEther(cum)),
})
}
const cur = Number(w.balance)
const offset = cur - Number(formatEther(cum))
for (const p of points) p.balance += offset
return points
})
$inspect(balanceHistory)
let selectedRange = $state<'1D' | '1W' | '1M' | '1Y' | 'ALL'>('ALL')
const rangeCutoff = $derived(
selectedRange === 'ALL'
? 0
: Date.now() -
(
{
'1D': 86400000,
'1W': 604800000,
'1M': 2592000000,
'1Y': 31536000000,
} as const
)[selectedRange],
)
const chartData = $derived(
selectedRange === 'ALL'
? balanceHistory
: balanceHistory.filter((p) => p.date.getTime() >= rangeCutoff),
)
</script>
{#if !w.ready}
<div
class="mx-auto mt-16 max-w-md text-center text-muted-foreground text-sm">
Loading...
</div>
{:else if w.wallets.length === 0 || showCreateForm}
<Card>
<CardHeader>
<CardTitle>
{#if showCreateForm}
{@const cur = w.currentWallet}
{cur ? `Add Another Wallet` : `Import / Create Wallet`}
{:else}
Import / Create Wallet
{/if}
</CardTitle>
<CardDescription>
Name your wallet and enter your seed phrase to save it
securely, or generate a new wallet
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
<input
type="text"
placeholder="Wallet name (e.g. Main, Savings)"
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
bind:value={inputWalletName} />
<Textarea
placeholder="Enter your 12 or 24 word seed phrase"
bind:value={inputSeed} />
<Button
variant="outline"
onclick={() => {
inputSeed = generateMnemonic(english)
}}
class="w-full mt-1">
Generate Random Seed
</Button>
<input
type="password"
placeholder="Set a password (optional)"
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
bind:value={inputPassword} />
<div class="flex gap-2">
<Button
onclick={() =>
w.createWallet(
inputWalletName,
inputSeed,
inputPassword || undefined,
)}
disabled={w.loading ||
!inputSeed.trim() ||
!inputWalletName.trim()}>
{w.loading ? 'Saving...' : 'Save Wallet'}
</Button>
{#if showCreateForm}
<Button
variant="outline"
onclick={() => (showCreateForm = false)}>
Cancel
</Button>
{/if}
</div>
</div>
{#if w.error}
<p class="mt-3 text-red-500">{w.error}</p>
{/if}
</CardContent>
</Card>
{:else if !w.currentWalletId}
<Card>
<CardHeader>
<CardTitle>Wallets</CardTitle>
<CardDescription>Select a wallet to use</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-2">
{#each w.wallets as wal}
<button
onclick={async () => {
if (wal.hasPassword) {
await w.selectWallet(wal.id)
} else {
await w.selectAndUnlockWallet(wal.id)
}
}}
class="flex w-full cursor-pointer items-center gap-3 rounded-md border border-input p-3 text-left hover:bg-muted transition-colors">
<WalletIcon
size={20}
class="shrink-0 text-muted-foreground" />
<div class="flex-1 min-w-0">
<p class="font-medium text-sm">{wal.name}</p>
<p class="text-xs text-muted-foreground">
{new Date(wal.createdAt).toLocaleDateString()}
</p>
</div>
<div class="flex items-center gap-1">
{#if wal.hasPassword}
<LockKeyhole
size={14}
class="text-muted-foreground" />
{/if}
</div>
</button>
{/each}
<Button
variant="outline"
onclick={() => (showCreateForm = true)}
class="mt-2">
<Plus size={16} />
Add Wallet
</Button>
</div>
</CardContent>
</Card>
{:else if w.isLocked}
<Card>
<CardHeader>
<CardTitle>
{w.currentWallet?.name ?? 'Wallet'} Locked
</CardTitle>
<CardDescription>Unlock to access your wallet</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
{#if w.biometricAvailable}
<Button
onclick={() => w.unlockWithBiometrics()}
disabled={w.loading}>
<Fingerprint size={16} />
{w.loading ? 'Unlocking...' : 'Unlock with Touch ID'}
</Button>
{/if}
{#if w.currentPasswordHash}
<div class="flex gap-2 items-end">
<input
type="password"
placeholder="Enter password"
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
bind:value={inputUnlockPassword} />
<Button
onclick={async () => {
const ok = await w.unlockWithPassword(
inputUnlockPassword,
)
if (!ok) w.error = 'Wrong password'
inputUnlockPassword = ''
}}
disabled={w.loading || !inputUnlockPassword}
size="sm">
{w.loading ? '...' : 'Unlock'}
</Button>
</div>
{/if}
<Button variant="outline" onclick={() => w.clearSelection()}>
Pick another wallet
</Button>
</div>
{#if w.error}
<p class="mt-3 text-red-500">{w.error}</p>
{/if}
</CardContent>
</Card>
{:else}
<div class="flex flex-col gap-4">
<div class="flex gap-1 justify-center" role="group">
{#each w.networks as net}
<button
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={() => navigate(navTarget(net.id))}>
<img
src={nativeIcon(net.id)}
alt=""
class="-ml-0.5 mr-1 inline-block size-3.5 rounded-full align-middle"
onerror={(e) =>
((e.target as HTMLElement).style.display = 'none')} />
{net.name}
</button>
{/each}
</div>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>
{w.currentWallet?.name ?? 'Wallet'}
</CardTitle>
<CardDescription>Your wallet is unlocked</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{#if w.address}
<div class="mb-4 space-y-1">
<p class="font-medium text-xs">Address</p>
<div class="flex items-start gap-2">
<p class="font-mono text-xs break-all flex-1">
{w.address}
</p>
<button
onclick={() =>
electrobun.rpc?.request.openExternal({
url: w.explorerAddressUrl,
})}
class="shrink-0 cursor-pointer rounded-md border p-1.5 text-muted-foreground hover:bg-muted transition-colors">
<ExternalLink size={16} />
</button>
{#if qrDataUrl}
<Dialog.Root bind:open={qrDialogOpen}>
<Dialog.Trigger>
<button
class="shrink-0 cursor-pointer rounded-md border p-1.5 text-muted-foreground hover:bg-muted transition-colors">
<QrCodeIcon size={16} />
</button>
</Dialog.Trigger>
<Dialog.Content>
<div
class="flex flex-col items-center gap-3 py-4">
<div class="relative">
<img
src={qrDataUrl}
alt="QR code"
class="size-52 rounded-md" />
<img
src={nativeIcon(w.network)}
alt=""
class="absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white p-0.5" />
</div>
<p
class="font-mono text-xs break-all text-center max-w-64">
{w.address}
</p>
</div>
</Dialog.Content>
</Dialog.Root>
{/if}
</div>
<p class="mt-2 font-medium text-xs">
{w.symbol} Balance
</p>
<p class="flex items-center gap-1.5 font-mono text-lg">
<img
src={nativeIcon(w.network)}
alt=""
class="size-4 rounded-full"
onerror={(e) =>
((e.target as HTMLElement).style.display =
'none')} />
{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}
{#if gasPrice}
{@const gasNative = (Number(gasPrice) * 21000) / 1e9}
<p class="font-mono text-xs text-muted-foreground mt-1">
Gas: {Number(gasPrice).toFixed(1)} Gwei ({gasNative.toLocaleString(
undefined,
{
minimumFractionDigits: 6,
maximumFractionDigits: 6,
},
)}
{w.symbol})
{#if nativePrice}
${(
gasNative * Number(nativePrice.value)
).toLocaleString(undefined, {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
})}
{/if}
</p>
{/if}
</div>
<div class="mb-4 space-y-1">
<p class="font-medium text-xs">Tokens</p>
{#each w.tokenBalances as t}
<div
class="flex items-center gap-1.5 font-mono text-sm">
<img
src={t.logo}
alt=""
class="size-4 rounded-full"
onerror={(e) =>
((e.target as HTMLElement).style.display =
'none')} />
{Number(t.balance) < 0.0001 && Number(t.balance) > 0
? '<0.0001'
: Number(t.balance).toFixed(4)}
{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>
{/if}
{/each}
</div>
{/if}
{#if w.loadingBalance && w.address}
<div class="flex justify-center py-4">
<Loader />
</div>
{/if}
{#if w.error}
<p class="mb-3 text-red-500">{w.error}</p>
{/if}
<div class="flex gap-2 flex-wrap">
<Button
variant="outline"
onclick={() => navigate('/multicoin/send')}>
Send
</Button>
<Button
variant="outline"
onclick={() => navigate('/settings')}>
<SettingsIcon size={16} />
</Button>
<Button
variant="outline"
onclick={async () => {
await w.logout()
navigate('/')
}}>
Logout
</Button>
</div>
</CardContent>
</Card>
{#if balanceHistory.length > 1}
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Balance</CardTitle>
<div class="flex gap-1">
{#each ['1D', '1W', '1M', '1Y', 'ALL'] as const as range}
<button
onclick={() => (selectedRange = range)}
class="rounded px-1.5 py-0.5 text-xs font-medium transition-colors {selectedRange ===
range
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}">
{range}
</button>
{/each}
</div>
</div>
</CardHeader>
<CardContent>
{#if chartData.length > 1}
<Chart.Container
config={{
balance: { label: w.symbol, color: 'var(--chart-1)' },
}}>
<LineChart
data={chartData}
x="date"
xScale={scaleUtc()}
axis="x"
points
series={[
{
key: 'balance',
label: w.symbol,
color: 'var(--chart-1)',
},
]}
yDomain={[
chartData.reduce((a, b) =>
a.balance < b.balance ? a : b,
).balance,
chartData.reduce((a, b) =>
a.balance > b.balance ? a : b,
).balance,
]}
props={{
spline: {
curve: curveLinear,
motion: 'tween',
strokeWidth: 2,
},
yAxis: {},
xAxis: {
format: (v: Date) =>
v.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
},
highlight: { points: { r: 4 } },
}}>
{#snippet tooltip()}
<Chart.Tooltip
labelFormatter={(value: Date) => {
return value.toLocaleDateString()
}}
valueFormatter={(value: Number) => {
return Number(value).toFixed(6)
}} />
{/snippet}
</LineChart>
</Chart.Container>
{:else}
<p class="text-center text-sm text-muted-foreground">
No transactions in this time range.
</p>
{/if}
</CardContent>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>Transactions</CardTitle>
</CardHeader>
<CardContent>
{#if w.loadingTransactions}
<div class="flex justify-center py-4">
<Loader />
</div>
{:else if !w.apiKey}
<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="Alchemy API key"
bind:value={apiKeyInput} />
<Button
size="sm"
onclick={() => w.saveApiKey(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-96 space-y-1 overflow-y-auto">
{#each w.transactions.filter((tx) => tx.tokenDecimal !== '0') as tx}
<button
onclick={() =>
navigate('/tx/:chainid/:hash', {
params: {
chainid: w.chainid,
hash: tx.hash,
},
})}
class="flex w-full cursor-pointer items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left hover:bg-muted/80 transition-colors">
<div
class="shrink-0 mt-0.5 {txAction(tx, w.address) ===
'Swap'
? 'text-yellow-500'
: tx.from.toLowerCase() ===
w.address.toLowerCase()
? 'text-muted-foreground'
: 'text-green-500'}">
{#if txAction(tx, w.address) === 'Swap'}
<ArrowLeftRight size={16} />
{:else if tx.from.toLowerCase() === w.address.toLowerCase()}
<ArrowUp size={16} />
{:else}
<ArrowDown size={16} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<p class="font-medium">
{txAction(tx, w.address)}
</p>
<p
class="flex items-center gap-1 text-muted-foreground font-mono">
{#if tx.pairedValue}
<img
src={nativeIcon(w.network)}
alt=""
class="size-3.5 rounded-full"
onerror={(e) =>
((e.target as HTMLElement).style.display =
'none')} />
{Number(tx.value).toFixed(4)}
{w.symbol}
<span class="mx-0.5">→</span>
<img
src={tx.pairedLogo ||
tokenIcon(w.network, tx.contractAddress)}
alt=""
class="size-3.5 rounded-full"
onerror={(e) =>
((e.target as HTMLElement).style.display =
'none')} />
{Number(tx.pairedValue).toFixed(4)}
{tx.pairedSymbol ?? w.symbol}
{:else if tx.tokenSymbol}
<img
src={tx.logo ||
tokenIcon(w.network, tx.contractAddress)}
alt=""
class="size-3.5 rounded-full"
onerror={(e) =>
((e.target as HTMLElement).style.display =
'none')} />
{Number(tx.value).toFixed(4)}
{tx.tokenSymbol}
{:else}
<img
src={nativeIcon(w.network)}
alt=""
class="size-3.5 rounded-full"
onerror={(e) =>
((e.target as HTMLElement).style.display =
'none')} />
{Number(tx.value).toFixed(4)}
{w.symbol}
{/if}
</p>
<p class="text-muted-foreground">
{new Date(
Number(tx.timeStamp) * 1000,
).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<p
class="text-muted-foreground/50 truncate font-mono">
{tx.hash.slice(0, 10)}...
</p>
</div>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
</div>
{/if}
+5 -4
View File
@@ -9,10 +9,11 @@ export type {
MoneroAccountEntry,
MoneroSendResult,
MoneroTransferDetails,
TxEntry,
TransactionDetails,
TokenBalanceResult,
EvmWalletInfo,
XrpWalletInfo,
XrpBalance,
XrpTxEntry,
XrpTxDetails,
XrpSendResult,
} from './rpc-schema'
const rpc = Electroview.defineRPC<RPC>({
+53 -75
View File
@@ -79,67 +79,50 @@ export type MoneroFeeEstimate = {
estimatedFee: string
}
export type TxEntry = {
hash: string
timeStamp: string
from: string
to: string
value: string
tokenSymbol?: string
tokenDecimal?: string
contractAddress?: string
logo?: string
pairedValue?: string
pairedSymbol?: string
pairedDecimals?: string
pairedContractAddress?: 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
pairedValue?: string
pairedSymbol?: string
pairedDecimals?: string
pairedContractAddress?: string
pairedLogo?: string
}
export type TokenBalanceResult = {
symbol: string
decimals: number
balance: string
contractAddress: string
logo?: string
}
export type TokenPriceEntry = {
symbol: string
currency: string
value: string
lastUpdatedAt: string
network?: string
address?: string
}
export type EvmWalletInfo = {
export type XrpWalletInfo = {
id: string
name: string
address: string
hasPassword: boolean
vaultKey: string
createdAt: string
}
export type XrpBalance = {
address: string
balance: string
funded: boolean
}
export type XrpTxEntry = {
hash: string
amount: string
timestamp: string
direction: 'in' | 'out'
from: string
to: string
destinationTag?: number
fee: string
confirmed: boolean
}
export type XrpTxDetails = {
hash: string
from: string
to: string
amount: string
fee: string
timestamp: string
ledgerIndex: number
destinationTag?: number
confirmed: boolean
}
export type XrpSendResult = {
hash: string
fee: string
}
export type GeneralRpcRequests = {
resetApp: { params: {}; response: boolean }
biometricCanAuth: { params: {}; response: boolean }
@@ -151,23 +134,6 @@ export type GeneralRpcRequests = {
logToFile: { params: { message: string }; response: void }
}
export type EvmRpcRequests = {
fetchTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] }
fetchCachedTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] }
syncTxHistory: { params: { address: string; chainid: string }; response: void }
flushTxCache: { params: {}; response: void }
setAutoSync: { params: { address: string; chainid: string } | null; response: void }
fetchTokenBalances: { params: { address: string; chainid: string }; response: TokenBalanceResult[] }
fetchTransactionDetails: { params: { hash: string; chainid: string; address?: string }; response: TransactionDetails | null }
fetchTokenPrices: { params: { symbols?: string[]; addresses?: { network: string; address: string }[] }; response: TokenPriceEntry[] }
fetchGasPrice: { params: { chainid: string }; response: string | null }
evmCreateWallet: { params: { name: string; phrase: string; passwordHash?: string }; response: { id: string; name: string; createdAt: string } }
evmListWallets: { params: {}; response: EvmWalletInfo[] }
evmGetSeed: { params: { vaultKey: string }; response: string }
evmDeleteWallet: { params: { id: string }; response: void }
evmSendTransfer: { params: { seed: string; to: string; amount: string; chainid: string; contractAddress?: string; tokenDecimals?: number }; response: string }
}
export type MoneroRpcRequests = {
moneroBinaryStatus: { params: {}; response: MoneroBinaryStatus }
moneroDownloadBinary: { params: {}; response: void }
@@ -190,15 +156,27 @@ export type MoneroRpcRequests = {
fetchMoneroPrice: { params: {}; response: { usd: string } | null }
}
export type XrpRpcRequests = {
xrpListWallets: { params: {}; response: XrpWalletInfo[] }
xrpCreateWallet: { params: { name: string; hasPassword?: boolean }; response: { id: string; address: string; seed: string; createdAt: string } }
xrpImportWallet: { params: { name: string; secret: string; hasPassword?: boolean }; response: { id: string; address: string; createdAt: string } }
xrpDeleteWallet: { params: { id: string }; response: void }
xrpGetSeed: { params: { vaultKey: string }; response: string }
xrpGetBalance: { params: { address: string }; response: XrpBalance }
xrpGetTransactions: { params: { address: string }; response: XrpTxEntry[] }
xrpGetTxDetails: { params: { hash: string }; response: XrpTxDetails | null }
xrpSend: { params: { secret: string; to: string; amount: string; destinationTag?: number }; response: XrpSendResult }
xrpGetFee: { params: {}; response: { fee: string } | null }
fetchXrpPrice: { params: {}; response: { usd: string } | null }
}
export type RPC = {
bun: {
requests: GeneralRpcRequests & EvmRpcRequests & MoneroRpcRequests
requests: GeneralRpcRequests & MoneroRpcRequests & XrpRpcRequests
messages: {}
}
webview: {
requests: {}
messages: {
transfersUpdate: { count: number; chainid: string; address: string }
}
messages: {}
}
}
@@ -1,73 +1,37 @@
import { mnemonicToAccount } from 'viem/accounts'
import { createPublicClient, http, formatEther } from 'viem'
import {
electrobun,
type TxEntry,
type EvmWalletInfo,
type XrpTxEntry,
type XrpWalletInfo,
} from '$lib/electrobun'
import { tryCatch } from '@koins/utils'
import { moneroWallet } from './monero-wallet.svelte.js'
export const networks = [
{
id: 'bsc',
name: 'BNB Smart Chain',
rpc: 'https://bsc-dataseed.binance.org',
symbol: 'BNB',
chainid: '56',
explorerUrl: 'https://bscscan.com/tx/',
},
{
id: 'polygon',
name: 'Polygon',
rpc: 'https://polygon-bor.publicnode.com',
symbol: 'POL',
chainid: '137',
explorerUrl: 'https://polygonscan.com/tx/',
},
{
id: 'eth',
name: 'Ethereum',
rpc: 'https://ethereum-rpc.publicnode.com',
symbol: 'ETH',
chainid: '1',
explorerUrl: 'https://etherscan.io/tx/',
},
] as const
export const xrpNetwork = {
id: 'xrp',
name: 'XRP Ledger',
symbol: 'XRP',
explorerUrl: 'https://xrpscan.com/tx/',
explorerAddressUrl: 'https://xrpscan.com/account/',
} as const
export type NetworkId = (typeof networks)[number]['id']
export type TokenBalance = {
symbol: string
decimals: number
balance: string
contractAddress: string
logo?: string
}
export type AccountType = 'multi' | 'monero'
export const EvmWallet = () => {
let accountType = $state<AccountType | null>(null)
export const XrpWallet = () => {
let accountType = $state<'xrp' | null>(null)
let ready = $state(false)
let biometricAvailable = $state(false)
let seed = $state('')
let address = $state('')
let balance = $state('0')
let tokenBalances = $state<TokenBalance[]>([])
let network = $state<NetworkId>('eth')
let apiKey = $state('')
let transactions = $state<TxEntry[]>([])
let funded = $state(true)
let price = $state<string | null>(null)
let fee = $state<string | null>(null)
let transactions = $state<XrpTxEntry[]>([])
let loadingBalance = $state(false)
let loadingTransactions = $state(false)
let loading = $state(false)
let error = $state('')
let wallets = $state<EvmWalletInfo[]>([])
let wallets = $state<XrpWalletInfo[]>([])
let currentWalletId = $state<string | null>(null)
let currentPasswordHash = $state<string | null>(null)
const net = () => networks.find((n) => n.id === network)!
async function hashPassword(
password: string,
salt?: string,
@@ -111,25 +75,8 @@ export const EvmWallet = () => {
const init = async () => {
if (!electrobun.rpc) return
electrobun.rpc.addMessageListener(
'transfersUpdate',
(payload: {
chainid: string
address: string
count: number
}) => {
if (
payload.address === address &&
payload.chainid === net().chainid
) {
fetchTxHistory()
}
},
)
const [walletList] = await tryCatch(
electrobun.rpc.request.evmListWallets({}),
electrobun.rpc.request.xrpListWallets({}),
)
if (walletList) {
wallets = walletList
@@ -137,28 +84,13 @@ export const EvmWallet = () => {
currentWalletId = walletList[0].id
}
}
const [key] = await tryCatch(
electrobun.rpc.request.getSecret({
service: 'koins',
name: 'alchemy_key',
}),
)
if (key) apiKey = key
// await moneroWallet.checkStatus()
// if (moneroWallet.installed && !moneroWallet.running && !moneroWallet.downloading) {
// await moneroWallet.start()
// await moneroWallet.checkStatus()
// }
await checkBiometric()
ready = true
}
const login = async () => {
accountType = 'multi'
if (seed) await switchNetwork(network)
accountType = 'xrp'
if (seed) await refresh()
}
const logout = async () => {
@@ -170,27 +102,25 @@ export const EvmWallet = () => {
seed = ''
address = ''
balance = '0'
tokenBalances = []
funded = true
transactions = []
error = ''
currentPasswordHash = null
electrobun.rpc?.request.setAutoSync(null)
}
const unlockWallet = async (walletId: string) => {
const loadSeedForWallet = async (walletId: string) => {
if (!electrobun.rpc) return false
const wallet = wallets.find((w) => w.id === walletId)
if (!wallet) return false
currentWalletId = walletId
const [vaultSeed] = await tryCatch(
electrobun.rpc.request.evmGetSeed({
electrobun.rpc.request.xrpGetSeed({
vaultKey: wallet.vaultKey,
}),
)
if (!vaultSeed) return false
seed = vaultSeed
address = wallet.address
await refresh()
await setAutoSync()
return true
}
@@ -203,8 +133,7 @@ export const EvmWallet = () => {
)
if (!authed) return false
if (!currentWalletId) return false
await loadSeedForWallet(currentWalletId)
return !!seed
return loadSeedForWallet(currentWalletId)
}
const unlockWithPassword = async (password: string) => {
@@ -219,22 +148,6 @@ export const EvmWallet = () => {
}
}
const loadSeedForWallet = async (walletId: string) => {
if (!electrobun.rpc) return false
const wallet = wallets.find((w) => w.id === walletId)
if (!wallet) return false
const [vaultSeed] = await tryCatch(
electrobun.rpc.request.evmGetSeed({
vaultKey: wallet.vaultKey,
}),
)
if (!vaultSeed) return false
seed = vaultSeed
await refresh()
await setAutoSync()
return true
}
const selectWallet = async (walletId: string) => {
currentWalletId = walletId
clearWallet()
@@ -244,7 +157,7 @@ export const EvmWallet = () => {
const [ph] = await tryCatch(
electrobun.rpc.request.getSecret({
service: 'koins',
name: `evm_auth_${walletId}`,
name: `xrp_auth_${walletId}`,
}),
)
if (ph) {
@@ -260,61 +173,89 @@ export const EvmWallet = () => {
if (!currentPasswordHash && biometricAvailable) {
return unlockWithBiometrics()
}
if (!currentPasswordHash) {
return loadSeedForWallet(walletId)
}
return false
}
const createWallet = async (
const saveWallet = async (
kind: 'create' | 'import',
name: string,
phrase: string,
secret: string | undefined,
password?: string,
) => {
loading = true
error = ''
try {
mnemonicToAccount(phrase.trim())
if (!electrobun.rpc) throw new Error('RPC not available')
let passwordHash: string | undefined
if (password) {
const ph = await hashPassword(password)
passwordHash = JSON.stringify(ph)
}
const [result, resultError] = await tryCatch(
electrobun.rpc!.request.evmCreateWallet({
name,
phrase: phrase.trim(),
passwordHash,
}),
)
if (resultError) throw resultError
const result: {
id: string
address: string
createdAt: string
seed?: string
} =
kind === 'create'
? await electrobun.rpc.request.xrpCreateWallet({
name,
hasPassword: !!passwordHash,
})
: await electrobun.rpc.request.xrpImportWallet({
name,
secret: secret!,
hasPassword: !!passwordHash,
})
if (passwordHash) {
await electrobun.rpc?.request.setSecret({
await electrobun.rpc.request.setSecret({
service: 'koins',
name: `evm_auth_${result.id}`,
name: `xrp_auth_${result.id}`,
value: passwordHash,
})
}
const newWallet: EvmWalletInfo = {
const newWallet: XrpWalletInfo = {
id: result.id,
name,
address: result.address,
hasPassword: !!passwordHash,
vaultKey: `evm_seed_${result.id}`,
vaultKey: `xrp_seed_${result.id}`,
createdAt: result.createdAt,
}
wallets = [...wallets, newWallet]
currentWalletId = result.id
if (passwordHash) currentPasswordHash = passwordHash
seed = phrase.trim()
seed = result.seed ?? secret!
address = result.address
await refresh()
return result.seed
} catch (e) {
error = e instanceof Error ? e.message : 'Invalid seed phrase'
error = e instanceof Error ? e.message : 'Failed to save wallet'
throw e
} finally {
loading = false
}
}
const createWallet = async (name: string, password?: string) => {
return saveWallet('create', name, undefined, password)
}
const importWallet = async (
name: string,
secret: string,
password?: string,
) => {
return saveWallet('import', name, secret, password)
}
const deleteWallet = async (walletId: string) => {
await electrobun.rpc?.request.evmDeleteWallet({ id: walletId })
await electrobun.rpc?.request.xrpDeleteWallet({ id: walletId })
wallets = wallets.filter((w) => w.id !== walletId)
if (currentWalletId === walletId) {
clearWallet()
@@ -327,103 +268,49 @@ export const EvmWallet = () => {
currentWalletId = null
}
const switchNetwork = async (id: NetworkId) => {
network = id
if (seed) await refresh()
}
const refresh = async () => {
if (!seed || !electrobun.rpc) return
if (!address || !electrobun.rpc) return
const rpc = electrobun.rpc
loadingBalance = true
loadingTransactions = true
balance = '0'
error = ''
const account = mnemonicToAccount(seed)
address = account.address
const client = createPublicClient({ transport: http(net().rpc) })
syncTxHistory()
;(async () => {
const [nativeBal] = await tryCatch(
client.getBalance({ address: account.address }),
const [bal] = await tryCatch(
rpc.request.xrpGetBalance({ address }),
)
if (nativeBal !== undefined) balance = formatEther(nativeBal)
const [bals] = await tryCatch(
electrobun.rpc.request.fetchTokenBalances({
address,
chainid: net().chainid,
}),
)
if (bals) tokenBalances = bals
if (bal) {
balance = bal.balance
funded = bal.funded
}
const [p] = await tryCatch(rpc.request.fetchXrpPrice({}))
if (p) price = p.usd
const [f] = await tryCatch(rpc.request.xrpGetFee({}))
if (f) fee = f.fee
loadingBalance = false
})()
;(async () => {
await fetchTxHistory()
const [txs] = await tryCatch(
rpc.request.xrpGetTransactions({ address }),
)
if (txs) transactions = txs
loadingTransactions = false
})()
}
const syncTxHistory = () => {
if (!address || !apiKey || !electrobun.rpc) return
electrobun.rpc.request.syncTxHistory({
address,
chainid: net().chainid,
})
}
const setAutoSync = async () => {
if (!address || !electrobun.rpc) return
electrobun.rpc.request.setAutoSync({
address,
chainid: net().chainid,
})
}
const flushTxCache = async () => {
await electrobun.rpc?.request.flushTxCache({})
await fetchTxHistory()
}
const saveApiKey = async (key: string) => {
await electrobun.rpc?.request.setSecret({
service: 'koins',
name: 'alchemy_key',
value: key,
})
apiKey = key
syncTxHistory()
if (address) await fetchTxHistory()
}
const fetchTxHistory = async () => {
if (!address) {
transactions = []
return
}
const txs = await electrobun.rpc?.request.fetchCachedTxHistory({
address,
chainid: net().chainid,
})
transactions = txs ?? []
}
const send = async (
to: string,
amount: string,
contractAddress?: string,
tokenDecimals?: number,
destinationTag?: number,
) => {
if (!electrobun.rpc || !seed) throw new Error('Wallet not unlocked')
const hash = await electrobun.rpc.request.evmSendTransfer({
seed,
const result = await electrobun.rpc.request.xrpSend({
secret: seed,
to,
amount,
chainid: net().chainid,
contractAddress,
tokenDecimals,
destinationTag,
})
await refresh()
return hash
return result
}
const lock = () => {
@@ -432,7 +319,6 @@ export const EvmWallet = () => {
const reset = () => {
clearWallet()
apiKey = ''
wallets = []
currentWalletId = null
}
@@ -464,34 +350,14 @@ export const EvmWallet = () => {
get balance() {
return balance
},
get tokenBalances() {
return tokenBalances
get funded() {
return funded
},
get network() {
return network
get price() {
return price
},
get chainid() {
return net().chainid
},
get networkName() {
return net().name
},
get symbol() {
return net().symbol
},
get explorerUrl() {
return net().explorerUrl
},
get explorerAddressUrl() {
return address
? `${net().explorerUrl.replace('/tx/', '/address/')}${address}`
: ''
},
get networks() {
return networks
},
get apiKey() {
return apiKey
get fee() {
return fee
},
get transactions() {
return transactions
@@ -526,8 +392,8 @@ export const EvmWallet = () => {
get currentPasswordHash() {
return currentPasswordHash
},
set apiKey(v: string) {
apiKey = v
get explorerAddressUrl() {
return address ? `${xrpNetwork.explorerAddressUrl}${address}` : ''
},
set error(v: string) {
error = v
@@ -537,9 +403,7 @@ export const EvmWallet = () => {
logout,
refresh,
createWallet,
switchNetwork,
saveApiKey,
fetchTxHistory,
importWallet,
lock,
reset,
resetApp,
@@ -548,14 +412,11 @@ export const EvmWallet = () => {
hashPassword,
selectWallet,
selectAndUnlockWallet,
unlockWallet,
loadSeedForWallet,
deleteWallet,
clearSelection,
send,
syncTxHistory,
setAutoSync,
flushTxCache,
}
}
export const evmWallet = EvmWallet()
export const xrpWallet = XrpWallet()