create accounts/subaddresses, chunked address style, fix canSend for subaddresses
This commit is contained in:
@@ -17,6 +17,8 @@ export {
|
|||||||
getDaemonHeight,
|
getDaemonHeight,
|
||||||
transfer,
|
transfer,
|
||||||
sweepAll,
|
sweepAll,
|
||||||
|
createAccount,
|
||||||
|
createSubaddress,
|
||||||
isWalletOpen,
|
isWalletOpen,
|
||||||
isConnected,
|
isConnected,
|
||||||
listWallets,
|
listWallets,
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
Reference in New Issue
Block a user