refactor MoneroWalletManager class to function module; fix settings page imports

This commit is contained in:
2026-05-26 03:08:58 +08:00
parent b5f4a80f5c
commit 6fe9e6306b
7 changed files with 575 additions and 613 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import { join } from 'path'
import { getENV } from './lib/get-env' import { getENV } from './lib/get-env'
import { createEvmHandlers } from './rpc/evm' import { createEvmHandlers } from './rpc/evm'
import { createMoneroHandlers } from './rpc/monero' import { createMoneroHandlers } from './rpc/monero'
import { MoneroWalletManager } from './lib/monero' import type { MoneroWalletState } from './lib/monero'
import { canPromptTouchID, promptTouchID } from './lib/biometric' import { canPromptTouchID, promptTouchID } from './lib/biometric'
import { sqlite } from './lib/sqlite' import { sqlite } from './lib/sqlite'
import { evmWallets } from './lib/db/schema' import { evmWallets } from './lib/db/schema'
+22 -1
View File
@@ -1,2 +1,23 @@
export * from './binary' 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'
+295 -411
View File
@@ -6,459 +6,343 @@ import {
} from 'monero-ts' } from 'monero-ts'
import { getBinaryPath, getWalletDir } from './binary' import { getBinaryPath, getWalletDir } from './binary'
import { existsSync, readdirSync } from 'fs' import { existsSync, readdirSync } from 'fs'
import { join } from 'path'
const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081' const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081'
export class MoneroWalletManager { export type MoneroWalletState = {
private wallet: MoneroWalletRpc | null = null wallet: MoneroWalletRpc | null
private process: Bun.Subprocess | null = null process: Bun.Subprocess | null
private rpcPort: number rpcPort: number
private rpcUser: string rpcUser: string
private rpcPassword: string rpcPassword: string
private daemonAddress: string = DEFAULT_DAEMON daemonAddress: string
}
constructor() { export function createMoneroWalletState(): MoneroWalletState {
this.rpcPort = 28084 return {
this.rpcUser = 'koins' wallet: null,
this.rpcPassword = crypto.randomUUID() process: null,
rpcPort: 28084,
rpcUser: 'koins',
rpcPassword: crypto.randomUUID(),
daemonAddress: DEFAULT_DAEMON,
} }
}
listWallets(): string[] { function rpcUrl(state: MoneroWalletState): string {
const dir = getWalletDir() return `http://127.0.0.1:${state.rpcPort}`
if (!existsSync(dir)) return [] }
const files = readdirSync(dir)
const wallets = files function rpcConnection(state: MoneroWalletState): MoneroRpcConnection {
.filter((f) => f.endsWith('.keys')) return new MoneroRpcConnection({
.map((f) => f.replace(/\.keys$/, '')) uri: rpcUrl(state),
console.log( username: state.rpcUser,
`[monero] wallets on disk: ${wallets.join(', ') || 'none'}`, password: state.rpcPassword,
) })
return wallets }
}
private get rpcUrl() { async function rawRpc(state: MoneroWalletState, method: string, params: Record<string, any> = {}): Promise<any> {
return `http://127.0.0.1:${this.rpcPort}` 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 { async function waitForRpc(state: MoneroWalletState, timeoutMs = 30000): Promise<void> {
return new MoneroRpcConnection({ const start = Date.now()
uri: this.rpcUrl, let lastErr = ''
username: this.rpcUser, while (Date.now() - start < timeoutMs) {
password: this.rpcPassword, 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) { export async function start(state: MoneroWalletState, daemonAddress: string = DEFAULT_DAEMON) {
if (!existsSync(getBinaryPath())) if (!existsSync(getBinaryPath()))
throw new Error( throw new Error('monero-wallet-rpc not installed. Call downloadBinary() first.')
'monero-wallet-rpc not installed. Call downloadBinary() first.', state.daemonAddress = daemonAddress
)
this.daemonAddress = daemonAddress
console.log(`[monero] starting wallet-rpc process...`) console.log(`[monero] starting wallet-rpc process...`)
console.log(`[monero] daemon address: ${this.daemonAddress}`) console.log(`[monero] daemon address: ${state.daemonAddress}`)
console.log(`[monero] wallet dir: ${getWalletDir()}`) console.log(`[monero] wallet dir: ${getWalletDir()}`)
const cmd = [ const cmd = [
getBinaryPath(), getBinaryPath(),
'--daemon-address', '--daemon-address', state.daemonAddress,
this.daemonAddress, '--rpc-bind-port', String(state.rpcPort),
'--rpc-bind-port', '--rpc-bind-ip', '127.0.0.1',
String(this.rpcPort), '--wallet-dir', getWalletDir(),
'--rpc-bind-ip', '--rpc-login', `${state.rpcUser}:${state.rpcPassword}`,
'127.0.0.1', '--trusted-daemon',
'--wallet-dir', ]
getWalletDir(),
'--rpc-login',
`${this.rpcUser}:${this.rpcPassword}`,
'--trusted-daemon',
]
console.log(`[monero] spawning: ${cmd.join(' ')}`) console.log(`[monero] spawning: ${cmd.join(' ')}`)
this.process = Bun.spawn(cmd, { state.process = Bun.spawn(cmd, {
env: { ...process.env, LANG: 'en_US.UTF-8' }, env: { ...process.env, LANG: 'en_US.UTF-8' },
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
onExit(_proc, exitCode, _signalCode, error) { onExit(_proc, exitCode, _signalCode, error) {
console.log( console.log(`[monero] wallet-rpc process exited with code ${exitCode}`, error?.message ?? '')
`[monero] wallet-rpc process exited with code ${exitCode}`, },
error?.message ?? '', })
) console.log(`[monero] wallet-rpc pid: ${state.process.pid}`)
},
})
console.log(`[monero] wallet-rpc pid: ${this.process.pid}`)
const stdout = this.process.stdout.getReader() const stdout = state.process.stdout.getReader()
;(async () => { ;(async () => {
while (true) { while (true) {
const { done, value } = await stdout.read() const { done, value } = await stdout.read()
if (done) break if (done) break
console.log( console.log(`[monero:stdout] ${new TextDecoder().decode(value)}`)
`[monero:stdout] ${new TextDecoder().decode(value)}`, }
) })()
}
})()
const stderr = this.process.stderr.getReader() const stderr = state.process.stderr.getReader()
;(async () => { ;(async () => {
while (true) { while (true) {
const { done, value } = await stderr.read() const { done, value } = await stderr.read()
if (done) break if (done) break
console.log( console.log(`[monero:stderr] ${new TextDecoder().decode(value)}`)
`[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`)
}
private async rawRpc( console.log(`[monero] waiting for RPC server to become available...`)
method: string, await waitForRpc(state)
params: Record<string, any> = {}, console.log(`[monero] RPC server is ready`)
): Promise<any> {
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
}
private async waitForRpc(timeoutMs = 30000): Promise<void> { console.log(`[monero] connecting MoneroWalletRpc client...`)
const start = Date.now() state.wallet = await connectToWalletRpc(rpcUrl(state), state.rpcUser, state.rpcPassword) as MoneroWalletRpc
let lastErr = '' console.log(`[monero] MoneroWalletRpc connected successfully`)
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}`,
)
}
async stop() { export async function stop(state: MoneroWalletState) {
console.log(`[monero] stopping wallet manager...`) console.log(`[monero] stopping wallet manager...`)
if (this.process) { if (state.process) {
console.log( console.log(`[monero] killing wallet-rpc process (pid ${state.process.pid})`)
`[monero] killing wallet-rpc process (pid ${this.process.pid})`, state.process.kill()
) state.process = null
this.process.kill()
this.process = null
}
this.wallet = null
console.log(`[monero] wallet manager stopped`)
} }
state.wallet = null
console.log(`[monero] wallet manager stopped`)
}
async createWallet(name: string, password: string) { export async function createWallet(state: MoneroWalletState, name: string, password: string) {
console.log(`[monero] creating wallet: ${name}`) console.log(`[monero] creating wallet: ${name}`)
const result = await this.rawRpc('create_wallet', { const result = await rawRpc(state, 'create_wallet', { filename: name, password, language: 'English' })
filename: name, if (!state.wallet) throw new Error('Wallet RPC not started')
password, const address = await state.wallet.getAddress(0, 0)
language: 'English', console.log(`[monero] wallet created: ${name} -> ${address}`)
}) return { mnemonic: result.mnemonic, address }
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 }
}
async restoreWallet( export async function restoreWallet(state: MoneroWalletState, name: string, password: string, mnemonic: string, restoreHeight?: number) {
name: string, console.log(`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`)
password: string, await rawRpc(state, 'restore_deterministic_wallet', {
mnemonic: string, filename: name, password, seed: mnemonic, restore_height: restoreHeight ?? 0, language: 'English',
restoreHeight?: number, })
) { console.log(`[monero] wallet restored: ${name}`)
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}`)
}
async openWallet(name: string, password: string) { export async function openWallet(state: MoneroWalletState, name: string, password: string) {
console.log(`[monero] opening wallet: ${name}`) console.log(`[monero] opening wallet: ${name}`)
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
await this.wallet.openWallet(name, password) await state.wallet.openWallet(name, password)
console.log(`[monero] wallet opened: ${name}`) console.log(`[monero] wallet opened: ${name}`)
} }
async closeWallet() { export async function closeWallet(state: MoneroWalletState) {
console.log(`[monero] closing current wallet`) console.log(`[monero] closing current wallet`)
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
await this.wallet.close() await state.wallet.close()
console.log(`[monero] wallet closed`) console.log(`[monero] wallet closed`)
} }
async getBalance(): Promise<{ balance: bigint; unlocked: bigint }> { export async function getBalance(state: MoneroWalletState): Promise<{ balance: bigint; unlocked: bigint }> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const bal = (await this.wallet.getBalance()) as bigint const balance = await state.wallet.getBalance() as bigint
const unlocked = const unlocked = await state.wallet.getUnlockedBalance() as bigint
(await this.wallet.getUnlockedBalance()) as bigint console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
console.log(`[monero] balance: ${bal} (unlocked: ${unlocked})`) return { balance, unlocked }
return { balance: bal, unlocked } }
}
async getAddress(accountIdx = 0, subIdx = 0): Promise<string> { export async function getAddress(state: MoneroWalletState, accountIdx = 0, subIdx = 0): Promise<string> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const address = (await this.wallet.getAddress( const address = await state.wallet.getAddress(accountIdx, subIdx) as string
accountIdx, console.log(`[monero] address: ${address}`)
subIdx, return address
)) as string }
console.log(`[monero] address: ${address}`)
return address
}
async getAccounts(): Promise<any[]> { export async function getAccounts(state: MoneroWalletState): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const accounts = (await this.wallet.getAccounts(true)) as any[] const accounts = await state.wallet.getAccounts(true) as any[]
console.log( console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`)
`[monero] accounts: ${accounts?.length ?? 0} returned`, if (accounts?.length) {
) for (const acct of accounts) {
if (accounts?.length) { const subs = acct.getSubaddresses() ?? []
for (const acct of accounts) { console.log(`[monero] account ${acct.getIndex()}: balance=${acct.getBalance()?.toString() ?? '0'} subs=${subs.length} primary=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`)
const subs = acct.getSubaddresses() ?? [] const has0 = subs.some((s: any) => s.getIndex() === 0)
console.log( if (!has0) console.log(`[monero] sub 0 (primary): addr=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`)
`[monero] account ${acct.getIndex()}: balance=${acct.getBalance()?.toString() ?? '0'} subs=${subs.length} primary=${(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()}`)
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<any[]> { export async function getTransactions(state: MoneroWalletState, accountIndex?: number): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const params: Record<string, any> = { const params: Record<string, any> = { in: true, out: true, pending: false, pool: false }
in: true, if (accountIndex !== undefined) params.account_index = accountIndex
out: true, const result = await rawRpc(state, 'get_transfers', params)
pending: false, const all: any[] = []
pool: false, 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<any> { export async function getTransferDetails(state: MoneroWalletState, txid: string): Promise<any> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const result = await this.rawRpc('get_transfer_by_txid', { txid }) const result = await rawRpc(state, 'get_transfer_by_txid', { txid })
if (!result?.transfer) return null if (!result?.transfer) return null
const t = result.transfer const t = result.transfer
const isIn = t.type === 'in' const isIn = t.type === 'in'
const isOut = t.type === 'out' const isOut = t.type === 'out'
const dests = const dests = t.destinations?.map((d: any) => ({ address: d.address, amount: d.amount?.toString() ?? '0' })) ?? []
t.destinations?.map((d: any) => ({ const subaddrIndices = isIn
address: d.address, ? [{ major: t.subaddr_index?.major ?? 0, minor: t.subaddr_index?.minor ?? 0 }]
amount: d.amount?.toString() ?? '0', : (t.subaddr_indices ?? []).map((s: any) => ({ major: s.major ?? 0, minor: s.minor ?? 0 }))
})) ?? [] return {
const subaddrIndices = isIn hash: t.txid,
? [ direction: isIn ? ('in' as const) : ('out' as const),
{ amount: t.amount?.toString() ?? '0',
major: t.subaddr_index?.major ?? 0, fee: t.fee?.toString() ?? '0',
minor: t.subaddr_index?.minor ?? 0, height: t.height ?? 0,
}, timestamp: String(t.timestamp ?? 0),
] confirmations: t.confirmations ?? 0,
: (t.subaddr_indices ?? []).map((s: any) => ({ unlockTime: t.unlock_time ?? 0,
major: s.major ?? 0, locked: t.locked ?? false,
minor: s.minor ?? 0, doubleSpend: t.double_spend_seen ?? false,
})) note: t.note,
return { paymentId: t.payment_id ?? '',
hash: t.txid, destinations: dests,
direction: isIn ? ('in' as const) : ('out' as const), subaddrIndices,
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<number> { export async function getHeight(state: MoneroWalletState): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const height = (await this.wallet.getHeight()) as number const height = await state.wallet.getHeight() as number
console.log(`[monero] wallet height: ${height}`) console.log(`[monero] wallet height: ${height}`)
return 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<{ export async function getDaemonHeight(state: MoneroWalletState): Promise<number> {
fee: string if (!state.wallet) throw new Error('Wallet RPC not started')
fees: string[] try {
estimatedFee: string const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`)
}> { const height = await daemon.getHeight() as number
try { console.log(`[monero] daemon height: ${height}`)
const conn = new MoneroRpcConnection({ return height
uri: `http://${this.daemonAddress}`, } catch (e) {
}) console.log(`[monero] failed to get daemon height:`, e)
const res = await conn.sendJsonRequest('get_fee_estimate', { return 0
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 getDaemonHeight(): Promise<number> { export async function transfer(
if (!this.wallet) throw new Error('Wallet RPC not started') state: MoneroWalletState,
try { address: string,
const daemon = await connectToDaemonRpc( amount: bigint,
`http://${this.daemonAddress}`, priority: number = 0,
) accountIndex: number = 0,
const height = (await daemon.getHeight()) as number ): Promise<{ txHash: string; fee: string; amount: string }> {
console.log(`[monero] daemon height: ${height}`) if (!state.wallet) throw new Error('Wallet RPC not started')
return height console.log(`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`)
} catch (e) { const result = await rawRpc(state, 'transfer', {
console.log(`[monero] failed to get daemon height:`, e) destinations: [{ address, amount: amount.toString() }],
return 0 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( export function listWallets(): string[] {
address: string, const dir = getWalletDir()
amount: bigint, if (!existsSync(dir)) return []
priority: number = 0, return readdirSync(dir)
accountIndex: number = 0, .filter((f) => f.endsWith('.keys'))
): Promise<{ txHash: string; fee: string; amount: string }> { .map((f) => f.replace(/\.keys$/, ''))
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(),
}
}
async isWalletOpen(): Promise<boolean> { export async function isWalletOpen(state: MoneroWalletState): Promise<boolean> {
if (!this.wallet) return false if (!state.wallet) return false
try { try {
await this.wallet.getHeight() await state.wallet.getHeight()
return true return true
} catch { } catch {
return false return false
}
} }
}
async isConnected(): Promise<boolean> { export async function isConnected(state: MoneroWalletState): Promise<boolean> {
if (!this.wallet) return false if (!state.wallet) return false
try { try {
const daemon = await connectToDaemonRpc( const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`)
`http://${this.daemonAddress}`, await daemon.getHeight()
) return true
await daemon.getHeight() } catch (e) {
return true console.log(`[monero] connection check failed:`, e)
} catch (e) { return false
console.log(`[monero] connection check failed:`, e)
return false
}
} }
} }
+65 -146
View File
@@ -1,20 +1,30 @@
import { import {
isBinaryInstalled, isBinaryInstalled,
downloadBinary, 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' } from '../../lib/monero'
import type { MoneroWalletState } from '../../lib/monero'
export function createMoneroHandlers(state: { export function createMoneroHandlers(state: { manager: MoneroWalletState | null; downloading: boolean }) {
manager: MoneroWalletManager | null
downloading: boolean
}) {
return { return {
moneroBinaryStatus: async () => { moneroBinaryStatus: async () => {
const status = { const status = { installed: isBinaryInstalled(), downloading: state.downloading }
installed: isBinaryInstalled(),
downloading: state.downloading,
error: undefined,
}
console.log('[rpc] moneroBinaryStatus:', status) console.log('[rpc] moneroBinaryStatus:', status)
return status return status
}, },
@@ -31,117 +41,62 @@ export function createMoneroHandlers(state: {
state.downloading = false state.downloading = false
} }
}, },
moneroStart: async ({ moneroStart: async ({ daemonAddress }: { daemonAddress?: string }) => {
daemonAddress,
}: {
daemonAddress?: string
}) => {
console.log('[rpc] moneroStart:', { daemonAddress }) console.log('[rpc] moneroStart:', { daemonAddress })
if (!state.manager) { if (!state.manager) {
state.manager = new MoneroWalletManager() state.manager = createMoneroWalletState()
} }
try { try {
await state.manager.start(daemonAddress) await walletStart(state.manager, daemonAddress)
} catch (e) { } catch (e) {
console.log('[rpc] moneroStart error:', e) console.log('[rpc] moneroStart error:', e)
return { running: false, walletOpen: false, connected: false } 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) console.log('[rpc] moneroStart complete, connected:', connected)
return { running: true, walletOpen: false, connected } return { running: true, walletOpen: false, connected }
}, },
moneroStop: async () => { moneroStop: async () => {
console.log('[rpc] moneroStop') console.log('[rpc] moneroStop')
if (state.manager) { if (state.manager) {
await state.manager.stop() await walletStop(state.manager)
state.manager = null state.manager = null
} }
}, },
moneroCreateWallet: async ({ moneroCreateWallet: async ({ name, password }: { name: string; password: string }) => {
name,
password,
}: {
name: string
password: string
}) => {
console.log('[rpc] moneroCreateWallet:', name) console.log('[rpc] moneroCreateWallet:', name)
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') const result = await walletCreate(state.manager, name, password)
const result = await state.manager.createWallet(name, password)
console.log('[rpc] moneroCreateWallet complete') console.log('[rpc] moneroCreateWallet complete')
return result return result
}, },
moneroRestoreWallet: async ({ moneroRestoreWallet: async ({ name, password, mnemonic, restoreHeight }: { name: string; password: string; mnemonic: string; restoreHeight?: number }) => {
name,
password,
mnemonic,
restoreHeight,
}: {
name: string
password: string
mnemonic: string
restoreHeight?: number
}) => {
console.log('[rpc] moneroRestoreWallet:', name) console.log('[rpc] moneroRestoreWallet:', name)
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') await walletRestore(state.manager, name, password, mnemonic, restoreHeight)
await state.manager.restoreWallet( const address = await walletGetAddress(state.manager)
name, console.log('[rpc] moneroRestoreWallet complete, address:', address)
password,
mnemonic,
restoreHeight,
)
const address = await state.manager.getAddress()
console.log(
'[rpc] moneroRestoreWallet complete, address:',
address,
)
return { address } return { address }
}, },
moneroOpenWallet: async ({ moneroOpenWallet: async ({ name, password }: { name: string; password: string }) => {
name,
password,
}: {
name: string
password: string
}) => {
console.log('[rpc] moneroOpenWallet:', name) console.log('[rpc] moneroOpenWallet:', name)
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') await walletOpen(state.manager, name, password)
await state.manager.openWallet(name, password)
}, },
moneroGetBalance: async () => { moneroGetBalance: async () => {
console.log('[rpc] moneroGetBalance') console.log('[rpc] moneroGetBalance')
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') const { balance, unlocked } = await walletGetBalance(state.manager)
const { balance, unlocked } = await state.manager.getBalance() const address = await walletGetAddress(state.manager)
const address = await state.manager.getAddress() const height = await walletGetHeight(state.manager)
const height = await state.manager.getHeight() const daemonHeight = await walletGetDaemonHeight(state.manager)
const daemonHeight = await state.manager.getDaemonHeight() console.log('[rpc] balance:', { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight })
console.log('[rpc] balance:', { return { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight }
balance: balance.toString(),
unlocked: unlocked.toString(),
address,
height,
daemonHeight,
})
return {
balance: balance.toString(),
unlocked: unlocked.toString(),
address,
height,
daemonHeight,
}
}, },
moneroGetTransactions: async ({ moneroGetTransactions: async ({ accountIndex }: { accountIndex?: number }) => {
accountIndex,
}: {
accountIndex?: number
}) => {
console.log('[rpc] moneroGetTransactions', { accountIndex }) console.log('[rpc] moneroGetTransactions', { accountIndex })
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') const txs = await walletGetTransactions(state.manager, accountIndex)
const txs = await state.manager.getTransactions(accountIndex)
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`) console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
return txs return txs
}, },
@@ -150,85 +105,49 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroWalletStatus: not running') console.log('[rpc] moneroWalletStatus: not running')
return { running: false, walletOpen: false, connected: false } return { running: false, walletOpen: false, connected: false }
} }
const connected = await state.manager.isConnected() const connected = await isConnected(state.manager)
const walletOpen = await state.manager.isWalletOpen() const walletOpen = await isWalletOpen(state.manager)
console.log( console.log('[rpc] moneroWalletStatus: running, walletOpen:', walletOpen, 'connected:', connected)
'[rpc] moneroWalletStatus: running, walletOpen:',
walletOpen,
'connected:',
connected,
)
return { running: true, walletOpen, connected } return { running: true, walletOpen, connected }
}, },
moneroGetAccounts: async () => { moneroGetAccounts: async () => {
console.log('[rpc] moneroGetAccounts') console.log('[rpc] moneroGetAccounts')
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') const accounts = await walletGetAccounts(state.manager)
const accounts = await state.manager.getAccounts() console.log(`[rpc] moneroGetAccounts: ${accounts.length} accounts`)
console.log(
`[rpc] moneroGetAccounts: ${accounts.length} accounts`,
)
return accounts return accounts
}, },
moneroListWallets: async () => { moneroListWallets: async () => {
console.log('[rpc] moneroListWallets') console.log('[rpc] moneroListWallets')
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') const wallets = listWallets()
const wallets = state.manager.listWallets() console.log(`[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`)
console.log(
`[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`,
)
return wallets return wallets
}, },
moneroTransfer: async ({ moneroTransfer: async ({ address, amount, priority, accountIndex }: { address: string; amount: string; priority?: number; accountIndex?: number }) => {
address, console.log('[rpc] moneroTransfer:', { address, amount, priority })
amount, if (!state.manager) throw new Error('Monero wallet RPC not started')
priority, const result = await walletTransfer(state.manager, address, BigInt(amount), priority ?? 0, accountIndex ?? 0)
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,
)
console.log('[rpc] moneroTransfer complete:', result.txHash) console.log('[rpc] moneroTransfer complete:', result.txHash)
return 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) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started') const details = await walletGetTransferDetails(state.manager, txid)
const details = await state.manager.getTransferDetails(txid) console.log('[rpc] moneroGetTransferDetails complete:', details?.hash)
console.log(
'[rpc] moneroGetTransferDetails complete:',
details?.hash,
)
return details return details
}, },
moneroGetFeeEstimate: async () => { moneroGetFeeEstimate: async () => {
console.log('[rpc] moneroGetFeeEstimate') console.log('[rpc] moneroGetFeeEstimate')
if (!state.manager) if (!state.manager) throw new Error('Monero wallet RPC not started')
throw new Error('Monero wallet RPC not started')
try { try {
const fee = await state.manager.getFeeEstimate() const fee = await walletGetFeeEstimate(state.manager)
console.log('[monero][gas][fee]', fee) console.log('[monero][gas][fee]', fee)
return fee return fee
} catch (error) { } catch (error) {
console.log('[monero][gas][error]', error) console.log('[monero][gas][error]', error)
return null return
} }
}, },
} }
@@ -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' import { tryCatch } from '@koins/utils'
export const moneroNetwork = { export const moneroNetwork = {
@@ -13,7 +18,10 @@ export const moneroNetwork = {
export const atomicToXmr = (atomic: string): string => { export const atomicToXmr = (atomic: string): string => {
const n = BigInt(atomic) const n = BigInt(atomic)
const whole = n / 10_000_000_000_000n 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}` return frac ? `${whole}.${frac}` : `${whole}`
} }
@@ -52,7 +60,9 @@ export const MoneroWallet = () => {
const biometricAuth = async (): Promise<boolean> => { const biometricAuth = async (): Promise<boolean> => {
if (!electrobun.rpc) return false if (!electrobun.rpc) return false
const [authed] = await tryCatch( const [authed] = await tryCatch(
electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }), electrobun.rpc.request.biometricAuth({
reason: 'Unlock wallet',
}),
) )
return authed === true return authed === true
} }
@@ -81,17 +91,29 @@ export const MoneroWallet = () => {
accountType = null accountType = null
} }
const moneroGetStoredPassword = async (name: string): Promise<string | null> => { const moneroGetStoredPassword = async (
name: string,
): Promise<string | null> => {
if (!electrobun.rpc) return null if (!electrobun.rpc) return null
const [raw] = await tryCatch( 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 return raw ?? null
} }
const moneroStorePassword = async (name: string, password: string) => { const moneroStorePassword = async (
name: string,
password: string,
) => {
if (!electrobun.rpc) return 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 () => { const fetchAccounts = async () => {
@@ -112,11 +134,17 @@ export const MoneroWallet = () => {
wallets = result ?? [] 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') 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') 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 walletName = name
walletOpen = true walletOpen = true
await refresh() await refresh()
@@ -173,9 +201,16 @@ export const MoneroWallet = () => {
wallets = [] 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') 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 walletName = name
walletOpen = true walletOpen = true
address = result.address address = result.address
@@ -184,10 +219,19 @@ export const MoneroWallet = () => {
return result 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') if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroRestoreWallet({ const result = await electrobun.rpc.request.moneroRestoreWallet({
name, password, mnemonic, restoreHeight, name,
password,
mnemonic,
restoreHeight,
}) })
walletName = name walletName = name
walletOpen = true walletOpen = true
@@ -250,34 +294,90 @@ export const MoneroWallet = () => {
} }
return { return {
get accountType() { return accountType }, get accountType() {
get ready() { return ready }, return accountType
get biometricAvailable() { return biometricAvailable }, },
get running() { return running }, get ready() {
get walletOpen() { return walletOpen }, return ready
get connected() { return connected }, },
get balAtomic() { return balAtomic }, get biometricAvailable() {
get unlockedAtomic() { return unlockedAtomic }, return biometricAvailable
get address() { return address }, },
get height() { return height }, get running() {
get daemonHeight() { return daemonHeight }, return running
get txs() { return txs }, },
get installed() { return installed }, get walletOpen() {
get downloading() { return downloading }, return walletOpen
get walletName() { return walletName }, },
get accounts() { return accounts }, get connected() {
get wallets() { return wallets }, return connected
get loading() { return loading }, },
get error() { return error }, get balAtomic() {
get balance() { return atomicToXmr(balAtomic) }, return balAtomic
get unlocked() { return atomicToXmr(unlockedAtomic) }, },
set error(v: string) { error = v }, get unlockedAtomic() {
set installed(v: boolean) { installed = v }, return unlockedAtomic
set downloading(v: boolean) { downloading = v }, },
set running(v: boolean) { running = v }, get address() {
set connected(v: boolean) { connected = v }, return address
get selectedAccountIndex() { return selectedAccountIndex }, },
set selectedAccountIndex(v: number) { selectedAccountIndex = v }, 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, init,
login, login,
logout, logout,
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js' import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js' import { Button } from '$lib/components/ui/button/index.js'
import { import {
Card, Card,
@@ -17,6 +18,7 @@
if (!initStarted) { if (!initStarted) {
initStarted = true initStarted = true
w.init() w.init()
moneroWallet.init()
} }
}) })
</script> </script>
+48 -12
View File
@@ -1,5 +1,16 @@
<script lang="ts"> <script lang="ts">
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js' import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '$lib/components/ui/card/index.js'
import Loader from '$lib/components/loader.svelte'
import { navigate } from 'sv-router/generated'
const w = moneroWallet const w = moneroWallet
@@ -15,7 +26,9 @@
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Monero Settings</CardTitle> <CardTitle>Monero Settings</CardTitle>
<CardDescription>Server and wallet preferences</CardDescription> <CardDescription>
Server and wallet preferences
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@@ -24,7 +37,10 @@
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onclick={async () => { await w.stop(); await w.start() }} onclick={async () => {
await w.stop()
await w.start()
}}
disabled={w.loading}> disabled={w.loading}>
{#if w.loading} {#if w.loading}
<Loader /> <Loader />
@@ -34,23 +50,43 @@
</div> </div>
{#if w.biometricAvailable} {#if w.biometricAvailable}
<div> <div>
<p class="text-xs font-medium mb-2">Save wallet password with Touch ID</p> <p class="text-xs font-medium mb-2">
<p class="text-xs text-muted-foreground mb-3">Enter your wallet password to enable biometric unlock</p> Save wallet password with Touch ID
</p>
<p class="text-xs text-muted-foreground mb-3">
Enter your wallet password to enable biometric unlock
</p>
<div class="flex gap-2 items-end"> <div class="flex gap-2 items-end">
<Input type="password" placeholder="Wallet password" bind:value={moneroSettingsPw} /> <Input
<Button size="sm" onclick={async () => { type="password"
await w.moneroStorePassword(w.walletName, moneroSettingsPw) placeholder="Wallet password"
moneroSettingsPw = '' bind:value={moneroSettingsPw} />
w.error = '' <Button
}} disabled={!moneroSettingsPw || w.loading}>Save</Button> size="sm"
onclick={async () => {
await w.moneroStorePassword(
w.walletName,
moneroSettingsPw,
)
moneroSettingsPw = ''
w.error = ''
}}
disabled={!moneroSettingsPw || w.loading}>
Save
</Button>
</div> </div>
</div> </div>
{:else} {:else}
<p class="text-xs text-muted-foreground">Biometric authentication not available on this device</p> <p class="text-xs text-muted-foreground">
Biometric authentication not available on this device
</p>
{/if} {/if}
<div class="pt-2 border-t border-border"> <div class="pt-2 border-t border-border">
<Button variant="outline" size="sm" onclick={() => navigate('/monero')}> <Button
variant="outline"
size="sm"
onclick={() => navigate('/monero')}>
Back Back
</Button> </Button>
</div> </div>