import { MoneroWalletRpc, MoneroRpcConnection, connectToWalletRpc, connectToDaemonRpc } from 'monero-ts' import { getBinaryPath, getWalletDir } from './binary' import { existsSync, readdirSync } from 'fs' import { log, logError } from '../logger' const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081' export type MoneroWalletState = { wallet: MoneroWalletRpc | null process: Bun.Subprocess | null saveTimer: ReturnType | null rpcPort: number rpcUser: string rpcPassword: string daemonAddress: string } export function createMoneroWalletState(): MoneroWalletState { return { wallet: null, process: null, saveTimer: null, rpcPort: 28084, rpcUser: 'koins', rpcPassword: 'koins-local-rpc', daemonAddress: DEFAULT_DAEMON, } } 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, }) } function withTimeout( promise: Promise, ms: number, label: string, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error(`${label} timed out after ${ms}ms`)), ms, ) promise.then( (v) => { clearTimeout(timer) resolve(v) }, (e) => { clearTimeout(timer) reject(e) }, ) }) } async function rawRpc( state: MoneroWalletState, method: string, params: Record = {}, ): Promise { const conn = rpcConnection(state) const res = await withTimeout( conn.sendJsonRequest(method, params), 30000, `wallet-rpc ${method}`, ) if (res?.error) throw new Error(res.error.message || JSON.stringify(res.error)) return res?.result } async function waitForRpc( state: MoneroWalletState, timeoutMs = 30000, ): Promise { const start = Date.now() let lastErr = '' while (Date.now() - start < timeoutMs) { try { const conn = rpcConnection(state) const res = await withTimeout( conn.sendJsonRequest('get_version', {}), 5000, 'wallet-rpc probe', ) if (res?.result?.version) { console.log(`[monero] RPC ready after ${Date.now() - start}ms`) return } lastErr = JSON.stringify(res) } 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 function connectClient( state: MoneroWalletState, ): Promise { let lastErr = '' for (let attempt = 1; attempt <= 3; attempt++) { try { const client = (await connectToWalletRpc( rpcUrl(state), state.rpcUser, state.rpcPassword, )) as MoneroWalletRpc console.log(`[monero] MoneroWalletRpc connected (attempt ${attempt})`) return client } catch (e) { lastErr = String(e) console.log(`[monero] connectToWalletRpc attempt ${attempt} failed:`, e) if (attempt < 3) await new Promise((r) => setTimeout(r, 1000)) } } throw new Error(`Failed to connect to monero-wallet-rpc: ${lastErr}`) } 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 // Kill any existing monero-wallet-rpc on the port (stale process from // dev reloads or an unclean quit). Prefer SIGTERM so a live wallet gets // a chance to save its scan progress before exiting. try { const proc = Bun.spawnSync(['lsof', '-ti', `tcp:${state.rpcPort}`], { stdout: 'pipe', }) const pids = proc.stdout.toString().trim().split('\n').filter(Boolean) if (pids.length) { console.log( `[monero] stale process(es) on port ${state.rpcPort}: ${pids.join(', ')}`, ) for (const pid of pids) { try { process.kill(Number(pid), 'SIGTERM') } catch {} } const deadline = Date.now() + 5000 while (Date.now() < deadline) { const check = Bun.spawnSync( ['lsof', '-ti', `tcp:${state.rpcPort}`], { stdout: 'pipe' }, ) if (!check.stdout.toString().trim()) break await new Promise((r) => setTimeout(r, 250)) } const leftover = Bun.spawnSync( ['lsof', '-ti', `tcp:${state.rpcPort}`], { stdout: 'pipe' }, ) for (const pid of leftover.stdout.toString().trim().split('\n').filter(Boolean)) { try { process.kill(Number(pid), 'SIGKILL') console.log( `[monero] killed unresponsive process ${pid} on port ${state.rpcPort}`, ) } catch {} } } } catch {} 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', 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(' ')}`) 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 ?? '', ) log( `[monero] wallet-rpc process exited with code ${exitCode} ${error?.message ?? ''}`, ) if (state.saveTimer) { clearInterval(state.saveTimer) state.saveTimer = null } state.wallet = null state.process = null }, }) console.log(`[monero] wallet-rpc pid: ${state.process.pid}`) // Auto-save the wallet every 60s so scan progress survives an // unexpected kill (without `store`, the wallet file keeps its old // refresh height and every restart resyncs from block 0). state.saveTimer = setInterval(async () => { try { if (state.wallet) { await rawRpc(state, 'store') console.log(`[monero] wallet auto-saved`) } } catch (e) { console.log(`[monero] wallet auto-save failed:`, e) } }, 60000) const reader = (state.process!.stdout as ReadableStream).getReader() ;(async () => { while (true) { const { done, value } = await reader.read() if (done) break console.log( `[monero:stdout] ${new TextDecoder().decode(value)}`, ) } })() const stderr = (state.process!.stderr as ReadableStream).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 waitForRpc(state) console.log(`[monero] RPC server is ready`) console.log(`[monero] connecting MoneroWalletRpc client...`) state.wallet = await connectClient(state) console.log(`[monero] MoneroWalletRpc connected successfully`) log(`[monero] wallet-rpc started, RPC ready`) } export async function stop(state: MoneroWalletState) { console.log(`[monero] stopping wallet manager...`) if (state.saveTimer) { clearInterval(state.saveTimer) state.saveTimer = null } // Persist scan progress before shutting down. try { if (state.wallet) { await rawRpc(state, 'store') console.log(`[monero] wallet saved before stop`) log(`[monero] wallet saved before stop`) } } catch (e) { console.log(`[monero] wallet save before stop failed:`, e) } if (state.process) { const proc = state.process console.log(`[monero] sending SIGTERM to wallet-rpc (pid ${proc.pid})`) proc.kill() await Promise.race([proc.exited, sleep(3000)]) if (proc.exitCode === null) { console.log( `[monero] wallet-rpc did not exit gracefully, sending SIGKILL`, ) proc.kill('SIGKILL') } state.process = null } state.wallet = null console.log(`[monero] wallet manager stopped`) } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)) } 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 withTimeout( state.wallet.getAddress(0, 0), 30000, 'wallet get_address', )) as string console.log(`[monero] wallet created: ${name} -> ${address}`) return { mnemonic: result.mnemonic, address } } 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}`) } export async function openWallet( state: MoneroWalletState, name: string, password: string, ) { console.log(`[monero] opening wallet: ${name}`) // Self-heal: the client connection may have been lost while the // wallet-rpc process is still running (e.g. stale-process cleanup). if (!state.wallet) { if (!state.process) throw new Error('Wallet RPC not started') log(`[monero] reconnecting wallet client before open (${name})`) state.wallet = await connectClient(state) } try { await withTimeout(state.wallet.openWallet(name, password), 30000, `open_wallet ${name}`) log(`[monero] wallet opened: ${name}`) } catch (e) { logError(`[monero] wallet open failed (${name})`, e) throw e } } 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`) } export async function getBalance( state: MoneroWalletState, ): Promise<{ balance: bigint; unlocked: bigint }> { if (!state.wallet) throw new Error('Wallet RPC not started') const balance = (await withTimeout( state.wallet.getBalance(), 30000, 'wallet get_balance', )) as bigint const unlocked = (await withTimeout( state.wallet.getUnlockedBalance(), 30000, 'wallet get_unlocked_balance', )) as bigint console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`) return { balance, unlocked } } export async function getAddress( state: MoneroWalletState, accountIdx = 0, subIdx = 0, ): Promise { if (!state.wallet) throw new Error('Wallet RPC not started') const address = (await withTimeout( state.wallet.getAddress(accountIdx, subIdx), 30000, 'wallet get_address', )) as string console.log(`[monero] address: ${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( state: MoneroWalletState, ): Promise { if (!state.wallet) throw new Error('Wallet RPC not started') const accounts = (await withTimeout( state.wallet.getAccounts(true), 30000, 'wallet get_accounts', )) 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()) ?? [] } 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, }) } } 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 } export async function getTransferDetails( state: MoneroWalletState, txid: string, ): Promise { if (!state.wallet) throw new Error('Wallet RPC not started') let t: any = null let dests: any[] = [] try { console.log('[monero] trying get_transfer_by_txid for', txid) const result = await rawRpc(state, 'get_transfer_by_txid', { txid }) console.log('[monero] get_transfer_by_txid result:', result ? 'has result' : 'null') if (result?.transfer) { t = result.transfer dests = t.destinations?.map((d: any) => ({ address: d.address, amount: d.amount?.toString() ?? '0', })) ?? [] } } catch (e: any) { console.log('[monero] get_transfer_by_txid failed:', e?.message?.slice(0, 100)) } if (!t) { console.log('[monero] falling back to get_transfers scan') const params: Record = { in: true, out: true, pending: true, pool: true, } const result = await rawRpc(state, 'get_transfers', params) console.log('[monero] get_transfers result keys:', Object.keys(result ?? {})) let found: any = null for (const dir of ['in', 'out', 'pending', 'pool'] as const) { const transfers = result[dir] ?? [] console.log(`[monero] ${dir}: ${transfers.length} transfers`) for (const tr of transfers) { if (tr.txid === txid) { found = { ...tr, type: dir === 'in' ? 'in' : 'out' } break } } if (found) break } if (!found) { console.log('[monero] txid not found in get_transfers. Available txids:', ['in', 'out', 'pending', 'pool'].flatMap(d => (result[d] ?? []).map((t: any) => t.txid?.slice(0, 12)))) return null } console.log('[monero] found transfer in get_transfers') t = found } const isIn = t.type === 'in' 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 getHeight( state: MoneroWalletState, ): Promise { if (!state.wallet) throw new Error('Wallet RPC not started') const height = (await withTimeout( state.wallet.getHeight(), 30000, 'wallet get_height', )) 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' } } } 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 withTimeout( daemon.getHeight(), 15000, 'daemon get_height', )) 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 sweepAll( state: MoneroWalletState, address: string, 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] sweepAll: ${address} priority=${priority} account=${accountIndex}`, ) const result = await rawRpc(state, 'sweep_all', { address, priority, account_index: accountIndex, get_tx_keys: true, get_tx_hex: false, get_tx_metadata: false, }) const txHash = result.tx_hash_list?.[0] ?? '' const fee = result.fee_list?.[0]?.toString() ?? '0' const amount = result.amount_list?.[0]?.toString() ?? '0' console.log( `[monero] sweepAll result: tx_hash=${txHash} fee=${fee} amount=${amount}`, ) return { txHash, fee, amount } } 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(), } } export function listWallets(): string[] { const dir = getWalletDir() if (!existsSync(dir)) return [] return readdirSync(dir) .filter((f) => f.endsWith('.keys')) .map((f) => f.replace(/\.keys$/, '')) } export async function isWalletOpen( state: MoneroWalletState, ): Promise { if (!state.wallet) return false try { await withTimeout(state.wallet.getHeight(), 8000, 'wallet get_height') return true } catch (e) { // A timeout usually means the wallet-rpc is busy syncing, not that // the wallet is closed. Assume still-open so the UI doesn't flip to // "closed" and try to re-open an already-open wallet. if (e instanceof Error && e.message.includes('timed out')) return true return false } } export async function isConnected( state: MoneroWalletState, ): Promise { if (!state.wallet) return false try { const daemon = await withTimeout( connectToDaemonRpc(`http://${state.daemonAddress}`), 8000, 'daemon connect', ) await withTimeout(daemon.getHeight(), 8000, 'daemon get_height') return true } catch (e) { console.log(`[monero] connection check failed:`, e) return false } }