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
+48 -61
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import {
Card,
@@ -13,14 +13,20 @@
import { onMount } from 'svelte'
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
const w = wallet
let feError = $state<string | null>(null)
onMount(() => {
frontendLog('index.svelte mounted')
w.init().catch((e) => frontendLogError('w.init() failed', e))
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
xrpWallet.init().catch((e) => frontendLogError('xrpWallet.init() failed', e))
})
$effect(() => {
if (moneroWallet.accountType === 'monero') {
navigate('/monero')
} else if (xrpWallet.accountType === 'xrp') {
navigate('/xrp')
}
})
$effect(() => {
@@ -56,61 +62,42 @@
{/if}
<div class="mx-auto mt-24 max-w-sm">
{#if !w.ready}
<p class="text-center text-muted-foreground text-sm">
Loading...
</p>
{:else if w.accountType}
{#if w.accountType === 'multi'}
{navigate('/multicoin')}
{:else}
{navigate('/monero')}
{/if}
{:else}
<Card>
<CardHeader class="text-center">
<CardTitle>Welcome</CardTitle>
<CardDescription>
Choose an account type to get started
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
<Button
onclick={async () => {
await w.login()
navigate('/multicoin')
}}
class="w-full">
<span class="-ml-1 mr-2 flex items-center">
<img
src="/icons/ethereum.png"
alt=""
class="relative z-30 size-5 rounded-full ring-2 ring-accent" />
<img
src="/icons/binance.png"
alt=""
class="-ml-1.5 relative z-20 size-5 rounded-full ring-2 ring-accent" />
<img
src="/icons/polygon.png"
alt=""
class="-ml-1.5 relative z-10 size-5 rounded-full ring-2 ring-accent" />
</span>
</Button>
<Button
onclick={async () => {
await moneroWallet.login()
navigate('/monero')
}}
variant="outline"
class="w-full">
<img
src="/icons/monero.png"
alt=""
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
</Button>
</div>
</CardContent>
</Card>
{/if}
<Card>
<CardHeader class="text-center">
<CardTitle>Welcome</CardTitle>
<CardDescription>
Choose an account type to get started
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
<Button
onclick={async () => {
await moneroWallet.login()
navigate('/monero')
}}
variant="outline"
class="w-full">
<img
src="/icons/monero.png"
alt=""
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
Monero
</Button>
<Button
onclick={async () => {
await xrpWallet.login()
navigate('/xrp')
}}
variant="outline"
class="w-full">
<img
src="/icons/xrp.png"
alt=""
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
XRP
</Button>
</div>
</CardContent>
</Card>
</div>
@@ -1,7 +0,0 @@
<script lang="ts">
import EvmDashboard from '$lib/components/EvmDashboard.svelte'
</script>
<div class="mx-auto mt-16 max-w-md">
<EvmDashboard networkId="bsc" />
</div>
@@ -1,7 +0,0 @@
<script lang="ts">
import EvmDashboard from '$lib/components/EvmDashboard.svelte'
</script>
<div class="mx-auto mt-16 max-w-md">
<EvmDashboard networkId="eth" />
</div>
@@ -1,7 +0,0 @@
<script lang="ts">
import EvmDashboard from '$lib/components/EvmDashboard.svelte'
</script>
<div class="mx-auto mt-16 max-w-md">
<EvmDashboard networkId="polygon" />
</div>
@@ -1,220 +0,0 @@
<script lang="ts">
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
import { electrobun } from '$lib/electrobun.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '$lib/components/ui/select/index.js'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
import { parseEther, formatEther, parseGwei } from 'viem'
const w = wallet
type TokenOption = {
symbol: string
balance: string
contractAddress: string
decimals: number
logo?: string
}
const tokens = $derived<TokenOption[]>([
{
symbol: w.symbol,
balance: w.balance,
contractAddress: '',
decimals: 18,
},
...w.tokenBalances.map((t) => ({
symbol: t.symbol,
balance: t.balance,
contractAddress: t.contractAddress,
decimals: t.decimals,
logo: t.logo,
})),
])
let selectedSymbol = $state(w.symbol)
let recipient = $state('')
let amount = $state('')
let sending = $state(false)
let sentResult = $state<{ txHash: string } | null>(null)
let sendError = $state('')
let sendAll = $state(false)
const selectedToken = $derived(
tokens.find((t) => t.symbol === selectedSymbol) ?? tokens[0],
)
const exceedsBalance = $derived(
amount !== '' &&
selectedToken.balance !== '0' &&
parseFloat(amount) > parseFloat(selectedToken.balance),
)
const canSend = $derived(
recipient.length === 42 &&
recipient.startsWith('0x') &&
!sending &&
(sendAll || (amount !== '' && parseFloat(amount) > 0)),
)
const handleSend = async () => {
sending = true
sendError = ''
sentResult = null
try {
const isNative = !selectedToken.contractAddress
let finalAmount = String(amount)
if (sendAll && isNative) {
const gasPriceGwei = await electrobun.rpc?.request.fetchGasPrice({ chainid: w.chainid })
if (!gasPriceGwei) throw new Error('Could not fetch gas price')
const gasWei = parseGwei(gasPriceGwei) * 21000n
const balanceWei = parseEther(selectedToken.balance)
const sendWei = balanceWei - gasWei
if (sendWei <= 0n) throw new Error('Balance too low to cover gas')
finalAmount = formatEther(sendWei)
} else if (sendAll) {
finalAmount = String(selectedToken.balance)
}
const txHash = await w.send(
recipient,
finalAmount,
selectedToken.contractAddress || undefined,
selectedToken.decimals,
)
sentResult = { txHash }
recipient = ''
amount = ''
sendAll = false
} catch (e) {
sendError = e instanceof Error ? e.message : 'Send failed'
} finally {
sending = false
}
}
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() => navigate(w.network === 'eth' ? '/multicoin' : `/multicoin/${w.network}`)}
class="mb-4">
&larr; Back
</Button>
<Card>
<CardHeader>
<CardTitle>Send</CardTitle>
<CardDescription>
Balance: {selectedToken.balance} {selectedToken.symbol}
</CardDescription>
</CardHeader>
<CardContent>
<form
onsubmit={(e) => {
e.preventDefault()
handleSend()
}}
class="space-y-4">
<div class="space-y-1.5">
<label class="text-xs font-medium">Asset</label>
<Select type="single" bind:value={selectedSymbol}>
<SelectTrigger>
{selectedToken.symbol}
</SelectTrigger>
<SelectContent>
{#each tokens as t}
<SelectItem value={t.symbol}>
{t.symbol} ({t.balance})
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Recipient Address</label>
<Input
placeholder="0x..."
bind:value={recipient} />
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Amount ({selectedToken.symbol})</label>
<div class="flex gap-2 items-start">
<div class="flex-1">
<Input
type="number"
step="0.000000000000000001"
min="0"
placeholder="0.0"
bind:value={amount} />
</div>
<Button
variant="outline"
onclick={() => { amount = selectedToken.balance }}
class="mt-0 shrink-0">
All
</Button>
</div>
{#if exceedsBalance}
<p class="text-xs text-red-500">
Amount exceeds balance
</p>
{/if}
</div>
{#if !selectedToken.contractAddress}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={sendAll}
class="h-3.5 w-3.5" />
<span class="text-xs text-muted-foreground">
Send all (deducts estimated gas fee)
</span>
</label>
{/if}
<Button type="submit" disabled={!canSend} class="w-full">
{sending ? 'Sending...' : 'Send'}
</Button>
</form>
{#if sendError}
<p class="mt-3 text-xs text-red-500">{sendError}</p>
{/if}
{#if sentResult}
<div
class="mt-4 space-y-2 rounded-md border border-green-500/30 bg-green-500/5 p-3">
<p class="text-xs font-medium text-green-600">
Transaction Sent
</p>
<p class="font-mono text-xs break-all">
<a
href="{w.explorerUrl}{sentResult.txHash}"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-green-500">
{sentResult.txHash}
</a>
</p>
</div>
{/if}
</CardContent>
</Card>
</div>
+17 -89
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
import { electrobun } from '$lib/electrobun.js'
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import {
Card,
CardContent,
@@ -12,19 +10,13 @@
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
import ArrowLeft from '@lucide/svelte/icons/arrow-left'
import Eye from '@lucide/svelte/icons/eye'
import Fingerprint from '@lucide/svelte/icons/fingerprint'
import Trash2 from '@lucide/svelte/icons/trash-2'
import WalletIcon from '@lucide/svelte/icons/wallet'
const w = wallet
const w = xrpWallet
let confirm = $state(false)
let resetting = $state(false)
let deletingWalletId = $state<string | null>(null)
let flushing = $state(false)
let seedRevealed = $state(false)
let seedPassword = $state('')
let seedError = $state('')
async function handleReset() {
resetting = true
@@ -37,20 +29,22 @@
async function handleDeleteWallet(id: string) {
await w.deleteWallet(id)
deletingWalletId = null
if (w.wallets.length === 0) navigate('/multicoin')
}
async function handleFlushCache() {
flushing = true
await w.flushTxCache()
flushing = false
if (w.wallets.length === 0) navigate('/xrp')
}
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => navigate(w.network === 'eth' ? '/multicoin' : `/multicoin/${w.network}`)}>
<Button
variant="outline"
size="sm"
onclick={() =>
w.currentWalletId
? navigate('/xrp/wallet/:id', {
params: { id: w.currentWalletId },
})
: navigate('/xrp')}>
<ArrowLeft size={16} />
Back
</Button>
@@ -59,7 +53,7 @@
<Card>
<CardHeader>
<CardTitle>Wallets</CardTitle>
<CardDescription>Manage your EVM wallets</CardDescription>
<CardDescription>Manage your XRP wallets</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-2">
@@ -68,8 +62,8 @@
<WalletIcon size={18} 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">
Created {new Date(wal.createdAt).toLocaleDateString()}
<p class="text-xs text-muted-foreground truncate font-mono">
{wal.address}
</p>
</div>
{#if deletingWalletId === wal.id}
@@ -92,83 +86,17 @@
</CardContent>
</Card>
{/if}
{#if w.seed}
<Card>
<CardHeader>
<CardTitle>Seed Phrase</CardTitle>
<CardDescription>Reveal your wallet recovery phrase</CardDescription>
</CardHeader>
<CardContent>
{#if seedRevealed}
<div class="rounded-md border border-destructive/50 bg-destructive/5 p-3">
<p class="text-xs text-destructive font-medium mb-2">
Never share your seed phrase. Anyone with it can access your funds.
</p>
<p class="font-mono text-xs break-all">{w.seed}</p>
</div>
<Button variant="outline" size="sm" class="mt-3" onclick={() => { seedRevealed = false; seedPassword = ''; seedError = '' }}>
<Eye size={14} /> Hide
</Button>
{:else}
<div class="space-y-2">
{#if w.currentPasswordHash}
<div class="flex gap-2 items-end">
<Input type="password" placeholder="Enter password" bind:value={seedPassword} />
<Button onclick={async () => {
seedError = ''
const { salt, hash } = JSON.parse(w.currentPasswordHash!)
const { hash: check } = await w.hashPassword(seedPassword, salt)
if (check === hash) {
seedRevealed = true
} else {
seedError = 'Incorrect password'
}
seedPassword = ''
}}>Reveal</Button>
</div>
{/if}
{#if w.biometricAvailable}
<Button variant="outline" class="w-full" onclick={async () => {
seedError = ''
const ok = await electrobun.rpc?.request.biometricAuth({ reason: 'Reveal seed phrase' })
if (ok) seedRevealed = true
}}>
<Fingerprint size={14} /> Reveal with Touch ID
</Button>
{/if}
{#if !w.currentPasswordHash && !w.biometricAvailable}
<Button onclick={() => (seedRevealed = true)}>Reveal Seed Phrase</Button>
{/if}
{#if seedError}
<p class="text-xs text-destructive">{seedError}</p>
{/if}
</div>
{/if}
</CardContent>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
<CardDescription>App preferences and data management</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div>
<p class="font-medium text-xs">Transaction Cache</p>
<p class="text-muted-foreground text-xs mt-1">
Clears all cached transaction history and sync status. Data will be re-fetched from Alchemy on next refresh.
</p>
<div class="mt-3">
<Button variant="outline" onclick={handleFlushCache} disabled={flushing}>
{flushing ? 'Flushing...' : 'Flush Transaction Cache'}
</Button>
</div>
</div>
<div>
<p class="font-medium text-xs">Reset App</p>
<p class="text-muted-foreground text-xs mt-1">
Removes your seed, API key, and all cached data from the keychain
and database. You can then set up the wallet fresh.
Removes all XRP wallets and seeds from the keychain. You
can then set up the wallet fresh.
</p>
<div class="mt-3">
{#if confirm}
@@ -1,175 +0,0 @@
<script lang="ts">
import {
electrobun,
type TransactionDetails,
} from '$lib/electrobun.js'
import { evmWallet } from '$lib/states/evm-wallet.svelte.js'
import { networks } from '$lib/states/evm-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,
address: evmWallet.address || undefined,
})
.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('/multicoin')}
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>
{#if details.pairedValue}
<div
class="rounded-md border border-yellow-500/30 bg-yellow-500/5 p-2">
<p class="mb-0.5 flex items-center gap-1 text-xs font-medium text-yellow-600">
Swap
</p>
<p class="font-mono text-xs">
{Number(details.value).toFixed(4)}
{network?.symbol ?? 'ETH'}
<span class="mx-1">→</span>
{Number(details.pairedValue).toFixed(4)}
{details.pairedSymbol ?? network?.symbol ?? 'ETH'}
</p>
</div>
{/if}
<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>
<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>
+273
View File
@@ -0,0 +1,273 @@
<script lang="ts">
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import { Textarea } from '$lib/components/ui/textarea/index.js'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '$lib/components/ui/card/index.js'
import Fingerprint from '@lucide/svelte/icons/fingerprint'
import LockKeyhole from '@lucide/svelte/icons/lock-keyhole'
import Plus from '@lucide/svelte/icons/plus'
import WalletIcon from '@lucide/svelte/icons/wallet'
import Loader from '$lib/components/loader.svelte'
import { navigate } from 'sv-router/generated'
const w = xrpWallet
let inputWalletName = $state('')
let inputSeed = $state('')
let inputPassword = $state('')
let inputUnlockPassword = $state('')
let showCreateForm = $state(false)
let newSeed = $state<string | null>(null)
let initStarted = false
$effect(() => {
if (!initStarted) {
initStarted = true
w.init().then(async () => {
if (w.accountType === 'xrp') return
await w.login()
})
}
})
$effect(() => {
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
navigate('/xrp/wallet/:id', {
params: { id: w.currentWalletId },
})
}
})
const handleCreate = async () => {
const seed = await w.createWallet(
inputWalletName,
inputPassword || undefined,
)
newSeed = seed ?? null
}
const handleImport = async () => {
await w.importWallet(
inputWalletName,
inputSeed.trim(),
inputPassword || undefined,
)
}
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="flex flex-col gap-4">
{#if !w.ready}
<p class="text-center text-muted-foreground text-sm mt-8">
Loading...
</p>
{:else if !w.accountType}
<p class="text-center text-muted-foreground text-sm mt-8">
<a href="/" class="underline">Go to welcome page</a>
to get started
</p>
{:else}
<a
href="/"
class="text-xs text-muted-foreground underline mb-1 inline-block">
← Back to home
</a>
{#if newSeed}
<Card>
<CardHeader>
<CardTitle>Wallet Created</CardTitle>
<CardDescription>
Save this seed phrase — it is the only way to recover
your wallet
</CardDescription>
</CardHeader>
<CardContent>
<div class="rounded-md border border-destructive/50 bg-destructive/5 p-3 mb-3">
<p class="text-xs text-destructive font-medium mb-2">
Never share your seed. Anyone with it can access your
funds.
</p>
<p class="font-mono text-xs break-all">{newSeed}</p>
</div>
<p class="text-xs text-muted-foreground mb-3">
Your XRP address becomes active after it receives at
least 10 XRP (the base reserve).
</p>
<Button
class="w-full"
onclick={() => {
newSeed = null
inputWalletName = ''
inputPassword = ''
}}>
I saved my seed
</Button>
</CardContent>
</Card>
{:else if w.wallets.length === 0 || showCreateForm}
<Card>
<CardHeader>
<CardTitle>
{showCreateForm ? 'Add Another Wallet' : 'Create / Import Wallet'}
</CardTitle>
<CardDescription>
Generate a new XRP wallet or import an existing seed
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
<Input
placeholder="Wallet name (e.g. Main, Savings)"
bind:value={inputWalletName} />
<Button
onclick={handleCreate}
disabled={w.loading || !inputWalletName.trim()}>
{w.loading ? 'Creating...' : 'Generate New Wallet'}
</Button>
<hr class="border-muted" />
<p class="text-xs text-muted-foreground">
Or import an existing wallet:
</p>
<Textarea
placeholder="XRP seed (s...) or 12/24-word phrase"
bind:value={inputSeed} />
<Input
type="password"
placeholder="Set a password (optional)"
bind:value={inputPassword} />
<div class="flex gap-2">
<Button
variant="outline"
onclick={handleImport}
disabled={w.loading ||
!inputSeed.trim() ||
!inputWalletName.trim()}>
{w.loading ? 'Importing...' : 'Import Wallet'}
</Button>
{#if showCreateForm}
<Button
variant="outline"
onclick={() => (showCreateForm = false)}>
Cancel
</Button>
{/if}
</div>
</div>
{#if w.error}
<p class="mt-3 text-xs 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 truncate font-mono">
{wal.address}
</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"
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">
{#if w.loading}
<Loader />
{:else}
Unlock
{/if}
</Button>
</div>
{/if}
<Button
variant="outline"
onclick={() => w.clearSelection()}>
Pick another wallet
</Button>
</div>
{#if w.error}
<p class="mt-3 text-xs text-red-500">{w.error}</p>
{/if}
</CardContent>
</Card>
{/if}
{/if}
</div>
</div>
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
const w = xrpWallet
let recipient = $state('')
let destinationTag = $state('')
let amountXrp = $state('')
let sending = $state(false)
let sentResult = $state<{ hash: string; fee: string } | null>(null)
let sendError = $state('')
const isValidAddress = $derived(/^r[1-9A-HJ-NP-Za-km-z]{24,34}$/.test(recipient))
const amount = $derived(parseFloat(amountXrp))
const amountValid = $derived(!isNaN(amount) && amount > 0)
const exceedsBalance = $derived(amountValid && amount > parseFloat(w.balance))
const tagValid = $derived(
destinationTag === '' ||
(/^\d+$/.test(destinationTag) && Number(destinationTag) <= 4294967295),
)
const canSend = $derived(
isValidAddress &&
amountValid &&
!exceedsBalance &&
tagValid &&
!sending,
)
const handleSend = async () => {
sending = true
sendError = ''
sentResult = null
try {
const result = await w.send(
recipient,
amount.toString(),
destinationTag === '' ? undefined : Number(destinationTag),
)
sentResult = result
recipient = ''
destinationTag = ''
amountXrp = ''
} catch (e) {
sendError = e instanceof Error ? e.message : 'Send failed'
} finally {
sending = false
}
}
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() =>
navigate('/xrp/wallet/:id', {
params: { id: w.currentWalletId ?? '' },
})}
class="mb-4">
&larr; Back
</Button>
<Card>
<CardHeader>
<CardTitle>Send XRP</CardTitle>
<CardDescription>
Balance: {w.balance} XRP
{#if w.fee}
(fee: ~{w.fee} XRP)
{/if}
</CardDescription>
</CardHeader>
<CardContent>
<form
onsubmit={(e) => {
e.preventDefault()
handleSend()
}}
class="space-y-4">
<div class="space-y-1.5">
<label class="text-xs font-medium">Recipient Address</label>
<Input
placeholder="XRP address starting with r..."
bind:value={recipient} />
{#if recipient && !isValidAddress}
<p class="text-xs text-red-500">Invalid XRP address</p>
{/if}
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">
Destination Tag (optional)
</label>
<Input
type="number"
min="0"
step="1"
placeholder="Required by some exchanges"
bind:value={destinationTag} />
{#if !tagValid}
<p class="text-xs text-red-500">
Destination tag must be a number between 0 and
4294967295
</p>
{/if}
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Amount (XRP)</label>
<div class="relative">
<Input
type="number"
step="0.000001"
min="0"
placeholder="0.0"
bind:value={amountXrp} />
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
XRP
</span>
</div>
{#if exceedsBalance}
<p class="text-xs text-red-500">
Amount exceeds balance
</p>
{/if}
</div>
<Button type="submit" disabled={!canSend} class="w-full">
{sending ? 'Sending...' : 'Send'}
</Button>
</form>
{#if sendError}
<p class="mt-3 text-xs text-red-500">{sendError}</p>
{/if}
{#if sentResult}
<div
class="mt-4 space-y-2 rounded-md border border-green-500/30 bg-green-500/5 p-3">
<p class="text-xs font-medium text-green-600">
Transaction Sent
</p>
<p class="font-mono text-xs break-all">
Hash: {sentResult.hash}
</p>
<p class="font-mono text-xs">
Fee: {sentResult.fee} XRP
</p>
</div>
{/if}
</CardContent>
</Card>
</div>
@@ -0,0 +1,142 @@
<script lang="ts">
import { electrobun, type XrpTxDetails } from '$lib/electrobun.js'
import { xrpWallet, xrpNetwork } from '$lib/states/xrp-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'
import ArrowDown from '@lucide/svelte/icons/arrow-down'
import ArrowUp from '@lucide/svelte/icons/arrow-up'
let details = $state<XrpTxDetails | null>(null)
let loading = $state(true)
let error = $state('')
const hash = $derived(route.params.hash)
$effect(() => {
if (!hash) return
loading = true
error = ''
electrobun.rpc?.request
.xrpGetTxDetails({ hash })
.then((res) => {
details = res
loading = false
})
.catch((e) => {
error = e instanceof Error ? e.message : 'Failed to fetch details'
loading = false
})
})
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() =>
navigate('/xrp/wallet/:id', {
params: { id: xrpWallet.currentWalletId ?? '' },
})}
class="mb-4">
&larr; Back
</Button>
<Card>
<CardHeader>
<CardTitle>Transaction Details</CardTitle>
</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="flex items-center gap-2 rounded-md bg-muted p-2">
<div class="shrink-0 {details.to === xrpWallet.address ? 'text-green-500' : 'text-muted-foreground'}">
{#if details.to === xrpWallet.address}
<ArrowDown size={16} />
{:else}
<ArrowUp size={16} />
{/if}
</div>
<p class="font-medium">
{details.to === xrpWallet.address ? 'Received' : 'Sent'}
</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Transaction Hash</p>
<p class="break-all">{details.hash}</p>
</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}</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">Amount</p>
<p>{details.amount} XRP</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Fee</p>
<p>{details.fee} XRP</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">Ledger</p>
<p>{details.ledgerIndex.toLocaleString()}</p>
</div>
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Validated</p>
<p>{details.confirmed ? 'Yes' : 'No'}</p>
</div>
</div>
{#if details.destinationTag !== undefined}
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Destination Tag</p>
<p>{details.destinationTag}</p>
</div>
{/if}
{#if details.timestamp && details.timestamp !== '0'}
<div class="rounded-md bg-muted p-2">
<p class="text-muted-foreground mb-0.5">Date</p>
<p>{new Date(Number(details.timestamp) * 1000).toLocaleString()}</p>
</div>
{/if}
</div>
<div class="mt-4">
<Button
variant="outline"
size="sm"
class="w-full"
onclick={() =>
electrobun.rpc?.request.openExternal({
url: `${xrpNetwork.explorerUrl}${details?.hash}`,
})}>
View on Explorer
</Button>
</div>
{:else}
<p class="text-muted-foreground text-xs">No details available</p>
{/if}
</CardContent>
</Card>
</div>
@@ -0,0 +1,235 @@
<script lang="ts">
import { xrpWallet, xrpNetwork } from '$lib/states/xrp-wallet.svelte.js'
import { electrobun } from '$lib/electrobun.js'
import { Button } from '$lib/components/ui/button/index.js'
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 SettingsIcon from '@lucide/svelte/icons/settings'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import ExternalLink from '@lucide/svelte/icons/external-link'
import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte'
import * as Dialog from '$lib/components/ui/dialog/index.js'
import { CopyButton } from '$lib/components/ui/copy-button'
import { navigate, route } from 'sv-router/generated'
import { onMount } from 'svelte'
const w = xrpWallet
const walletId = $derived(route.params.id)
let qrDialogOpen = $state(false)
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
$state()
$effect(() => {
if (w.seed && !autoRefreshTimer) {
autoRefreshTimer = setInterval(() => {
w.refresh()
}, 30000)
} else if (!w.seed && autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = undefined
}
return () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = undefined
}
}
})
onMount(() => {
if (!w.seed || w.currentWalletId !== walletId) {
navigate('/xrp')
return
}
w.refresh()
})
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="flex flex-col gap-4">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>
{w.currentWallet?.name ?? 'XRP Wallet'}
</CardTitle>
<img src="/icons/xrp.png" alt="" class="size-6 rounded-full" />
</div>
<CardDescription>XRP Ledger</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-4 space-y-1">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1 space-y-1">
<p class="font-medium text-xs">Address</p>
<p class="font-mono text-xs break-all">{w.address}</p>
<div class="flex gap-1.5 mt-1">
<Dialog.Root bind:open={qrDialogOpen}>
<Dialog.Trigger>
<Button
variant="outline"
size="icon-sm"
class="text-muted-foreground"
aria-label="Show QR code">
<QrCodeIcon size={16} />
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div
class="flex flex-col items-center gap-3 py-4">
<QrCode text={w.address} size={256} />
<p
class="font-mono text-xs break-all text-center max-w-64">
{w.address}
</p>
</div>
</Dialog.Content>
</Dialog.Root>
<CopyButton
text={w.address}
variant="outline"
size="icon-sm"
class="text-muted-foreground">
</CopyButton>
<Button
variant="outline"
size="icon-sm"
class="text-muted-foreground"
aria-label="View on explorer"
onclick={() =>
electrobun.rpc?.request.openExternal({
url: w.explorerAddressUrl,
})}>
<ExternalLink size={16} />
</Button>
</div>
</div>
</div>
<p class="mt-2 font-medium text-xs">Balance</p>
{#if w.loadingBalance}
<div class="py-1"><Loader /></div>
{:else}
<div class="flex items-baseline gap-2">
<p class="font-mono text-lg">{w.balance} XRP</p>
{#if w.price}
<p class="font-mono text-sm text-muted-foreground">
≈ ${(
parseFloat(w.balance) * parseFloat(w.price)
).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
{/if}
</div>
{#if !w.funded}
<p class="text-xs text-muted-foreground">
Account not activated — it needs at least 10 XRP to
meet the base reserve.
</p>
{/if}
{#if w.fee}
<p class="text-xs text-muted-foreground mt-1">
Network fee: ~{w.fee} XRP
</p>
{/if}
{/if}
</div>
<div class="flex gap-2">
<Button
variant="outline"
onclick={() => navigate('/xrp/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>
<Card>
<CardHeader>
<CardTitle>Transactions</CardTitle>
</CardHeader>
<CardContent>
{#if w.loadingTransactions}
<div class="flex justify-center py-4">
<Loader />
</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 as tx}
<button
onclick={() =>
navigate('/xrp/tx/:hash', {
params: { hash: tx.hash },
})}
class="flex w-full items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left cursor-pointer hover:bg-muted/80 transition-colors">
<div
class="shrink-0 mt-0.5 {tx.direction === 'in'
? 'text-green-500'
: 'text-muted-foreground'}">
{#if tx.direction === 'in'}
<ArrowDown size={16} />
{:else}
<ArrowUp size={16} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<p class="font-medium">
{tx.direction === 'in' ? 'Received' : 'Sent'}
{#if !tx.confirmed}
<span class="text-muted-foreground">
(unconfirmed)
</span>
{/if}
</p>
<p class="font-mono text-muted-foreground">
{tx.amount} XRP
</p>
{#if tx.timestamp && tx.timestamp !== '0'}
<p class="text-muted-foreground">
{new Date(
Number(tx.timestamp) * 1000,
).toLocaleString()}
</p>
{/if}
<p
class="text-muted-foreground/50 truncate font-mono">
{tx.hash.slice(0, 12)}...
</p>
</div>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
</div>
</div>