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
+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>