From 6fe9e6306b744a6d1718bbc3740a67755289ec0c Mon Sep 17 00:00:00 2001 From: TZGyn Date: Tue, 26 May 2026 03:08:58 +0800 Subject: [PATCH] refactor MoneroWalletManager class to function module; fix settings page imports --- apps/desktop/src/bun/index.ts | 2 +- apps/desktop/src/bun/lib/monero/index.ts | 23 +- apps/desktop/src/bun/lib/monero/wallet.ts | 706 ++++++++---------- apps/desktop/src/bun/rpc/monero/index.ts | 211 ++---- .../src/lib/states/monero-wallet.svelte.ts | 184 +++-- apps/desktop/src/routes/index.svelte | 2 + .../desktop/src/routes/monero/settings.svelte | 60 +- 7 files changed, 575 insertions(+), 613 deletions(-) diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index dcdbc5e..bb1ebf3 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -12,7 +12,7 @@ import { join } from 'path' import { getENV } from './lib/get-env' import { createEvmHandlers } from './rpc/evm' import { createMoneroHandlers } from './rpc/monero' -import { MoneroWalletManager } from './lib/monero' +import type { MoneroWalletState } from './lib/monero' import { canPromptTouchID, promptTouchID } from './lib/biometric' import { sqlite } from './lib/sqlite' import { evmWallets } from './lib/db/schema' diff --git a/apps/desktop/src/bun/lib/monero/index.ts b/apps/desktop/src/bun/lib/monero/index.ts index 7ce2b1d..9621ea3 100644 --- a/apps/desktop/src/bun/lib/monero/index.ts +++ b/apps/desktop/src/bun/lib/monero/index.ts @@ -1,2 +1,23 @@ export * from './binary' -export { MoneroWalletManager } from './wallet' +export { + createMoneroWalletState, + start, + stop, + createWallet, + restoreWallet, + openWallet, + closeWallet, + getBalance, + getAddress, + getAccounts, + getTransactions, + getTransferDetails, + getHeight, + getFeeEstimate, + getDaemonHeight, + transfer, + isWalletOpen, + isConnected, + listWallets, +} from './wallet' +export type { MoneroWalletState } from './wallet' diff --git a/apps/desktop/src/bun/lib/monero/wallet.ts b/apps/desktop/src/bun/lib/monero/wallet.ts index 42ca3cb..fa8ffbb 100644 --- a/apps/desktop/src/bun/lib/monero/wallet.ts +++ b/apps/desktop/src/bun/lib/monero/wallet.ts @@ -6,459 +6,343 @@ import { } from 'monero-ts' import { getBinaryPath, getWalletDir } from './binary' import { existsSync, readdirSync } from 'fs' -import { join } from 'path' const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081' -export class MoneroWalletManager { - private wallet: MoneroWalletRpc | null = null - private process: Bun.Subprocess | null = null - private rpcPort: number - private rpcUser: string - private rpcPassword: string - private daemonAddress: string = DEFAULT_DAEMON +export type MoneroWalletState = { + wallet: MoneroWalletRpc | null + process: Bun.Subprocess | null + rpcPort: number + rpcUser: string + rpcPassword: string + daemonAddress: string +} - constructor() { - this.rpcPort = 28084 - this.rpcUser = 'koins' - this.rpcPassword = crypto.randomUUID() +export function createMoneroWalletState(): MoneroWalletState { + return { + wallet: null, + process: null, + rpcPort: 28084, + rpcUser: 'koins', + rpcPassword: crypto.randomUUID(), + daemonAddress: DEFAULT_DAEMON, } +} - listWallets(): string[] { - const dir = getWalletDir() - if (!existsSync(dir)) return [] - const files = readdirSync(dir) - const wallets = files - .filter((f) => f.endsWith('.keys')) - .map((f) => f.replace(/\.keys$/, '')) - console.log( - `[monero] wallets on disk: ${wallets.join(', ') || 'none'}`, - ) - return wallets - } +function rpcUrl(state: MoneroWalletState): string { + return `http://127.0.0.1:${state.rpcPort}` +} + +function rpcConnection(state: MoneroWalletState): MoneroRpcConnection { + return new MoneroRpcConnection({ + uri: rpcUrl(state), + username: state.rpcUser, + password: state.rpcPassword, + }) +} - private get rpcUrl() { - return `http://127.0.0.1:${this.rpcPort}` - } +async function rawRpc(state: MoneroWalletState, method: string, params: Record = {}): Promise { + const conn = rpcConnection(state) + const res = await conn.sendJsonRequest(method, params) + if (res?.error) throw new Error(res.error.message || JSON.stringify(res.error)) + return res?.result +} - private get rpcConnection(): MoneroRpcConnection { - return new MoneroRpcConnection({ - uri: this.rpcUrl, - username: this.rpcUser, - password: this.rpcPassword, - }) +async function waitForRpc(state: MoneroWalletState, timeoutMs = 30000): Promise { + const start = Date.now() + let lastErr = '' + while (Date.now() - start < timeoutMs) { + try { + await Bun.connect({ + hostname: '127.0.0.1', + port: state.rpcPort, + socket: { + open(s) { s.end() }, + close() {}, + data() {}, + error() {}, + }, + }) + console.log(`[monero] RPC ready after ${Date.now() - start}ms`) + return + } catch (e) { + lastErr = String(e) + } + await new Promise((r) => setTimeout(r, 500)) } + throw new Error(`monero-wallet-rpc failed to start within ${timeoutMs}ms: ${lastErr}`) +} - async start(daemonAddress: string = DEFAULT_DAEMON) { - if (!existsSync(getBinaryPath())) - throw new Error( - 'monero-wallet-rpc not installed. Call downloadBinary() first.', - ) - this.daemonAddress = daemonAddress +export async function start(state: MoneroWalletState, daemonAddress: string = DEFAULT_DAEMON) { + if (!existsSync(getBinaryPath())) + throw new Error('monero-wallet-rpc not installed. Call downloadBinary() first.') + state.daemonAddress = daemonAddress - console.log(`[monero] starting wallet-rpc process...`) - console.log(`[monero] daemon address: ${this.daemonAddress}`) - console.log(`[monero] wallet dir: ${getWalletDir()}`) + console.log(`[monero] starting wallet-rpc process...`) + console.log(`[monero] daemon address: ${state.daemonAddress}`) + console.log(`[monero] wallet dir: ${getWalletDir()}`) - const cmd = [ - getBinaryPath(), - '--daemon-address', - this.daemonAddress, - '--rpc-bind-port', - String(this.rpcPort), - '--rpc-bind-ip', - '127.0.0.1', - '--wallet-dir', - getWalletDir(), - '--rpc-login', - `${this.rpcUser}:${this.rpcPassword}`, - '--trusted-daemon', - ] + const cmd = [ + getBinaryPath(), + '--daemon-address', state.daemonAddress, + '--rpc-bind-port', String(state.rpcPort), + '--rpc-bind-ip', '127.0.0.1', + '--wallet-dir', getWalletDir(), + '--rpc-login', `${state.rpcUser}:${state.rpcPassword}`, + '--trusted-daemon', + ] - console.log(`[monero] spawning: ${cmd.join(' ')}`) - this.process = Bun.spawn(cmd, { - env: { ...process.env, LANG: 'en_US.UTF-8' }, - stdout: 'pipe', - stderr: 'pipe', - onExit(_proc, exitCode, _signalCode, error) { - console.log( - `[monero] wallet-rpc process exited with code ${exitCode}`, - error?.message ?? '', - ) - }, - }) - console.log(`[monero] wallet-rpc pid: ${this.process.pid}`) + console.log(`[monero] spawning: ${cmd.join(' ')}`) + state.process = Bun.spawn(cmd, { + env: { ...process.env, LANG: 'en_US.UTF-8' }, + stdout: 'pipe', + stderr: 'pipe', + onExit(_proc, exitCode, _signalCode, error) { + console.log(`[monero] wallet-rpc process exited with code ${exitCode}`, error?.message ?? '') + }, + }) + console.log(`[monero] wallet-rpc pid: ${state.process.pid}`) - const stdout = this.process.stdout.getReader() - ;(async () => { - while (true) { - const { done, value } = await stdout.read() - if (done) break - console.log( - `[monero:stdout] ${new TextDecoder().decode(value)}`, - ) - } - })() + const stdout = state.process.stdout.getReader() + ;(async () => { + while (true) { + const { done, value } = await stdout.read() + if (done) break + console.log(`[monero:stdout] ${new TextDecoder().decode(value)}`) + } + })() - const stderr = this.process.stderr.getReader() - ;(async () => { - while (true) { - const { done, value } = await stderr.read() - if (done) break - console.log( - `[monero:stderr] ${new TextDecoder().decode(value)}`, - ) - } - })() - - console.log( - `[monero] waiting for RPC server to become available...`, - ) - await this.waitForRpc() - console.log(`[monero] RPC server is ready`) - - console.log(`[monero] connecting MoneroWalletRpc client...`) - this.wallet = (await connectToWalletRpc( - this.rpcUrl, - this.rpcUser, - this.rpcPassword, - )) as MoneroWalletRpc - console.log(`[monero] MoneroWalletRpc connected successfully`) - } + const stderr = state.process.stderr.getReader() + ;(async () => { + while (true) { + const { done, value } = await stderr.read() + if (done) break + console.log(`[monero:stderr] ${new TextDecoder().decode(value)}`) + } + })() - private async rawRpc( - method: string, - params: Record = {}, - ): Promise { - const conn = this.rpcConnection - const res = await conn.sendJsonRequest(method, params) - if (res?.error) - throw new Error(res.error.message || JSON.stringify(res.error)) - return res?.result - } + console.log(`[monero] waiting for RPC server to become available...`) + await waitForRpc(state) + console.log(`[monero] RPC server is ready`) - private async waitForRpc(timeoutMs = 30000): Promise { - const start = Date.now() - let lastErr = '' - while (Date.now() - start < timeoutMs) { - try { - await Bun.connect({ - hostname: '127.0.0.1', - port: this.rpcPort, - socket: { - open(s) { - s.end() - }, - close() {}, - data() {}, - error() {}, - }, - }) - console.log( - `[monero] RPC ready after ${Date.now() - start}ms`, - ) - return - } catch (e) { - lastErr = String(e) - } - await new Promise((r) => setTimeout(r, 500)) - } - throw new Error( - `monero-wallet-rpc failed to start within ${timeoutMs}ms: ${lastErr}`, - ) - } + console.log(`[monero] connecting MoneroWalletRpc client...`) + state.wallet = await connectToWalletRpc(rpcUrl(state), state.rpcUser, state.rpcPassword) as MoneroWalletRpc + console.log(`[monero] MoneroWalletRpc connected successfully`) +} - async stop() { - console.log(`[monero] stopping wallet manager...`) - if (this.process) { - console.log( - `[monero] killing wallet-rpc process (pid ${this.process.pid})`, - ) - this.process.kill() - this.process = null - } - this.wallet = null - console.log(`[monero] wallet manager stopped`) +export async function stop(state: MoneroWalletState) { + console.log(`[monero] stopping wallet manager...`) + if (state.process) { + console.log(`[monero] killing wallet-rpc process (pid ${state.process.pid})`) + state.process.kill() + state.process = null } + state.wallet = null + console.log(`[monero] wallet manager stopped`) +} - async createWallet(name: string, password: string) { - console.log(`[monero] creating wallet: ${name}`) - const result = await this.rawRpc('create_wallet', { - filename: name, - password, - language: 'English', - }) - if (!this.wallet) throw new Error('Wallet RPC not started') - const address = await this.wallet.getAddress(0, 0) - console.log(`[monero] wallet created: ${name} -> ${address}`) - return { mnemonic: result.mnemonic, address } - } +export async function createWallet(state: MoneroWalletState, name: string, password: string) { + console.log(`[monero] creating wallet: ${name}`) + const result = await rawRpc(state, 'create_wallet', { filename: name, password, language: 'English' }) + if (!state.wallet) throw new Error('Wallet RPC not started') + const address = await state.wallet.getAddress(0, 0) + console.log(`[monero] wallet created: ${name} -> ${address}`) + return { mnemonic: result.mnemonic, address } +} - async restoreWallet( - name: string, - password: string, - mnemonic: string, - restoreHeight?: number, - ) { - console.log( - `[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`, - ) - await this.rawRpc('restore_deterministic_wallet', { - filename: name, - password, - seed: mnemonic, - restore_height: restoreHeight ?? 0, - language: 'English', - }) - console.log(`[monero] wallet restored: ${name}`) - } +export async function restoreWallet(state: MoneroWalletState, name: string, password: string, mnemonic: string, restoreHeight?: number) { + console.log(`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`) + await rawRpc(state, 'restore_deterministic_wallet', { + filename: name, password, seed: mnemonic, restore_height: restoreHeight ?? 0, language: 'English', + }) + console.log(`[monero] wallet restored: ${name}`) +} - async openWallet(name: string, password: string) { - console.log(`[monero] opening wallet: ${name}`) - if (!this.wallet) throw new Error('Wallet RPC not started') - await this.wallet.openWallet(name, password) - console.log(`[monero] wallet opened: ${name}`) - } +export async function openWallet(state: MoneroWalletState, name: string, password: string) { + console.log(`[monero] opening wallet: ${name}`) + if (!state.wallet) throw new Error('Wallet RPC not started') + await state.wallet.openWallet(name, password) + console.log(`[monero] wallet opened: ${name}`) +} - async closeWallet() { - console.log(`[monero] closing current wallet`) - if (!this.wallet) throw new Error('Wallet RPC not started') - await this.wallet.close() - console.log(`[monero] wallet closed`) - } +export async function closeWallet(state: MoneroWalletState) { + console.log(`[monero] closing current wallet`) + if (!state.wallet) throw new Error('Wallet RPC not started') + await state.wallet.close() + console.log(`[monero] wallet closed`) +} - async getBalance(): Promise<{ balance: bigint; unlocked: bigint }> { - if (!this.wallet) throw new Error('Wallet RPC not started') - const bal = (await this.wallet.getBalance()) as bigint - const unlocked = - (await this.wallet.getUnlockedBalance()) as bigint - console.log(`[monero] balance: ${bal} (unlocked: ${unlocked})`) - return { balance: bal, unlocked } - } +export async function getBalance(state: MoneroWalletState): Promise<{ balance: bigint; unlocked: bigint }> { + if (!state.wallet) throw new Error('Wallet RPC not started') + const balance = await state.wallet.getBalance() as bigint + const unlocked = await state.wallet.getUnlockedBalance() as bigint + console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`) + return { balance, unlocked } +} - async getAddress(accountIdx = 0, subIdx = 0): Promise { - if (!this.wallet) throw new Error('Wallet RPC not started') - const address = (await this.wallet.getAddress( - accountIdx, - subIdx, - )) as string - console.log(`[monero] address: ${address}`) - return address - } +export async function getAddress(state: MoneroWalletState, accountIdx = 0, subIdx = 0): Promise { + if (!state.wallet) throw new Error('Wallet RPC not started') + const address = await state.wallet.getAddress(accountIdx, subIdx) as string + console.log(`[monero] address: ${address}`) + return address +} - async getAccounts(): Promise { - if (!this.wallet) throw new Error('Wallet RPC not started') - const accounts = (await this.wallet.getAccounts(true)) as any[] - console.log( - `[monero] accounts: ${accounts?.length ?? 0} returned`, - ) - if (accounts?.length) { - for (const acct of accounts) { - const subs = acct.getSubaddresses() ?? [] - console.log( - `[monero] account ${acct.getIndex()}: balance=${acct.getBalance()?.toString() ?? '0'} subs=${subs.length} primary=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`, - ) - const has0 = subs.some((s: any) => s.getIndex() === 0) - if (!has0) { - console.log( - `[monero] sub 0 (primary): addr=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`, - ) - } - for (const sub of subs) { - console.log( - `[monero] sub ${sub.getIndex()}: addr=${(sub.getAddress() ?? '').substring(0, 16)}... label=${sub.getLabel() ?? ''} balance=${sub.getBalance()?.toString() ?? '0'} used=${sub.getIsUsed()}`, - ) - } +export async function getAccounts(state: MoneroWalletState): Promise { + if (!state.wallet) throw new Error('Wallet RPC not started') + const accounts = await state.wallet.getAccounts(true) as any[] + console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`) + if (accounts?.length) { + for (const acct of accounts) { + const subs = acct.getSubaddresses() ?? [] + console.log(`[monero] account ${acct.getIndex()}: balance=${acct.getBalance()?.toString() ?? '0'} subs=${subs.length} primary=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`) + const has0 = subs.some((s: any) => s.getIndex() === 0) + if (!has0) console.log(`[monero] sub 0 (primary): addr=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`) + for (const sub of subs) { + console.log(`[monero] sub ${sub.getIndex()}: addr=${(sub.getAddress() ?? '').substring(0, 16)}... label=${sub.getLabel() ?? ''} balance=${sub.getBalance()?.toString() ?? '0'} used=${sub.getIsUsed()}`) } } - return accounts?.map((a) => a.toJson()) ?? [] } + return accounts?.map((a) => a.toJson()) ?? [] +} - async getTransactions(accountIndex?: number): Promise { - if (!this.wallet) throw new Error('Wallet RPC not started') - const params: Record = { - in: true, - out: true, - pending: false, - pool: false, +export async function getTransactions(state: MoneroWalletState, accountIndex?: number): Promise { + if (!state.wallet) throw new Error('Wallet RPC not started') + const params: Record = { in: true, out: true, pending: false, pool: false } + if (accountIndex !== undefined) params.account_index = accountIndex + const result = await rawRpc(state, '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, + }) } - 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, - }) - } - } - 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 } + 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 getTransferDetails(txid: string): Promise { - if (!this.wallet) throw new Error('Wallet RPC not started') - const result = await this.rawRpc('get_transfer_by_txid', { txid }) - if (!result?.transfer) return null - const t = result.transfer - const isIn = t.type === 'in' - const isOut = t.type === 'out' - const dests = - t.destinations?.map((d: any) => ({ - address: d.address, - amount: d.amount?.toString() ?? '0', - })) ?? [] - const subaddrIndices = isIn - ? [ - { - major: t.subaddr_index?.major ?? 0, - minor: t.subaddr_index?.minor ?? 0, - }, - ] - : (t.subaddr_indices ?? []).map((s: any) => ({ - major: s.major ?? 0, - minor: s.minor ?? 0, - })) - return { - hash: t.txid, - direction: isIn ? ('in' as const) : ('out' as const), - amount: t.amount?.toString() ?? '0', - fee: t.fee?.toString() ?? '0', - height: t.height ?? 0, - timestamp: String(t.timestamp ?? 0), - confirmations: t.confirmations ?? 0, - unlockTime: t.unlock_time ?? 0, - locked: t.locked ?? false, - doubleSpend: t.double_spend_seen ?? false, - note: t.note, - paymentId: t.payment_id ?? '', - destinations: dests, - subaddrIndices, - } +export async function getTransferDetails(state: MoneroWalletState, txid: string): Promise { + if (!state.wallet) throw new Error('Wallet RPC not started') + const result = await rawRpc(state, 'get_transfer_by_txid', { txid }) + if (!result?.transfer) return null + const t = result.transfer + const isIn = t.type === 'in' + const isOut = t.type === 'out' + const dests = t.destinations?.map((d: any) => ({ address: d.address, amount: d.amount?.toString() ?? '0' })) ?? [] + const subaddrIndices = isIn + ? [{ major: t.subaddr_index?.major ?? 0, minor: t.subaddr_index?.minor ?? 0 }] + : (t.subaddr_indices ?? []).map((s: any) => ({ major: s.major ?? 0, minor: s.minor ?? 0 })) + return { + hash: t.txid, + direction: isIn ? ('in' as const) : ('out' as const), + amount: t.amount?.toString() ?? '0', + fee: t.fee?.toString() ?? '0', + height: t.height ?? 0, + timestamp: String(t.timestamp ?? 0), + confirmations: t.confirmations ?? 0, + unlockTime: t.unlock_time ?? 0, + locked: t.locked ?? false, + doubleSpend: t.double_spend_seen ?? false, + note: t.note, + paymentId: t.payment_id ?? '', + destinations: dests, + subaddrIndices, } +} - async getHeight(): Promise { - if (!this.wallet) throw new Error('Wallet RPC not started') - const height = (await this.wallet.getHeight()) as number - console.log(`[monero] wallet height: ${height}`) - return height +export async function getHeight(state: MoneroWalletState): Promise { + if (!state.wallet) throw new Error('Wallet RPC not started') + const height = await state.wallet.getHeight() as number + console.log(`[monero] wallet height: ${height}`) + return height +} + +export async function getFeeEstimate(state: MoneroWalletState): Promise<{ fee: string; fees: string[]; estimatedFee: string }> { + try { + const conn = new MoneroRpcConnection({ uri: `http://${state.daemonAddress}` }) + const res = await conn.sendJsonRequest('get_fee_estimate', { grace_blocks: 10 }) + if (res?.error) throw new Error(res.error.message) + console.log('[rpc][get_fee_estimate][response]', res) + const result = res?.result + const txSizeKb = 2.5 + const feePerKb = BigInt(result.fee ?? '0') + const fees = (result.fees ?? []).map((f: string) => BigInt(f).toString()) + const estimated = (feePerKb * BigInt(Math.round(txSizeKb * 10))) / 10n + return { fee: feePerKb.toString(), fees, estimatedFee: estimated.toString() } + } catch (e) { + console.log('[monero] getFeeEstimate error:', e) + return { fee: '0', fees: ['0', '0', '0', '0'], estimatedFee: '0' } } +} - async getFeeEstimate(): Promise<{ - fee: string - fees: string[] - estimatedFee: string - }> { - try { - const conn = new MoneroRpcConnection({ - uri: `http://${this.daemonAddress}`, - }) - const res = await conn.sendJsonRequest('get_fee_estimate', { - grace_blocks: 10, - }) - if (res?.error) throw new Error(res.error.message) - console.log('[rpc][get_fee_estimate][response]', res) - const result = res?.result - const txSizeKb = 2.5 - const feePerKb = BigInt(result.fee ?? '0') - const fees = (result.fees ?? []).map((f: string) => - BigInt(f).toString(), - ) - const estimated = - (feePerKb * BigInt(Math.round(txSizeKb * 10))) / 10n - return { - fee: feePerKb.toString(), - fees, - estimatedFee: estimated.toString(), - } - } catch (e) { - console.log('[monero] getFeeEstimate error:', e) - return { - fee: '0', - fees: ['0', '0', '0', '0'], - estimatedFee: '0', - } - } +export async function getDaemonHeight(state: MoneroWalletState): Promise { + if (!state.wallet) throw new Error('Wallet RPC not started') + try { + const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`) + const height = await daemon.getHeight() as number + console.log(`[monero] daemon height: ${height}`) + return height + } catch (e) { + console.log(`[monero] failed to get daemon height:`, e) + return 0 } +} - async getDaemonHeight(): Promise { - if (!this.wallet) throw new Error('Wallet RPC not started') - try { - const daemon = await connectToDaemonRpc( - `http://${this.daemonAddress}`, - ) - const height = (await daemon.getHeight()) as number - console.log(`[monero] daemon height: ${height}`) - return height - } catch (e) { - console.log(`[monero] failed to get daemon height:`, e) - return 0 - } - } +export async function transfer( + state: MoneroWalletState, + address: string, + amount: bigint, + priority: number = 0, + accountIndex: number = 0, +): Promise<{ txHash: string; fee: string; amount: string }> { + if (!state.wallet) throw new Error('Wallet RPC not started') + console.log(`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`) + const result = await rawRpc(state, '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 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(), - } - } +export function listWallets(): string[] { + const dir = getWalletDir() + if (!existsSync(dir)) return [] + return readdirSync(dir) + .filter((f) => f.endsWith('.keys')) + .map((f) => f.replace(/\.keys$/, '')) +} - async isWalletOpen(): Promise { - if (!this.wallet) return false - try { - await this.wallet.getHeight() - return true - } catch { - return false - } +export async function isWalletOpen(state: MoneroWalletState): Promise { + if (!state.wallet) return false + try { + await state.wallet.getHeight() + return true + } catch { + return false } +} - async isConnected(): Promise { - if (!this.wallet) return false - try { - const daemon = await connectToDaemonRpc( - `http://${this.daemonAddress}`, - ) - await daemon.getHeight() - return true - } catch (e) { - console.log(`[monero] connection check failed:`, e) - return false - } +export async function isConnected(state: MoneroWalletState): Promise { + if (!state.wallet) return false + try { + const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`) + await daemon.getHeight() + return true + } catch (e) { + console.log(`[monero] connection check failed:`, e) + return false } } diff --git a/apps/desktop/src/bun/rpc/monero/index.ts b/apps/desktop/src/bun/rpc/monero/index.ts index 0ab4aa0..5845965 100644 --- a/apps/desktop/src/bun/rpc/monero/index.ts +++ b/apps/desktop/src/bun/rpc/monero/index.ts @@ -1,20 +1,30 @@ import { isBinaryInstalled, downloadBinary, - MoneroWalletManager, + createMoneroWalletState, + start as walletStart, + stop as walletStop, + createWallet as walletCreate, + restoreWallet as walletRestore, + openWallet as walletOpen, + getBalance as walletGetBalance, + getTransactions as walletGetTransactions, + getAccounts as walletGetAccounts, + listWallets, + getTransferDetails as walletGetTransferDetails, + getHeight as walletGetHeight, + getDaemonHeight as walletGetDaemonHeight, + getFeeEstimate as walletGetFeeEstimate, + transfer as walletTransfer, + isWalletOpen, + isConnected, } from '../../lib/monero' +import type { MoneroWalletState } from '../../lib/monero' -export function createMoneroHandlers(state: { - manager: MoneroWalletManager | null - downloading: boolean -}) { +export function createMoneroHandlers(state: { manager: MoneroWalletState | null; downloading: boolean }) { return { moneroBinaryStatus: async () => { - const status = { - installed: isBinaryInstalled(), - downloading: state.downloading, - error: undefined, - } + const status = { installed: isBinaryInstalled(), downloading: state.downloading } console.log('[rpc] moneroBinaryStatus:', status) return status }, @@ -31,117 +41,62 @@ export function createMoneroHandlers(state: { state.downloading = false } }, - moneroStart: async ({ - daemonAddress, - }: { - daemonAddress?: string - }) => { + moneroStart: async ({ daemonAddress }: { daemonAddress?: string }) => { console.log('[rpc] moneroStart:', { daemonAddress }) if (!state.manager) { - state.manager = new MoneroWalletManager() + state.manager = createMoneroWalletState() } try { - await state.manager.start(daemonAddress) + await walletStart(state.manager, daemonAddress) } catch (e) { console.log('[rpc] moneroStart error:', e) return { running: false, walletOpen: false, connected: false } } - const connected = await state.manager.isConnected() + const connected = await isConnected(state.manager) console.log('[rpc] moneroStart complete, connected:', connected) return { running: true, walletOpen: false, connected } }, moneroStop: async () => { console.log('[rpc] moneroStop') if (state.manager) { - await state.manager.stop() + await walletStop(state.manager) state.manager = null } }, - moneroCreateWallet: async ({ - name, - password, - }: { - name: string - password: string - }) => { + moneroCreateWallet: async ({ name, password }: { name: string; password: string }) => { console.log('[rpc] moneroCreateWallet:', name) - if (!state.manager) - throw new Error('Monero wallet RPC not started') - const result = await state.manager.createWallet(name, password) + if (!state.manager) throw new Error('Monero wallet RPC not started') + const result = await walletCreate(state.manager, name, password) console.log('[rpc] moneroCreateWallet complete') return result }, - moneroRestoreWallet: async ({ - name, - password, - mnemonic, - restoreHeight, - }: { - name: string - password: string - mnemonic: string - restoreHeight?: number - }) => { + moneroRestoreWallet: async ({ name, password, mnemonic, restoreHeight }: { name: string; password: string; mnemonic: string; restoreHeight?: number }) => { console.log('[rpc] moneroRestoreWallet:', name) - if (!state.manager) - throw new Error('Monero wallet RPC not started') - await state.manager.restoreWallet( - name, - password, - mnemonic, - restoreHeight, - ) - const address = await state.manager.getAddress() - console.log( - '[rpc] moneroRestoreWallet complete, address:', - address, - ) + if (!state.manager) throw new Error('Monero wallet RPC not started') + await walletRestore(state.manager, name, password, mnemonic, restoreHeight) + const address = await walletGetAddress(state.manager) + console.log('[rpc] moneroRestoreWallet complete, address:', address) return { address } }, - moneroOpenWallet: async ({ - name, - password, - }: { - name: string - password: string - }) => { + moneroOpenWallet: async ({ name, password }: { name: string; password: string }) => { console.log('[rpc] moneroOpenWallet:', name) - if (!state.manager) - throw new Error('Monero wallet RPC not started') - await state.manager.openWallet(name, password) + if (!state.manager) throw new Error('Monero wallet RPC not started') + await walletOpen(state.manager, name, password) }, moneroGetBalance: async () => { console.log('[rpc] moneroGetBalance') - if (!state.manager) - throw new Error('Monero wallet RPC not started') - const { balance, unlocked } = await state.manager.getBalance() - const address = await state.manager.getAddress() - const height = await state.manager.getHeight() - const daemonHeight = await state.manager.getDaemonHeight() - console.log('[rpc] balance:', { - balance: balance.toString(), - unlocked: unlocked.toString(), - address, - height, - daemonHeight, - }) - return { - balance: balance.toString(), - unlocked: unlocked.toString(), - address, - height, - daemonHeight, - } + if (!state.manager) throw new Error('Monero wallet RPC not started') + const { balance, unlocked } = await walletGetBalance(state.manager) + const address = await walletGetAddress(state.manager) + const height = await walletGetHeight(state.manager) + const daemonHeight = await walletGetDaemonHeight(state.manager) + console.log('[rpc] balance:', { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight }) + return { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight } }, - moneroGetTransactions: async ({ - accountIndex, - }: { - accountIndex?: number - }) => { + moneroGetTransactions: async ({ accountIndex }: { accountIndex?: number }) => { console.log('[rpc] moneroGetTransactions', { accountIndex }) - if (!state.manager) - throw new Error('Monero wallet RPC not started') - const txs = await state.manager.getTransactions(accountIndex) + if (!state.manager) throw new Error('Monero wallet RPC not started') + const txs = await walletGetTransactions(state.manager, accountIndex) console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`) return txs }, @@ -150,85 +105,49 @@ export function createMoneroHandlers(state: { console.log('[rpc] moneroWalletStatus: not running') return { running: false, walletOpen: false, connected: false } } - const connected = await state.manager.isConnected() - const walletOpen = await state.manager.isWalletOpen() - console.log( - '[rpc] moneroWalletStatus: running, walletOpen:', - walletOpen, - 'connected:', - connected, - ) + const connected = await isConnected(state.manager) + const walletOpen = await isWalletOpen(state.manager) + console.log('[rpc] moneroWalletStatus: running, walletOpen:', walletOpen, 'connected:', connected) return { running: true, walletOpen, connected } }, moneroGetAccounts: async () => { console.log('[rpc] moneroGetAccounts') - if (!state.manager) - throw new Error('Monero wallet RPC not started') - const accounts = await state.manager.getAccounts() - console.log( - `[rpc] moneroGetAccounts: ${accounts.length} accounts`, - ) + if (!state.manager) throw new Error('Monero wallet RPC not started') + const accounts = await walletGetAccounts(state.manager) + console.log(`[rpc] moneroGetAccounts: ${accounts.length} accounts`) return accounts }, moneroListWallets: async () => { console.log('[rpc] moneroListWallets') - if (!state.manager) - throw new Error('Monero wallet RPC not started') - const wallets = state.manager.listWallets() - console.log( - `[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`, - ) + if (!state.manager) throw new Error('Monero wallet RPC not started') + const wallets = listWallets() + console.log(`[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`) return wallets }, - moneroTransfer: async ({ - address, - amount, - priority, - accountIndex, - }: { - address: string - amount: string - priority?: number - accountIndex?: number - }) => { - console.log('[rpc] moneroTransfer:', { - address, - amount, - priority, - }) - if (!state.manager) - throw new Error('Monero wallet RPC not started') - const result = await state.manager.transfer( - address, - BigInt(amount), - priority ?? 0, - accountIndex ?? 0, - ) + moneroTransfer: async ({ address, amount, priority, accountIndex }: { address: string; amount: string; priority?: number; accountIndex?: number }) => { + console.log('[rpc] moneroTransfer:', { address, amount, priority }) + if (!state.manager) throw new Error('Monero wallet RPC not started') + const result = await walletTransfer(state.manager, address, BigInt(amount), priority ?? 0, accountIndex ?? 0) console.log('[rpc] moneroTransfer complete:', result.txHash) return result }, moneroGetTransferDetails: async ({ txid }: { txid: string }) => { console.log('[rpc] moneroGetTransferDetails:', txid) - if (!state.manager) - throw new Error('Monero wallet RPC not started') - const details = await state.manager.getTransferDetails(txid) - console.log( - '[rpc] moneroGetTransferDetails complete:', - details?.hash, - ) + if (!state.manager) throw new Error('Monero wallet RPC not started') + const details = await walletGetTransferDetails(state.manager, txid) + console.log('[rpc] moneroGetTransferDetails complete:', details?.hash) return details }, moneroGetFeeEstimate: async () => { console.log('[rpc] moneroGetFeeEstimate') - if (!state.manager) - throw new Error('Monero wallet RPC not started') + if (!state.manager) throw new Error('Monero wallet RPC not started') try { - const fee = await state.manager.getFeeEstimate() + const fee = await walletGetFeeEstimate(state.manager) console.log('[monero][gas][fee]', fee) return fee } catch (error) { console.log('[monero][gas][error]', error) - return null + return } }, } diff --git a/apps/desktop/src/lib/states/monero-wallet.svelte.ts b/apps/desktop/src/lib/states/monero-wallet.svelte.ts index bbc6067..62a1bae 100644 --- a/apps/desktop/src/lib/states/monero-wallet.svelte.ts +++ b/apps/desktop/src/lib/states/monero-wallet.svelte.ts @@ -1,4 +1,9 @@ - import { electrobun, type MoneroTxEntry, type MoneroAccountEntry, type MoneroSendResult } from '$lib/electrobun' +import { + electrobun, + type MoneroTxEntry, + type MoneroAccountEntry, + type MoneroSendResult, +} from '$lib/electrobun' import { tryCatch } from '@koins/utils' export const moneroNetwork = { @@ -13,7 +18,10 @@ export const moneroNetwork = { export const atomicToXmr = (atomic: string): string => { const n = BigInt(atomic) const whole = n / 10_000_000_000_000n - const frac = (n % 10_000_000_000_000n).toString().padStart(12, '0').replace(/0+$/, '') + const frac = (n % 10_000_000_000_000n) + .toString() + .padStart(12, '0') + .replace(/0+$/, '') return frac ? `${whole}.${frac}` : `${whole}` } @@ -52,7 +60,9 @@ export const MoneroWallet = () => { const biometricAuth = async (): Promise => { if (!electrobun.rpc) return false const [authed] = await tryCatch( - electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }), + electrobun.rpc.request.biometricAuth({ + reason: 'Unlock wallet', + }), ) return authed === true } @@ -81,17 +91,29 @@ export const MoneroWallet = () => { accountType = null } - const moneroGetStoredPassword = async (name: string): Promise => { + const moneroGetStoredPassword = async ( + name: string, + ): Promise => { if (!electrobun.rpc) return null const [raw] = await tryCatch( - electrobun.rpc.request.getSecret({ service: 'koins', name: `monero_pw_${name}` }), + electrobun.rpc.request.getSecret({ + service: 'koins', + name: `monero_pw_${name}`, + }), ) return raw ?? null } - const moneroStorePassword = async (name: string, password: string) => { + const moneroStorePassword = async ( + name: string, + password: string, + ) => { if (!electrobun.rpc) return - await electrobun.rpc.request.setSecret({ service: 'koins', name: `monero_pw_${name}`, value: password }) + await electrobun.rpc.request.setSecret({ + service: 'koins', + name: `monero_pw_${name}`, + value: password, + }) } const fetchAccounts = async () => { @@ -112,11 +134,17 @@ export const MoneroWallet = () => { wallets = result ?? [] } - const openExistingWallet = async (name: string, password?: string) => { + const openExistingWallet = async ( + name: string, + password?: string, + ) => { if (!electrobun.rpc) throw new Error('RPC not available') - const pw = password ?? await moneroGetStoredPassword(name) + const pw = password ?? (await moneroGetStoredPassword(name)) if (!pw) throw new Error('Password required') - await electrobun.rpc.request.moneroOpenWallet({ name, password: pw }) + await electrobun.rpc.request.moneroOpenWallet({ + name, + password: pw, + }) walletName = name walletOpen = true await refresh() @@ -173,9 +201,16 @@ export const MoneroWallet = () => { wallets = [] } - const createWallet = async (name: string, password: string, storePw?: boolean) => { + const createWallet = async ( + name: string, + password: string, + storePw?: boolean, + ) => { if (!electrobun.rpc) throw new Error('RPC not available') - const result = await electrobun.rpc.request.moneroCreateWallet({ name, password }) + const result = await electrobun.rpc.request.moneroCreateWallet({ + name, + password, + }) walletName = name walletOpen = true address = result.address @@ -184,10 +219,19 @@ export const MoneroWallet = () => { return result } - const restoreWallet = async (name: string, password: string, mnemonic: string, restoreHeight?: number, storePw?: boolean) => { + const restoreWallet = async ( + name: string, + password: string, + mnemonic: string, + restoreHeight?: number, + storePw?: boolean, + ) => { if (!electrobun.rpc) throw new Error('RPC not available') const result = await electrobun.rpc.request.moneroRestoreWallet({ - name, password, mnemonic, restoreHeight, + name, + password, + mnemonic, + restoreHeight, }) walletName = name walletOpen = true @@ -250,34 +294,90 @@ export const MoneroWallet = () => { } return { - get accountType() { return accountType }, - get ready() { return ready }, - get biometricAvailable() { return biometricAvailable }, - get running() { return running }, - get walletOpen() { return walletOpen }, - get connected() { return connected }, - get balAtomic() { return balAtomic }, - get unlockedAtomic() { return unlockedAtomic }, - get address() { return address }, - get height() { return height }, - get daemonHeight() { return daemonHeight }, - get txs() { return txs }, - get installed() { return installed }, - get downloading() { return downloading }, - get walletName() { return walletName }, - get accounts() { return accounts }, - get wallets() { return wallets }, - get loading() { return loading }, - get error() { return error }, - get balance() { return atomicToXmr(balAtomic) }, - get unlocked() { return atomicToXmr(unlockedAtomic) }, - set error(v: string) { error = v }, - set installed(v: boolean) { installed = v }, - 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 }, + get accountType() { + return accountType + }, + get ready() { + return ready + }, + get biometricAvailable() { + return biometricAvailable + }, + get running() { + return running + }, + get walletOpen() { + return walletOpen + }, + get connected() { + return connected + }, + get balAtomic() { + return balAtomic + }, + get unlockedAtomic() { + return unlockedAtomic + }, + get address() { + return address + }, + get height() { + return height + }, + get daemonHeight() { + return daemonHeight + }, + get txs() { + return txs + }, + get installed() { + return installed + }, + get downloading() { + return downloading + }, + get walletName() { + return walletName + }, + get accounts() { + return accounts + }, + get wallets() { + return wallets + }, + get loading() { + return loading + }, + get error() { + return error + }, + get balance() { + return atomicToXmr(balAtomic) + }, + get unlocked() { + return atomicToXmr(unlockedAtomic) + }, + set error(v: string) { + error = v + }, + set installed(v: boolean) { + installed = v + }, + 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, diff --git a/apps/desktop/src/routes/index.svelte b/apps/desktop/src/routes/index.svelte index a0efa19..652fbbb 100644 --- a/apps/desktop/src/routes/index.svelte +++ b/apps/desktop/src/routes/index.svelte @@ -1,5 +1,6 @@ diff --git a/apps/desktop/src/routes/monero/settings.svelte b/apps/desktop/src/routes/monero/settings.svelte index 264c84f..8a00758 100644 --- a/apps/desktop/src/routes/monero/settings.svelte +++ b/apps/desktop/src/routes/monero/settings.svelte @@ -1,5 +1,16 @@