Add Monero send page with transfer RPC and account selection
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type {
|
|||||||
MoneroWalletStatus,
|
MoneroWalletStatus,
|
||||||
MoneroSubaddressEntry,
|
MoneroSubaddressEntry,
|
||||||
MoneroAccountEntry,
|
MoneroAccountEntry,
|
||||||
|
MoneroSendResult,
|
||||||
TxEntry,
|
TxEntry,
|
||||||
TransactionDetails,
|
TransactionDetails,
|
||||||
TokenBalanceResult,
|
TokenBalanceResult,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
← 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>
|
||||||
Reference in New Issue
Block a user