create accounts/subaddresses, chunked address style, fix canSend for subaddresses

This commit is contained in:
2026-05-26 13:33:28 +08:00
parent 34602a4bfa
commit 2139d5e6ff
7 changed files with 259 additions and 12 deletions
+2
View File
@@ -17,6 +17,8 @@ export {
getDaemonHeight, getDaemonHeight,
transfer, transfer,
sweepAll, sweepAll,
createAccount,
createSubaddress,
isWalletOpen, isWalletOpen,
isConnected, isConnected,
listWallets, listWallets,
+26
View File
@@ -259,6 +259,32 @@ export async function getAddress(
return address return address
} }
export async function createAccount(
state: MoneroWalletState,
label?: string,
): Promise<{ index: number; address: string }> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const result = await rawRpc(state, 'create_account', {
label: label ?? '',
})
console.log(`[monero] created account ${result.account_index}: ${result.address}`)
return { index: result.account_index, address: result.address }
}
export async function createSubaddress(
state: MoneroWalletState,
accountIndex: number,
label?: string,
): Promise<{ index: number; address: string }> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const result = await rawRpc(state, 'create_address', {
account_index: accountIndex,
label: label ?? '',
})
console.log(`[monero] created subaddress ${result.address_index} for account ${accountIndex}: ${result.address}`)
return { index: result.address_index, address: result.address }
}
export async function getAccounts( export async function getAccounts(
state: MoneroWalletState, state: MoneroWalletState,
): Promise<any[]> { ): Promise<any[]> {
+26
View File
@@ -18,6 +18,8 @@ import {
getFeeEstimate as walletGetFeeEstimate, getFeeEstimate as walletGetFeeEstimate,
transfer as walletTransfer, transfer as walletTransfer,
sweepAll as walletSweepAll, sweepAll as walletSweepAll,
createAccount as walletCreateAccount,
createSubaddress as walletCreateSubaddress,
isWalletOpen, isWalletOpen,
isConnected, isConnected,
} from '../../lib/monero' } from '../../lib/monero'
@@ -256,6 +258,30 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroSweepAll complete:', result.txHash) console.log('[rpc] moneroSweepAll complete:', result.txHash)
return result return result
}, },
moneroCreateAccount: async ({ label }: { label?: string }) => {
console.log('[rpc] moneroCreateAccount:', { label })
if (!state.manager) throw new Error('Monero wallet RPC not started')
const result = await walletCreateAccount(state.manager, label)
console.log('[rpc] moneroCreateAccount complete:', result)
return result
},
moneroCreateSubaddress: async ({
accountIndex,
label,
}: {
accountIndex: number
label?: string
}) => {
console.log('[rpc] moneroCreateSubaddress:', { accountIndex, label })
if (!state.manager) throw new Error('Monero wallet RPC not started')
const result = await walletCreateSubaddress(
state.manager,
accountIndex,
label,
)
console.log('[rpc] moneroCreateSubaddress complete:', result)
return result
},
moneroGetTransferDetails: async ({ txid }: { txid: string }) => { moneroGetTransferDetails: async ({ txid }: { txid: string }) => {
console.log('[rpc] moneroGetTransferDetails:', txid) console.log('[rpc] moneroGetTransferDetails:', txid)
if (!state.manager) if (!state.manager)
+2
View File
@@ -180,6 +180,8 @@ export type MoneroRpcRequests = {
moneroListWallets: { params: {}; response: string[] } moneroListWallets: { params: {}; response: string[] }
moneroTransfer: { params: { address: string; amount: string; priority?: number; accountIndex?: number }; response: MoneroSendResult } moneroTransfer: { params: { address: string; amount: string; priority?: number; accountIndex?: number }; response: MoneroSendResult }
moneroSweepAll: { params: { address: string; priority?: number; accountIndex?: number }; response: MoneroSendResult } moneroSweepAll: { params: { address: string; priority?: number; accountIndex?: number }; response: MoneroSendResult }
moneroCreateAccount: { params: { label?: string }; response: { index: number; address: string } }
moneroCreateSubaddress: { params: { accountIndex: number; label?: string }; response: { index: number; address: string } }
moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null } moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null }
moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | null } moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | null }
} }
@@ -308,6 +308,23 @@ export const MoneroWallet = () => {
return result return result
} }
const createAccount = async (label?: string) => {
if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroCreateAccount({ label })
await refresh()
return result
}
const createSubaddress = async (accountIndex: number, label?: string) => {
if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroCreateSubaddress({
accountIndex,
label,
})
await refresh()
return result
}
return { return {
get accountType() { get accountType() {
return accountType return accountType
@@ -412,6 +429,8 @@ export const MoneroWallet = () => {
refresh, refresh,
send, send,
sendAll, sendAll,
createAccount,
createSubaddress,
} }
} }
+183 -11
View File
@@ -7,6 +7,13 @@
import { Button } from '$lib/components/ui/button/index.js' import { Button } from '$lib/components/ui/button/index.js'
import { Textarea } from '$lib/components/ui/textarea/index.js' import { Textarea } from '$lib/components/ui/textarea/index.js'
import { Input } from '$lib/components/ui/input/index.js' import { Input } from '$lib/components/ui/input/index.js'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '$lib/components/ui/select/index.js'
import * as Dialog from '$lib/components/ui/dialog/index.js'
import { import {
Card, Card,
CardContent, CardContent,
@@ -31,6 +38,13 @@
} catch {} } catch {}
} }
const chunkAddress = (addr: string) => {
const start = addr.slice(0, 6)
const mid = addr.slice(6, 12)
const end = addr.slice(-6)
return { start, mid, end }
}
let moneroWalletName = $state('') let moneroWalletName = $state('')
let moneroWalletPass = $state('') let moneroWalletPass = $state('')
let moneroMnemonic = $state('') let moneroMnemonic = $state('')
@@ -48,6 +62,12 @@
estimatedFee: string estimatedFee: string
} | null>(null) } | null>(null)
let accountDialogOpen = $state(false)
let accountLabel = $state('')
let subaddressDialogOpen = $state(false)
let subaddressAccountIndex = $state('')
let subaddressLabel = $state('')
$effect(() => { $effect(() => {
if (w.walletOpen && !autoRefreshTimer) { if (w.walletOpen && !autoRefreshTimer) {
autoRefreshTimer = setInterval(() => w.refresh(), 30000) autoRefreshTimer = setInterval(() => w.refresh(), 30000)
@@ -91,7 +111,7 @@
{:else if !w.accountType} {:else if !w.accountType}
<p class="text-center text-muted-foreground text-sm mt-8"> <p class="text-center text-muted-foreground text-sm mt-8">
<a href="/" class="underline">Go to welcome page</a> <a href="/" class="underline">Go to welcome page</a>
to get started to get started
</p> </p>
{:else if w.accountType === 'monero'} {:else if w.accountType === 'monero'}
{#if w.downloading} {#if w.downloading}
@@ -283,6 +303,7 @@
</Card> </Card>
{:else} {:else}
<!-- Monero wallet unlocked --> <!-- Monero wallet unlocked -->
{@const a = chunkAddress(w.address)}
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -313,7 +334,14 @@
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<p class="font-medium text-xs">Address</p> <p class="font-medium text-xs">Address</p>
<p class="font-mono text-xs break-all"> <p class="font-mono text-xs break-all">
{w.address} <span class="text-primary-foreground">
{a.start}
</span>
<span class="text-muted-foreground">{a.mid}</span>
<span class="text-muted-foreground/40">...</span>
<span class="text-primary-foreground">
{a.end}
</span>
</p> </p>
</div> </div>
</div> </div>
@@ -325,12 +353,19 @@
</p> </p>
{/if} {/if}
{#if feeEstimate} {#if feeEstimate}
{@const labels = ['Unimportant', 'Normal', 'Elevated', 'Priority']} {@const labels = [
'Unimportant',
'Normal',
'Elevated',
'Priority',
]}
<div class="mt-1 space-y-0.5"> <div class="mt-1 space-y-0.5">
{#each feeEstimate.fees as f, i} {#each feeEstimate.fees as f, i}
{@const feeAtomic = (BigInt(f) * 2500n) / 1000n} {@const feeAtomic = (BigInt(f) * 2500n) / 1000n}
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{labels[i]}: ~{atomicToXmr(feeAtomic.toString())} XMR {labels[i]}: ~{atomicToXmr(
feeAtomic.toString(),
)} XMR
</p> </p>
{/each} {/each}
</div> </div>
@@ -408,7 +443,12 @@
<div class="max-h-96 space-y-1 overflow-y-auto"> <div class="max-h-96 space-y-1 overflow-y-auto">
{#each w.txs as tx} {#each w.txs as tx}
<button <button
onclick={() => navigate(`/monero/tx/${tx.hash}`)} onclick={() =>
navigate(`/monero/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"> 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 <div
class="shrink-0 mt-0.5 {tx.direction === 'in' class="shrink-0 mt-0.5 {tx.direction === 'in'
@@ -457,7 +497,121 @@
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="flex gap-2 mb-3">
<Dialog.Root bind:open={accountDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">
+ Account
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Account</Dialog.Title>
<Dialog.Description>
Optional label for the new account
</Dialog.Description>
</Dialog.Header>
<div class="px-6 pb-4 space-y-3">
<Input
placeholder="Account label (optional)"
bind:value={accountLabel} />
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
accountDialogOpen = false
accountLabel = ''
}}>
Cancel
</Button>
<Button
size="sm"
onclick={async () => {
await w.createAccount(
accountLabel || undefined,
)
accountDialogOpen = false
accountLabel = ''
}}>
Create
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={subaddressDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">
+ Subaddress
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Subaddress</Dialog.Title>
<Dialog.Description>
Account index and optional label
</Dialog.Description>
</Dialog.Header>
<div class="px-6 pb-4 space-y-3">
<div class="space-y-1.5">
<label class="text-xs font-medium">
Account
</label>
<Select
type="single"
bind:value={subaddressAccountIndex}>
<SelectTrigger>
{subaddressAccountIndex
? `Account ${subaddressAccountIndex}`
: 'Select account'}
</SelectTrigger>
<SelectContent>
{#each w.accounts as acct}
<SelectItem value={String(acct.index)}>
Account {acct.index}{acct.label
? ` - ${acct.label}`
: ''}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<Input
placeholder="Subaddress label (optional)"
bind:value={subaddressLabel} />
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
}}>
Cancel
</Button>
<Button
size="sm"
disabled={!subaddressAccountIndex}
onclick={async () => {
await w.createSubaddress(
parseInt(subaddressAccountIndex),
subaddressLabel || undefined,
)
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
}}>
Create
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
</div>
{#each w.accounts as account} {#each w.accounts as account}
{@const a = chunkAddress(account.primaryAddress)}
<div class="mb-3 rounded-md bg-muted p-3"> <div class="mb-3 rounded-md bg-muted p-3">
<div class="mb-1 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between">
<p class="text-xs font-medium"> <p class="text-xs font-medium">
@@ -469,10 +623,16 @@
</div> </div>
<p <p
class="mb-2 font-mono text-xs text-muted-foreground break-all"> class="mb-2 font-mono text-xs text-muted-foreground break-all">
{account.primaryAddress} <span class="text-primary-foreground">
{a.start}
</span>
<span class="text-muted-foreground">{a.mid}</span>
<span class="text-muted-foreground/40">...</span>
<span class="text-primary-foreground">{a.end}</span>
</p> </p>
<button <button
onclick={() => copyToClipboard(account.primaryAddress)} onclick={() =>
copyToClipboard(account.primaryAddress)}
class="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"> class="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors">
<Copy size={12} /> Copy address <Copy size={12} /> Copy address
</button> </button>
@@ -482,6 +642,7 @@
Subaddresses Subaddresses
</p> </p>
{#each account.subaddresses as sub} {#each account.subaddresses as sub}
{@const a = chunkAddress(sub.address)}
<div <div
class="rounded-sm bg-background px-2 py-1.5 text-xs"> class="rounded-sm bg-background px-2 py-1.5 text-xs">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
@@ -498,12 +659,23 @@
{atomicToXmr(sub.balance)} XMR {atomicToXmr(sub.balance)} XMR
</p> </p>
</div> </div>
<p <p class="font-mono text-xs mt-0.5">
class="font-mono text-muted-foreground/50 truncate mt-0.5"> <span class="text-primary-foreground">
{sub.address} {a.start}
</span>
<span class="text-muted-foreground">
{a.mid}
</span>
<span class="text-muted-foreground/40">
...
</span>
<span class="text-primary-foreground">
{a.end}
</span>
</p> </p>
<button <button
onclick={() => copyToClipboard(sub.address)} onclick={() =>
copyToClipboard(sub.address)}
class="mt-1 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"> class="mt-1 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors">
<Copy size={12} /> Copy <Copy size={12} /> Copy
</button> </button>
+1 -1
View File
@@ -56,7 +56,7 @@
amountAtomic > BigInt(unlockedAtomic), amountAtomic > BigInt(unlockedAtomic),
) )
const canSend = $derived( const canSend = $derived(
recipient.startsWith('4') && (recipient.startsWith('4') || recipient.startsWith('8')) &&
recipient.length >= 95 && recipient.length >= 95 &&
(sendAll || amountAtomic > 0n) && (sendAll || amountAtomic > 0n) &&
(sendAll || !exceedsBalance) && (sendAll || !exceedsBalance) &&