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'
+196 -312
View File
@@ -6,286 +6,210 @@ 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
.filter((f) => f.endsWith('.keys'))
.map((f) => f.replace(/\.keys$/, ''))
console.log(
`[monero] wallets on disk: ${wallets.join(', ') || 'none'}`,
)
return wallets
}
private get rpcUrl() { function rpcConnection(state: MoneroWalletState): MoneroRpcConnection {
return `http://127.0.0.1:${this.rpcPort}`
}
private get rpcConnection(): MoneroRpcConnection {
return new MoneroRpcConnection({ return new MoneroRpcConnection({
uri: this.rpcUrl, uri: rpcUrl(state),
username: this.rpcUser, username: state.rpcUser,
password: this.rpcPassword, password: state.rpcPassword,
}) })
} }
async start(daemonAddress: string = DEFAULT_DAEMON) { async function rawRpc(state: MoneroWalletState, method: string, params: Record<string, any> = {}): Promise<any> {
if (!existsSync(getBinaryPath())) const conn = rpcConnection(state)
throw new Error(
'monero-wallet-rpc not installed. Call downloadBinary() first.',
)
this.daemonAddress = daemonAddress
console.log(`[monero] starting wallet-rpc process...`)
console.log(`[monero] daemon address: ${this.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',
]
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}`)
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 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`)
}
private async rawRpc(
method: string,
params: Record<string, any> = {},
): Promise<any> {
const conn = this.rpcConnection
const res = await conn.sendJsonRequest(method, params) const res = await conn.sendJsonRequest(method, params)
if (res?.error) if (res?.error) throw new Error(res.error.message || JSON.stringify(res.error))
throw new Error(res.error.message || JSON.stringify(res.error))
return res?.result return res?.result
} }
private async waitForRpc(timeoutMs = 30000): Promise<void> { async function waitForRpc(state: MoneroWalletState, timeoutMs = 30000): Promise<void> {
const start = Date.now() const start = Date.now()
let lastErr = '' let lastErr = ''
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
try { try {
await Bun.connect({ await Bun.connect({
hostname: '127.0.0.1', hostname: '127.0.0.1',
port: this.rpcPort, port: state.rpcPort,
socket: { socket: {
open(s) { open(s) { s.end() },
s.end()
},
close() {}, close() {},
data() {}, data() {},
error() {}, error() {},
}, },
}) })
console.log( console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
`[monero] RPC ready after ${Date.now() - start}ms`,
)
return return
} catch (e) { } catch (e) {
lastErr = String(e) lastErr = String(e)
} }
await new Promise((r) => setTimeout(r, 500)) await new Promise((r) => setTimeout(r, 500))
} }
throw new Error( throw new Error(`monero-wallet-rpc failed to start within ${timeoutMs}ms: ${lastErr}`)
`monero-wallet-rpc failed to start within ${timeoutMs}ms: ${lastErr}`, }
)
}
async stop() { export async function start(state: MoneroWalletState, daemonAddress: string = DEFAULT_DAEMON) {
console.log(`[monero] stopping wallet manager...`) if (!existsSync(getBinaryPath()))
if (this.process) { throw new Error('monero-wallet-rpc not installed. Call downloadBinary() first.')
console.log( state.daemonAddress = daemonAddress
`[monero] killing wallet-rpc process (pid ${this.process.pid})`,
)
this.process.kill()
this.process = null
}
this.wallet = null
console.log(`[monero] wallet manager stopped`)
}
async createWallet(name: string, password: string) { console.log(`[monero] starting wallet-rpc process...`)
console.log(`[monero] creating wallet: ${name}`) console.log(`[monero] daemon address: ${state.daemonAddress}`)
const result = await this.rawRpc('create_wallet', { console.log(`[monero] wallet dir: ${getWalletDir()}`)
filename: name,
password, const cmd = [
language: 'English', 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 ?? '')
},
}) })
if (!this.wallet) throw new Error('Wallet RPC not started') console.log(`[monero] wallet-rpc pid: ${state.process.pid}`)
const address = await this.wallet.getAddress(0, 0)
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 = state.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 waitForRpc(state)
console.log(`[monero] RPC server is ready`)
console.log(`[monero] connecting MoneroWalletRpc client...`)
state.wallet = await connectToWalletRpc(rpcUrl(state), state.rpcUser, state.rpcPassword) as MoneroWalletRpc
console.log(`[monero] MoneroWalletRpc connected successfully`)
}
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`)
}
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}`) console.log(`[monero] wallet created: ${name} -> ${address}`)
return { mnemonic: result.mnemonic, 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] 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}`) 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,
subIdx,
)) as string
console.log(`[monero] address: ${address}`) console.log(`[monero] address: ${address}`)
return 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) { if (accounts?.length) {
for (const acct of accounts) { for (const acct of accounts) {
const subs = acct.getSubaddresses() ?? [] const subs = acct.getSubaddresses() ?? []
console.log( console.log(`[monero] account ${acct.getIndex()}: balance=${acct.getBalance()?.toString() ?? '0'} subs=${subs.length} primary=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`)
`[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) const has0 = subs.some((s: any) => s.getIndex() === 0)
if (!has0) { if (!has0) console.log(`[monero] sub 0 (primary): addr=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`)
console.log(
`[monero] sub 0 (primary): addr=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`,
)
}
for (const sub of subs) { for (const sub of subs) {
console.log( console.log(`[monero] sub ${sub.getIndex()}: addr=${(sub.getAddress() ?? '').substring(0, 16)}... label=${sub.getLabel() ?? ''} balance=${sub.getBalance()?.toString() ?? '0'} used=${sub.getIsUsed()}`)
`[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,
pool: false,
}
if (accountIndex !== undefined) {
params.account_index = accountIndex
}
const result = await this.rawRpc('get_transfers', params)
const all: any[] = [] const all: any[] = []
for (const dir of ['in', 'out'] as const) { for (const dir of ['in', 'out'] as const) {
const transfers = result[dir] ?? [] const transfers = result[dir] ?? []
@@ -294,45 +218,28 @@ export class MoneroWalletManager {
hash: t.txid, hash: t.txid,
amount: t.amount?.toString() ?? '0', amount: t.amount?.toString() ?? '0',
timestamp: String(t.timestamp ?? 0), timestamp: String(t.timestamp ?? 0),
direction: direction: dir === 'in' ? ('in' as const) : ('out' as const),
dir === 'in' ? ('in' as const) : ('out' as const),
height: t.height ?? 0, height: t.height ?? 0,
note: t.note, note: t.note,
}) })
} }
} }
all.sort( all.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp))
(a: any, b: any) => Number(b.timestamp) - Number(a.timestamp), console.log(`[monero] transactions: ${all.length} returned${accountIndex !== undefined ? ` for account ${accountIndex}` : ''}`)
)
console.log(
`[monero] transactions: ${all.length} returned${accountIndex !== undefined ? ` for account ${accountIndex}` : ''}`,
)
return all 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) => ({
address: d.address,
amount: d.amount?.toString() ?? '0',
})) ?? []
const subaddrIndices = isIn 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 }))
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 { return {
hash: t.txid, hash: t.txid,
direction: isIn ? ('in' as const) : ('out' as const), direction: isIn ? ('in' as const) : ('out' as const),
@@ -349,78 +256,56 @@ export class MoneroWalletManager {
destinations: dests, destinations: dests,
subaddrIndices, 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
} }
async getFeeEstimate(): Promise<{ export async function getFeeEstimate(state: MoneroWalletState): Promise<{ fee: string; fees: string[]; estimatedFee: string }> {
fee: string
fees: string[]
estimatedFee: string
}> {
try { try {
const conn = new MoneroRpcConnection({ const conn = new MoneroRpcConnection({ uri: `http://${state.daemonAddress}` })
uri: `http://${this.daemonAddress}`, const res = await conn.sendJsonRequest('get_fee_estimate', { grace_blocks: 10 })
})
const res = await conn.sendJsonRequest('get_fee_estimate', {
grace_blocks: 10,
})
if (res?.error) throw new Error(res.error.message) if (res?.error) throw new Error(res.error.message)
console.log('[rpc][get_fee_estimate][response]', res) console.log('[rpc][get_fee_estimate][response]', res)
const result = res?.result const result = res?.result
const txSizeKb = 2.5 const txSizeKb = 2.5
const feePerKb = BigInt(result.fee ?? '0') const feePerKb = BigInt(result.fee ?? '0')
const fees = (result.fees ?? []).map((f: string) => const fees = (result.fees ?? []).map((f: string) => BigInt(f).toString())
BigInt(f).toString(), const estimated = (feePerKb * BigInt(Math.round(txSizeKb * 10))) / 10n
) return { fee: feePerKb.toString(), fees, estimatedFee: estimated.toString() }
const estimated =
(feePerKb * BigInt(Math.round(txSizeKb * 10))) / 10n
return {
fee: feePerKb.toString(),
fees,
estimatedFee: estimated.toString(),
}
} catch (e) { } catch (e) {
console.log('[monero] getFeeEstimate error:', e) console.log('[monero] getFeeEstimate error:', e)
return { return { fee: '0', fees: ['0', '0', '0', '0'], estimatedFee: '0' }
fee: '0',
fees: ['0', '0', '0', '0'],
estimatedFee: '0',
}
}
} }
}
async getDaemonHeight(): Promise<number> { export async function getDaemonHeight(state: MoneroWalletState): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
try { try {
const daemon = await connectToDaemonRpc( const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`)
`http://${this.daemonAddress}`, const height = await daemon.getHeight() as number
)
const height = (await daemon.getHeight()) as number
console.log(`[monero] daemon height: ${height}`) console.log(`[monero] daemon height: ${height}`)
return height return height
} catch (e) { } catch (e) {
console.log(`[monero] failed to get daemon height:`, e) console.log(`[monero] failed to get daemon height:`, e)
return 0 return 0
} }
} }
async transfer( export async function transfer(
state: MoneroWalletState,
address: string, address: string,
amount: bigint, amount: bigint,
priority: number = 0, priority: number = 0,
accountIndex: number = 0, accountIndex: number = 0,
): Promise<{ txHash: string; fee: string; amount: string }> { ): Promise<{ txHash: string; fee: string; amount: string }> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
console.log( console.log(`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`)
`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`, const result = await rawRpc(state, 'transfer', {
)
const result = await this.rawRpc('transfer', {
destinations: [{ address, amount: amount.toString() }], destinations: [{ address, amount: amount.toString() }],
priority, priority,
account_index: accountIndex, account_index: accountIndex,
@@ -428,37 +313,36 @@ export class MoneroWalletManager {
get_tx_hex: false, get_tx_hex: false,
get_tx_metadata: false, get_tx_metadata: false,
}) })
console.log( console.log(`[monero] transfer result: tx_hash=${result.tx_hash} fee=${result.fee} amount=${result.amount}`)
`[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() }
) }
return {
txHash: result.tx_hash,
fee: result.fee?.toString() ?? '0',
amount: result.amount?.toString() ?? amount.toString(),
}
}
async isWalletOpen(): Promise<boolean> { export function listWallets(): string[] {
if (!this.wallet) return false 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<boolean> {
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() await daemon.getHeight()
return true return true
} catch (e) { } catch (e) {
console.log(`[monero] connection check failed:`, e) console.log(`[monero] connection check failed:`, e)
return false 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>
+46 -10
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"
bind:value={moneroSettingsPw} />
<Button
size="sm"
onclick={async () => {
await w.moneroStorePassword(
w.walletName,
moneroSettingsPw,
)
moneroSettingsPw = '' moneroSettingsPw = ''
w.error = '' w.error = ''
}} disabled={!moneroSettingsPw || w.loading}>Save</Button> }}
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>