Add Monero send page with transfer RPC and account selection

This commit is contained in:
2026-05-24 19:43:32 +08:00
parent 30d8abf26b
commit cc32fdb20f
7 changed files with 280 additions and 1 deletions
+17
View File
@@ -712,6 +712,23 @@ const rpc = BrowserView.defineRPC<RPC>({
) )
return wallets return wallets
}, },
moneroTransfer: async ({ address, amount, priority, accountIndex }) => {
console.log('[rpc] moneroTransfer:', {
address,
amount,
priority,
})
if (!moneroManager)
throw new Error('Monero wallet RPC not started')
const result = await moneroManager.transfer(
address,
BigInt(amount),
priority ?? 0,
accountIndex ?? 0,
)
console.log('[rpc] moneroTransfer complete:', result.txHash)
return result
},
evmCreateWallet: async ({ name, phrase, passwordHash }) => { evmCreateWallet: async ({ name, phrase, passwordHash }) => {
console.log('[rpc] evmCreateWallet:', name) console.log('[rpc] evmCreateWallet:', name)
const id = crypto.randomUUID() const id = crypto.randomUUID()
+24
View File
@@ -337,6 +337,30 @@ export class MoneroWalletManager {
} }
} }
async transfer(
address: string,
amount: bigint,
priority: number = 0,
accountIndex: number = 0,
): Promise<{ txHash: string; fee: string; amount: string }> {
if (!this.wallet) throw new Error('Wallet RPC not started')
console.log(`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`)
const result = await this.rawRpc('transfer', {
destinations: [{ address, amount: amount.toString() }],
priority,
account_index: accountIndex,
get_tx_key: true,
get_tx_hex: false,
get_tx_metadata: false,
})
console.log(`[monero] transfer result: tx_hash=${result.tx_hash} fee=${result.fee} amount=${result.amount}`)
return {
txHash: result.tx_hash,
fee: result.fee?.toString() ?? '0',
amount: result.amount?.toString() ?? amount.toString(),
}
}
async isWalletOpen(): Promise<boolean> { async isWalletOpen(): Promise<boolean> {
if (!this.wallet) return false if (!this.wallet) return false
try { try {
+1
View File
@@ -7,6 +7,7 @@ export type {
MoneroWalletStatus, MoneroWalletStatus,
MoneroSubaddressEntry, MoneroSubaddressEntry,
MoneroAccountEntry, MoneroAccountEntry,
MoneroSendResult,
TxEntry, TxEntry,
TransactionDetails, TransactionDetails,
TokenBalanceResult, TokenBalanceResult,
+7
View File
@@ -40,6 +40,12 @@ export type MoneroSubaddressEntry = {
numBlocksToUnlock: number numBlocksToUnlock: number
} }
export type MoneroSendResult = {
txHash: string
fee: string
amount: string
}
export type MoneroAccountEntry = { export type MoneroAccountEntry = {
index: number index: number
primaryAddress: string primaryAddress: string
@@ -129,6 +135,7 @@ export type RPC = {
moneroWalletStatus: { params: {}; response: MoneroWalletStatus } moneroWalletStatus: { params: {}; response: MoneroWalletStatus }
moneroGetAccounts: { params: {}; response: MoneroAccountEntry[] } moneroGetAccounts: { params: {}; response: MoneroAccountEntry[] }
moneroListWallets: { params: {}; response: string[] } moneroListWallets: { params: {}; response: string[] }
moneroTransfer: { params: { address: string; amount: string; priority?: number; accountIndex?: number }; response: MoneroSendResult }
evmCreateWallet: { params: { name: string; phrase: string; passwordHash?: string }; response: { id: string; name: string; createdAt: string } } evmCreateWallet: { params: { name: string; phrase: string; passwordHash?: string }; response: { id: string; name: string; createdAt: string } }
evmListWallets: { params: {}; response: EvmWalletInfo[] } evmListWallets: { params: {}; response: EvmWalletInfo[] }
evmGetSeed: { params: { vaultKey: string }; response: string } evmGetSeed: { params: { vaultKey: string }; response: string }
@@ -1,4 +1,4 @@
import { electrobun, type MoneroTxEntry, type MoneroAccountEntry } from '$lib/electrobun' import { electrobun, type MoneroTxEntry, type MoneroAccountEntry, type MoneroSendResult } from '$lib/electrobun'
import { tryCatch } from '@koins/utils' import { tryCatch } from '@koins/utils'
export const moneroNetwork = { export const moneroNetwork = {
@@ -229,6 +229,23 @@ export const MoneroWallet = () => {
} }
} }
const send = async (
address: string,
amountAtomic: string,
priority?: number,
accountIndex?: number,
): Promise<MoneroSendResult> => {
if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroTransfer({
address,
amount: amountAtomic,
priority,
accountIndex,
})
await refresh()
return result
}
return { return {
get accountType() { return accountType }, get accountType() { return accountType },
get ready() { return ready }, get ready() { return ready },
@@ -273,6 +290,7 @@ export const MoneroWallet = () => {
restoreWallet, restoreWallet,
openWallet, openWallet,
refresh, refresh,
send,
} }
} }
@@ -219,6 +219,9 @@
{/if} {/if}
Refresh Refresh
</Button> </Button>
<Button variant="outline" onclick={() => navigate('/monero/send')}>
Send
</Button>
<Button variant="outline" onclick={() => navigate('/monero/settings')}> <Button variant="outline" onclick={() => navigate('/monero/settings')}>
<SettingsIcon size={16} /> <SettingsIcon size={16} />
</Button> </Button>
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import {
moneroWallet,
atomicToXmr,
} from '$lib/states/monero-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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '$lib/components/ui/select/index.js'
import { navigate } from 'sv-router/generated'
const w = moneroWallet
let recipient = $state('')
let amountXmr = $state('')
let priority = $state('0')
let accountIndex = $state(
w.accounts.length > 0 ? String(w.accounts[0].index) : '0',
)
let sending = $state(false)
let sentResult = $state<{ txHash: string; fee: string } | null>(
null,
)
let sendError = $state('')
const selectedAccount = $derived(
w.accounts.find((a) => String(a.index) === accountIndex),
)
const unlockedAtomic = $derived(
selectedAccount?.unlockedBalance ?? w.unlockedAtomic,
)
const balanceAtomic = $derived(
selectedAccount?.balance ?? w.balAtomic,
)
const amountAtomic = $derived.by(() => {
if (!amountXmr) return 0n
const num = parseFloat(amountXmr)
if (isNaN(num) || num <= 0) return 0n
return BigInt(Math.round(num * 1e12))
})
const exceedsBalance = $derived(
amountAtomic > BigInt(unlockedAtomic),
)
const canSend = $derived(
recipient.startsWith('4') &&
recipient.length >= 95 &&
amountAtomic > 0n &&
!exceedsBalance &&
!sending,
)
const handleSend = async () => {
sending = true
sendError = ''
sentResult = null
try {
const result = await w.send(
recipient,
amountAtomic.toString(),
parseInt(priority),
parseInt(accountIndex),
)
sentResult = { txHash: result.txHash, fee: result.fee }
recipient = ''
amountXmr = ''
} 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('/monero')}
class="mb-4">
&larr; Back
</Button>
<Card>
<CardHeader>
<CardTitle>Send XMR</CardTitle>
<CardDescription>
Balance: {atomicToXmr(unlockedAtomic)} XMR
{#if unlockedAtomic !== balanceAtomic}
(locked: {atomicToXmr(
(
BigInt(balanceAtomic) - BigInt(unlockedAtomic)
).toString(),
)} XMR)
{/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">From Account</label>
<Select type="single" bind:value={accountIndex}>
<SelectTrigger>
{accountIndex}
</SelectTrigger>
<SelectContent>
{#each w.accounts as acct}
<SelectItem value={String(acct.index)}>
Account {acct.index}{acct.label
? ` - ${acct.label}`
: ''}
({atomicToXmr(acct.unlockedBalance)} XMR)
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Recipient Address</label>
<Input
placeholder="Monero address starting with 4..."
bind:value={recipient} />
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Amount (XMR)</label>
<div class="relative">
<Input
type="number"
step="0.000000000001"
min="0"
placeholder="0.0"
bind:value={amountXmr} />
<span
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
XMR
</span>
</div>
{#if exceedsBalance}
<p class="text-xs text-red-500">
Amount exceeds unlocked balance
</p>
{/if}
<p class="text-xs text-muted-foreground">
{new Intl.NumberFormat().format(Number(amountAtomic))} atomic
units
</p>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium">Priority</label>
<Select type="single" bind:value={priority}>
<SelectTrigger>
{priority}
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Default</SelectItem>
<SelectItem value="1">Unimportant</SelectItem>
<SelectItem value="2">Normal</SelectItem>
<SelectItem value="3">Elevated</SelectItem>
<SelectItem value="4">Priority</SelectItem>
</SelectContent>
</Select>
</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.txHash}
</p>
<p class="font-mono text-xs">
Fee: {atomicToXmr(sentResult.fee)} XMR
</p>
</div>
{/if}
</CardContent>
</Card>
</div>