Add per-account transaction filtering and QR codes for Monero

This commit is contained in:
2026-05-24 19:58:52 +08:00
parent 932501c32c
commit b1d14e1d8b
6 changed files with 101 additions and 62 deletions
+13 -15
View File
@@ -395,6 +395,14 @@ const rpc = BrowserView.defineRPC<RPC>({
openExternal: async ({ url }) => {
Utils.openExternal(url)
},
generateQrCode: async ({ text, size }) => {
const { default: QRCode } = await import('qrcode')
return QRCode.toString(text, {
type: 'svg',
width: size ?? 128,
margin: 1,
})
},
fetchTxHistory: async ({ address, chainid }) => {
const key = await Bun.secrets.get({
service: 'koins',
@@ -653,25 +661,15 @@ const rpc = BrowserView.defineRPC<RPC>({
daemonHeight,
}
},
moneroGetTransactions: async () => {
console.log('[rpc] moneroGetTransactions')
moneroGetTransactions: async ({ accountIndex }) => {
console.log('[rpc] moneroGetTransactions', { accountIndex })
if (!moneroManager)
throw new Error('Monero wallet RPC not started')
const txs = await moneroManager.getTransactions()
const result = (txs || []).map((tx: any) => ({
hash: tx.hash,
amount: tx.amount?.toString() ?? '0',
timestamp: String(tx.timestamp ?? 0),
direction:
tx.direction === 'in'
? ('in' as const)
: ('out' as const),
height: tx.height ?? 0,
}))
const txs = await moneroManager.getTransactions(accountIndex)
console.log(
`[rpc] moneroGetTransactions: ${result.length} txs`,
`[rpc] moneroGetTransactions: ${txs.length} txs`,
)
return result
return txs
},
moneroWalletStatus: async () => {
if (!moneroManager) {
+26 -36
View File
@@ -274,45 +274,35 @@ export class MoneroWalletManager {
return accounts?.map((a) => a.toJson()) ?? []
}
async getTransactions(): Promise<any[]> {
async getTransactions(accountIndex?: number): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const txs = (await this.wallet.getTxs()) as any[]
console.log(`[monero] transactions: ${txs?.length ?? 0} returned`)
if (txs?.length) {
for (const tx of txs.slice(0, 5)) {
const isIn = tx.getIsIncoming()
const isOut = tx.getIsOutgoing()
const amt = isIn
? (tx.getIncomingAmount() ?? 0n)
: (tx.getOutgoingAmount() ?? 0n)
console.log(
`[monero] tx: hash=${(tx.getHash() ?? '').substring(0, 12)} amount=${amt.toString()} dir=${isIn ? 'in' : 'out'} height=${tx.getHeight() ?? 0}`,
)
}
if (txs.length > 5)
console.log(`[monero] ... and ${txs.length - 5} more`)
const params: Record<string, any> = {
in: true,
out: true,
pending: false,
pool: false,
}
const result = (txs || []).map((tx: any) => {
const isIn = tx.getIsIncoming()
const isOut = tx.getIsOutgoing()
const amt = isIn
? (tx.getIncomingAmount() ?? 0n)
: (tx.getOutgoingAmount() ?? 0n)
const block = tx.getBlock()
const ts =
block?.getTimestamp() ?? tx.getReceivedTimestamp() ?? 0
return {
hash: tx.getHash(),
amount: amt?.toString() ?? '0',
timestamp: String(ts),
direction: isIn ? ('in' as const) : ('out' as const),
height: tx.getHeight() ?? 0,
if (accountIndex !== undefined) {
params.account_index = accountIndex
}
const result = await this.rawRpc('get_transfers', params)
const all: any[] = []
for (const dir of ['in', 'out'] as const) {
const transfers = result[dir] ?? []
for (const t of transfers) {
all.push({
hash: t.txid,
amount: t.amount?.toString() ?? '0',
timestamp: String(t.timestamp ?? 0),
direction: dir === 'in' ? ('in' as const) : ('out' as const),
height: t.height ?? 0,
note: t.note,
})
}
})
result.sort(
(a: any, b: any) => Number(b.timestamp) - Number(a.timestamp),
)
return result
}
all.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp))
console.log(`[monero] transactions: ${all.length} returned${accountIndex !== undefined ? ` for account ${accountIndex}` : ''}`)
return all
}
async getHeight(): Promise<number> {
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { electrobun } from '$lib/electrobun.js'
let { text, size = 128 }: { text: string; size?: number } = $props()
let svgContent = $state('')
$effect(() => {
if (!text || !electrobun.rpc) return
svgContent = ''
electrobun.rpc.request
.generateQrCode({ text, size })
.then((svg) => {
svgContent = svg
})
})
</script>
{#if svgContent}
{@html svgContent}
{/if}
+2 -1
View File
@@ -123,6 +123,7 @@ export type RPC = {
fetchTokenBalances: { params: { address: string; chainid: string }; response: TokenBalanceResult[] }
fetchTransactionDetails: { params: { hash: string; chainid: string; address?: string }; response: TransactionDetails | null }
openExternal: { params: { url: string }; response: void }
generateQrCode: { params: { text: string; size?: number }; response: string }
moneroBinaryStatus: { params: {}; response: MoneroBinaryStatus }
moneroDownloadBinary: { params: {}; response: void }
moneroStart: { params: { daemonAddress?: string }; response: MoneroWalletStatus }
@@ -131,7 +132,7 @@ export type RPC = {
moneroRestoreWallet: { params: { name: string; password: string; mnemonic: string; restoreHeight?: number }; response: { address: string } }
moneroOpenWallet: { params: { name: string; password: string }; response: void }
moneroGetBalance: { params: {}; response: MoneroBalance }
moneroGetTransactions: { params: {}; response: MoneroTxEntry[] }
moneroGetTransactions: { params: { accountIndex?: number }; response: MoneroTxEntry[] }
moneroWalletStatus: { params: {}; response: MoneroWalletStatus }
moneroGetAccounts: { params: {}; response: MoneroAccountEntry[] }
moneroListWallets: { params: {}; response: string[] }
@@ -39,6 +39,7 @@ export const MoneroWallet = () => {
let wallets = $state<string[]>([])
let loading = $state(false)
let error = $state('')
let selectedAccountIndex = $state(0)
const checkBiometric = async () => {
if (!electrobun.rpc) return
@@ -218,7 +219,9 @@ export const MoneroWallet = () => {
daemonHeight = bal.daemonHeight
}
const [result] = await tryCatch(
electrobun.rpc.request.moneroGetTransactions({}),
electrobun.rpc.request.moneroGetTransactions({
accountIndex: selectedAccountIndex,
}),
)
txs = result ?? []
await fetchAccounts()
@@ -273,6 +276,8 @@ export const MoneroWallet = () => {
set downloading(v: boolean) { downloading = v },
set running(v: boolean) { running = v },
set connected(v: boolean) { connected = v },
get selectedAccountIndex() { return selectedAccountIndex },
set selectedAccountIndex(v: number) { selectedAccountIndex = v },
init,
login,
logout,
+33 -9
View File
@@ -15,6 +15,7 @@
import Fingerprint from '@lucide/svelte/icons/fingerprint'
import SettingsIcon from '@lucide/svelte/icons/settings'
import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte'
import { navigate } from 'sv-router/generated'
const w = moneroWallet
@@ -194,8 +195,13 @@
</CardHeader>
<CardContent>
<div class="mb-4 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 items-start gap-3">
<QrCode text={w.address} size={128} />
<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>
</div>
<p class="mt-2 font-medium text-xs">Balance</p>
<p class="font-mono text-lg">{w.balance} XMR</p>
{#if w.unlockedAtomic !== w.balAtomic}
@@ -236,6 +242,19 @@
<Card>
<CardHeader>
<CardTitle>Transactions</CardTitle>
{#if w.accounts.length > 0}
<CardDescription>
<div class="flex flex-wrap gap-1.5 mt-1">
{#each w.accounts as acct}
<button
onclick={() => { w.selectedAccountIndex = acct.index; w.refresh() }}
class="rounded-md px-2 py-0.5 text-xs transition-colors {w.selectedAccountIndex === acct.index ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'}">
Account {acct.index}
</button>
{/each}
</div>
</CardDescription>
{/if}
</CardHeader>
<CardContent>
{#if w.loading}
@@ -299,15 +318,20 @@
{#if account.subaddresses.length > 1}
<div class="space-y-1 border-t border-border pt-2">
<p class="text-xs text-muted-foreground mb-1">Subaddresses</p>
{#each account.subaddresses as sub}
<div class="rounded-sm bg-background px-2 py-1.5 text-xs">
<div class="flex items-center justify-between">
<p class="font-medium">#{sub.index}{sub.label ? ` - ${sub.label}` : ''}</p>
<p class="text-muted-foreground">{atomicToXmr(sub.balance)} XMR</p>
{#each account.subaddresses as sub}
<div class="rounded-sm bg-background px-2 py-1.5 text-xs">
<div class="flex items-start gap-2">
<QrCode text={sub.address} size={64} />
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<p class="font-medium">#{sub.index}{sub.label ? ` - ${sub.label}` : ''}</p>
<p class="text-muted-foreground">{atomicToXmr(sub.balance)} XMR</p>
</div>
<p class="font-mono text-muted-foreground/50 truncate mt-0.5">{sub.address}</p>
</div>
<p class="font-mono text-muted-foreground/50 truncate">{sub.address}</p>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>