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 { createEvmHandlers } from './rpc/evm'
import { createMoneroHandlers } from './rpc/monero'
import { MoneroWalletManager } from './lib/monero'
import type { MoneroWalletState } from './lib/monero'
import { canPromptTouchID, promptTouchID } from './lib/biometric'
import { sqlite } from './lib/sqlite'
import { evmWallets } from './lib/db/schema'
+22 -1
View File
@@ -1,2 +1,23 @@
export * from './binary'
export { MoneroWalletManager } from './wallet'
export {
createMoneroWalletState,
start,
stop,
createWallet,
restoreWallet,
openWallet,
closeWallet,
getBalance,
getAddress,
getAccounts,
getTransactions,
getTransferDetails,
getHeight,
getFeeEstimate,
getDaemonHeight,
transfer,
isWalletOpen,
isConnected,
listWallets,
} from './wallet'
export type { MoneroWalletState } from './wallet'
+196 -312
View File
@@ -6,286 +6,210 @@ import {
} from 'monero-ts'
import { getBinaryPath, getWalletDir } from './binary'
import { existsSync, readdirSync } from 'fs'
import { join } from 'path'
const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081'
export class MoneroWalletManager {
private wallet: MoneroWalletRpc | null = null
private process: Bun.Subprocess | null = null
private rpcPort: number
private rpcUser: string
private rpcPassword: string
private daemonAddress: string = DEFAULT_DAEMON
export type MoneroWalletState = {
wallet: MoneroWalletRpc | null
process: Bun.Subprocess | null
rpcPort: number
rpcUser: string
rpcPassword: string
daemonAddress: string
}
constructor() {
this.rpcPort = 28084
this.rpcUser = 'koins'
this.rpcPassword = crypto.randomUUID()
export function createMoneroWalletState(): MoneroWalletState {
return {
wallet: null,
process: null,
rpcPort: 28084,
rpcUser: 'koins',
rpcPassword: crypto.randomUUID(),
daemonAddress: DEFAULT_DAEMON,
}
}
listWallets(): string[] {
const dir = getWalletDir()
if (!existsSync(dir)) return []
const files = readdirSync(dir)
const wallets = files
.filter((f) => f.endsWith('.keys'))
.map((f) => f.replace(/\.keys$/, ''))
console.log(
`[monero] wallets on disk: ${wallets.join(', ') || 'none'}`,
)
return wallets
}
function rpcUrl(state: MoneroWalletState): string {
return `http://127.0.0.1:${state.rpcPort}`
}
private get rpcUrl() {
return `http://127.0.0.1:${this.rpcPort}`
}
private get rpcConnection(): MoneroRpcConnection {
function rpcConnection(state: MoneroWalletState): MoneroRpcConnection {
return new MoneroRpcConnection({
uri: this.rpcUrl,
username: this.rpcUser,
password: this.rpcPassword,
uri: rpcUrl(state),
username: state.rpcUser,
password: state.rpcPassword,
})
}
}
async start(daemonAddress: string = DEFAULT_DAEMON) {
if (!existsSync(getBinaryPath()))
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
async function rawRpc(state: MoneroWalletState, method: string, params: Record<string, any> = {}): Promise<any> {
const conn = rpcConnection(state)
const res = await conn.sendJsonRequest(method, params)
if (res?.error)
throw new Error(res.error.message || JSON.stringify(res.error))
if (res?.error) throw new Error(res.error.message || JSON.stringify(res.error))
return res?.result
}
}
private async waitForRpc(timeoutMs = 30000): Promise<void> {
async function waitForRpc(state: MoneroWalletState, timeoutMs = 30000): Promise<void> {
const start = Date.now()
let lastErr = ''
while (Date.now() - start < timeoutMs) {
try {
await Bun.connect({
hostname: '127.0.0.1',
port: this.rpcPort,
port: state.rpcPort,
socket: {
open(s) {
s.end()
},
open(s) { s.end() },
close() {},
data() {},
error() {},
},
})
console.log(
`[monero] RPC ready after ${Date.now() - start}ms`,
)
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}`,
)
}
throw new Error(`monero-wallet-rpc failed to start within ${timeoutMs}ms: ${lastErr}`)
}
async stop() {
console.log(`[monero] stopping wallet manager...`)
if (this.process) {
console.log(
`[monero] killing wallet-rpc process (pid ${this.process.pid})`,
)
this.process.kill()
this.process = null
}
this.wallet = null
console.log(`[monero] wallet manager stopped`)
}
export async function start(state: MoneroWalletState, daemonAddress: string = DEFAULT_DAEMON) {
if (!existsSync(getBinaryPath()))
throw new Error('monero-wallet-rpc not installed. Call downloadBinary() first.')
state.daemonAddress = daemonAddress
async createWallet(name: string, password: string) {
console.log(`[monero] creating wallet: ${name}`)
const result = await this.rawRpc('create_wallet', {
filename: name,
password,
language: 'English',
console.log(`[monero] starting wallet-rpc process...`)
console.log(`[monero] daemon address: ${state.daemonAddress}`)
console.log(`[monero] wallet dir: ${getWalletDir()}`)
const cmd = [
getBinaryPath(),
'--daemon-address', state.daemonAddress,
'--rpc-bind-port', String(state.rpcPort),
'--rpc-bind-ip', '127.0.0.1',
'--wallet-dir', getWalletDir(),
'--rpc-login', `${state.rpcUser}:${state.rpcPassword}`,
'--trusted-daemon',
]
console.log(`[monero] spawning: ${cmd.join(' ')}`)
state.process = Bun.spawn(cmd, {
env: { ...process.env, LANG: 'en_US.UTF-8' },
stdout: 'pipe',
stderr: 'pipe',
onExit(_proc, exitCode, _signalCode, error) {
console.log(`[monero] wallet-rpc process exited with code ${exitCode}`, error?.message ?? '')
},
})
if (!this.wallet) throw new Error('Wallet RPC not started')
const address = await this.wallet.getAddress(0, 0)
console.log(`[monero] wallet-rpc pid: ${state.process.pid}`)
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}`)
return { mnemonic: result.mnemonic, address }
}
}
async restoreWallet(
name: string,
password: string,
mnemonic: string,
restoreHeight?: number,
) {
console.log(
`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`,
)
await this.rawRpc('restore_deterministic_wallet', {
filename: name,
password,
seed: mnemonic,
restore_height: restoreHeight ?? 0,
language: 'English',
export async function restoreWallet(state: MoneroWalletState, name: string, password: string, mnemonic: string, restoreHeight?: number) {
console.log(`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`)
await rawRpc(state, 'restore_deterministic_wallet', {
filename: name, password, seed: mnemonic, restore_height: restoreHeight ?? 0, language: 'English',
})
console.log(`[monero] wallet restored: ${name}`)
}
}
async openWallet(name: string, password: string) {
export async function openWallet(state: MoneroWalletState, name: string, password: string) {
console.log(`[monero] opening wallet: ${name}`)
if (!this.wallet) throw new Error('Wallet RPC not started')
await this.wallet.openWallet(name, password)
if (!state.wallet) throw new Error('Wallet RPC not started')
await state.wallet.openWallet(name, password)
console.log(`[monero] wallet opened: ${name}`)
}
}
async closeWallet() {
export async function closeWallet(state: MoneroWalletState) {
console.log(`[monero] closing current wallet`)
if (!this.wallet) throw new Error('Wallet RPC not started')
await this.wallet.close()
if (!state.wallet) throw new Error('Wallet RPC not started')
await state.wallet.close()
console.log(`[monero] wallet closed`)
}
}
async getBalance(): Promise<{ balance: bigint; unlocked: bigint }> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const bal = (await this.wallet.getBalance()) as bigint
const unlocked =
(await this.wallet.getUnlockedBalance()) as bigint
console.log(`[monero] balance: ${bal} (unlocked: ${unlocked})`)
return { balance: bal, unlocked }
}
export async function getBalance(state: MoneroWalletState): Promise<{ balance: bigint; unlocked: bigint }> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const balance = await state.wallet.getBalance() as bigint
const unlocked = await state.wallet.getUnlockedBalance() as bigint
console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
return { balance, unlocked }
}
async getAddress(accountIdx = 0, subIdx = 0): Promise<string> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const address = (await this.wallet.getAddress(
accountIdx,
subIdx,
)) as string
export async function getAddress(state: MoneroWalletState, accountIdx = 0, subIdx = 0): Promise<string> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const address = await state.wallet.getAddress(accountIdx, subIdx) as string
console.log(`[monero] address: ${address}`)
return address
}
}
async getAccounts(): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const accounts = (await this.wallet.getAccounts(true)) as any[]
console.log(
`[monero] accounts: ${accounts?.length ?? 0} returned`,
)
export async function getAccounts(state: MoneroWalletState): Promise<any[]> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const accounts = await state.wallet.getAccounts(true) as any[]
console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`)
if (accounts?.length) {
for (const acct of accounts) {
const subs = acct.getSubaddresses() ?? []
console.log(
`[monero] account ${acct.getIndex()}: balance=${acct.getBalance()?.toString() ?? '0'} subs=${subs.length} primary=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`,
)
console.log(`[monero] account ${acct.getIndex()}: balance=${acct.getBalance()?.toString() ?? '0'} subs=${subs.length} primary=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`)
const has0 = subs.some((s: any) => s.getIndex() === 0)
if (!has0) {
console.log(
`[monero] sub 0 (primary): addr=${(acct.getPrimaryAddress() ?? '').substring(0, 16)}...`,
)
}
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()}`,
)
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()) ?? []
}
}
async getTransactions(accountIndex?: number): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const params: Record<string, any> = {
in: true,
out: true,
pending: false,
pool: false,
}
if (accountIndex !== undefined) {
params.account_index = accountIndex
}
const result = await this.rawRpc('get_transfers', params)
export async function getTransactions(state: MoneroWalletState, accountIndex?: number): Promise<any[]> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const params: Record<string, any> = { in: true, out: true, pending: false, pool: false }
if (accountIndex !== undefined) params.account_index = accountIndex
const result = await rawRpc(state, 'get_transfers', params)
const all: any[] = []
for (const dir of ['in', 'out'] as const) {
const transfers = result[dir] ?? []
@@ -294,45 +218,28 @@ export class MoneroWalletManager {
hash: t.txid,
amount: t.amount?.toString() ?? '0',
timestamp: String(t.timestamp ?? 0),
direction:
dir === 'in' ? ('in' as const) : ('out' as const),
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}` : ''}`,
)
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> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const result = await this.rawRpc('get_transfer_by_txid', { txid })
export async function getTransferDetails(state: MoneroWalletState, txid: string): Promise<any> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const result = await rawRpc(state, 'get_transfer_by_txid', { txid })
if (!result?.transfer) return null
const t = result.transfer
const isIn = t.type === 'in'
const isOut = t.type === 'out'
const dests =
t.destinations?.map((d: any) => ({
address: d.address,
amount: d.amount?.toString() ?? '0',
})) ?? []
const dests = t.destinations?.map((d: any) => ({ address: d.address, amount: d.amount?.toString() ?? '0' })) ?? []
const subaddrIndices = isIn
? [
{
major: t.subaddr_index?.major ?? 0,
minor: t.subaddr_index?.minor ?? 0,
},
]
: (t.subaddr_indices ?? []).map((s: any) => ({
major: s.major ?? 0,
minor: s.minor ?? 0,
}))
? [{ major: t.subaddr_index?.major ?? 0, minor: t.subaddr_index?.minor ?? 0 }]
: (t.subaddr_indices ?? []).map((s: any) => ({ major: s.major ?? 0, minor: s.minor ?? 0 }))
return {
hash: t.txid,
direction: isIn ? ('in' as const) : ('out' as const),
@@ -349,78 +256,56 @@ export class MoneroWalletManager {
destinations: dests,
subaddrIndices,
}
}
}
async getHeight(): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const height = (await this.wallet.getHeight()) as number
export async function getHeight(state: MoneroWalletState): Promise<number> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const height = await state.wallet.getHeight() as number
console.log(`[monero] wallet height: ${height}`)
return height
}
}
async getFeeEstimate(): Promise<{
fee: string
fees: string[]
estimatedFee: string
}> {
export async function getFeeEstimate(state: MoneroWalletState): Promise<{ fee: string; fees: string[]; estimatedFee: string }> {
try {
const conn = new MoneroRpcConnection({
uri: `http://${this.daemonAddress}`,
})
const res = await conn.sendJsonRequest('get_fee_estimate', {
grace_blocks: 10,
})
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(),
}
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',
}
}
return { fee: '0', fees: ['0', '0', '0', '0'], estimatedFee: '0' }
}
}
async getDaemonHeight(): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started')
export async function getDaemonHeight(state: MoneroWalletState): Promise<number> {
if (!state.wallet) throw new Error('Wallet RPC not started')
try {
const daemon = await connectToDaemonRpc(
`http://${this.daemonAddress}`,
)
const height = (await daemon.getHeight()) as number
const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`)
const height = await daemon.getHeight() as number
console.log(`[monero] daemon height: ${height}`)
return height
} catch (e) {
console.log(`[monero] failed to get daemon height:`, e)
return 0
}
}
}
async transfer(
export async function transfer(
state: MoneroWalletState,
address: string,
amount: bigint,
priority: number = 0,
accountIndex: number = 0,
): Promise<{ txHash: string; fee: string; amount: string }> {
if (!this.wallet) throw new Error('Wallet RPC not started')
console.log(
`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`,
)
const result = await this.rawRpc('transfer', {
): Promise<{ txHash: string; fee: string; amount: string }> {
if (!state.wallet) throw new Error('Wallet RPC not started')
console.log(`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`)
const result = await rawRpc(state, 'transfer', {
destinations: [{ address, amount: amount.toString() }],
priority,
account_index: accountIndex,
@@ -428,37 +313,36 @@ export class MoneroWalletManager {
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(),
}
}
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> {
if (!this.wallet) return false
export function listWallets(): string[] {
const dir = getWalletDir()
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter((f) => f.endsWith('.keys'))
.map((f) => f.replace(/\.keys$/, ''))
}
export async function isWalletOpen(state: MoneroWalletState): Promise<boolean> {
if (!state.wallet) return false
try {
await this.wallet.getHeight()
await state.wallet.getHeight()
return true
} catch {
return false
}
}
}
async isConnected(): Promise<boolean> {
if (!this.wallet) return false
export async function isConnected(state: MoneroWalletState): Promise<boolean> {
if (!state.wallet) return false
try {
const daemon = await connectToDaemonRpc(
`http://${this.daemonAddress}`,
)
const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`)
await daemon.getHeight()
return true
} catch (e) {
console.log(`[monero] connection check failed:`, e)
return false
}
}
}
+65 -146
View File
@@ -1,20 +1,30 @@
import {
isBinaryInstalled,
downloadBinary,
MoneroWalletManager,
createMoneroWalletState,
start as walletStart,
stop as walletStop,
createWallet as walletCreate,
restoreWallet as walletRestore,
openWallet as walletOpen,
getBalance as walletGetBalance,
getTransactions as walletGetTransactions,
getAccounts as walletGetAccounts,
listWallets,
getTransferDetails as walletGetTransferDetails,
getHeight as walletGetHeight,
getDaemonHeight as walletGetDaemonHeight,
getFeeEstimate as walletGetFeeEstimate,
transfer as walletTransfer,
isWalletOpen,
isConnected,
} from '../../lib/monero'
import type { MoneroWalletState } from '../../lib/monero'
export function createMoneroHandlers(state: {
manager: MoneroWalletManager | null
downloading: boolean
}) {
export function createMoneroHandlers(state: { manager: MoneroWalletState | null; downloading: boolean }) {
return {
moneroBinaryStatus: async () => {
const status = {
installed: isBinaryInstalled(),
downloading: state.downloading,
error: undefined,
}
const status = { installed: isBinaryInstalled(), downloading: state.downloading }
console.log('[rpc] moneroBinaryStatus:', status)
return status
},
@@ -31,117 +41,62 @@ export function createMoneroHandlers(state: {
state.downloading = false
}
},
moneroStart: async ({
daemonAddress,
}: {
daemonAddress?: string
}) => {
moneroStart: async ({ daemonAddress }: { daemonAddress?: string }) => {
console.log('[rpc] moneroStart:', { daemonAddress })
if (!state.manager) {
state.manager = new MoneroWalletManager()
state.manager = createMoneroWalletState()
}
try {
await state.manager.start(daemonAddress)
await walletStart(state.manager, daemonAddress)
} catch (e) {
console.log('[rpc] moneroStart error:', e)
return { running: false, walletOpen: false, connected: false }
}
const connected = await state.manager.isConnected()
const connected = await isConnected(state.manager)
console.log('[rpc] moneroStart complete, connected:', connected)
return { running: true, walletOpen: false, connected }
},
moneroStop: async () => {
console.log('[rpc] moneroStop')
if (state.manager) {
await state.manager.stop()
await walletStop(state.manager)
state.manager = null
}
},
moneroCreateWallet: async ({
name,
password,
}: {
name: string
password: string
}) => {
moneroCreateWallet: async ({ name, password }: { name: string; password: string }) => {
console.log('[rpc] moneroCreateWallet:', name)
if (!state.manager)
throw new Error('Monero wallet RPC not started')
const result = await state.manager.createWallet(name, password)
if (!state.manager) throw new Error('Monero wallet RPC not started')
const result = await walletCreate(state.manager, name, password)
console.log('[rpc] moneroCreateWallet complete')
return result
},
moneroRestoreWallet: async ({
name,
password,
mnemonic,
restoreHeight,
}: {
name: string
password: string
mnemonic: string
restoreHeight?: number
}) => {
moneroRestoreWallet: async ({ name, password, mnemonic, restoreHeight }: { name: string; password: string; mnemonic: string; restoreHeight?: number }) => {
console.log('[rpc] moneroRestoreWallet:', name)
if (!state.manager)
throw new Error('Monero wallet RPC not started')
await state.manager.restoreWallet(
name,
password,
mnemonic,
restoreHeight,
)
const address = await state.manager.getAddress()
console.log(
'[rpc] moneroRestoreWallet complete, address:',
address,
)
if (!state.manager) throw new Error('Monero wallet RPC not started')
await walletRestore(state.manager, name, password, mnemonic, restoreHeight)
const address = await walletGetAddress(state.manager)
console.log('[rpc] moneroRestoreWallet complete, address:', address)
return { address }
},
moneroOpenWallet: async ({
name,
password,
}: {
name: string
password: string
}) => {
moneroOpenWallet: async ({ name, password }: { name: string; password: string }) => {
console.log('[rpc] moneroOpenWallet:', name)
if (!state.manager)
throw new Error('Monero wallet RPC not started')
await state.manager.openWallet(name, password)
if (!state.manager) throw new Error('Monero wallet RPC not started')
await walletOpen(state.manager, name, password)
},
moneroGetBalance: async () => {
console.log('[rpc] moneroGetBalance')
if (!state.manager)
throw new Error('Monero wallet RPC not started')
const { balance, unlocked } = await state.manager.getBalance()
const address = await state.manager.getAddress()
const height = await state.manager.getHeight()
const daemonHeight = await state.manager.getDaemonHeight()
console.log('[rpc] balance:', {
balance: balance.toString(),
unlocked: unlocked.toString(),
address,
height,
daemonHeight,
})
return {
balance: balance.toString(),
unlocked: unlocked.toString(),
address,
height,
daemonHeight,
}
if (!state.manager) throw new Error('Monero wallet RPC not started')
const { balance, unlocked } = await walletGetBalance(state.manager)
const address = await walletGetAddress(state.manager)
const height = await walletGetHeight(state.manager)
const daemonHeight = await walletGetDaemonHeight(state.manager)
console.log('[rpc] balance:', { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight })
return { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight }
},
moneroGetTransactions: async ({
accountIndex,
}: {
accountIndex?: number
}) => {
moneroGetTransactions: async ({ accountIndex }: { accountIndex?: number }) => {
console.log('[rpc] moneroGetTransactions', { accountIndex })
if (!state.manager)
throw new Error('Monero wallet RPC not started')
const txs = await state.manager.getTransactions(accountIndex)
if (!state.manager) throw new Error('Monero wallet RPC not started')
const txs = await walletGetTransactions(state.manager, accountIndex)
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
return txs
},
@@ -150,85 +105,49 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroWalletStatus: not running')
return { running: false, walletOpen: false, connected: false }
}
const connected = await state.manager.isConnected()
const walletOpen = await state.manager.isWalletOpen()
console.log(
'[rpc] moneroWalletStatus: running, walletOpen:',
walletOpen,
'connected:',
connected,
)
const connected = await isConnected(state.manager)
const walletOpen = await isWalletOpen(state.manager)
console.log('[rpc] moneroWalletStatus: running, walletOpen:', walletOpen, 'connected:', connected)
return { running: true, walletOpen, connected }
},
moneroGetAccounts: async () => {
console.log('[rpc] moneroGetAccounts')
if (!state.manager)
throw new Error('Monero wallet RPC not started')
const accounts = await state.manager.getAccounts()
console.log(
`[rpc] moneroGetAccounts: ${accounts.length} accounts`,
)
if (!state.manager) throw new Error('Monero wallet RPC not started')
const accounts = await walletGetAccounts(state.manager)
console.log(`[rpc] moneroGetAccounts: ${accounts.length} accounts`)
return accounts
},
moneroListWallets: async () => {
console.log('[rpc] moneroListWallets')
if (!state.manager)
throw new Error('Monero wallet RPC not started')
const wallets = state.manager.listWallets()
console.log(
`[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`,
)
if (!state.manager) throw new Error('Monero wallet RPC not started')
const wallets = listWallets()
console.log(`[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`)
return wallets
},
moneroTransfer: async ({
address,
amount,
priority,
accountIndex,
}: {
address: string
amount: string
priority?: number
accountIndex?: number
}) => {
console.log('[rpc] moneroTransfer:', {
address,
amount,
priority,
})
if (!state.manager)
throw new Error('Monero wallet RPC not started')
const result = await state.manager.transfer(
address,
BigInt(amount),
priority ?? 0,
accountIndex ?? 0,
)
moneroTransfer: async ({ address, amount, priority, accountIndex }: { address: string; amount: string; priority?: number; accountIndex?: number }) => {
console.log('[rpc] moneroTransfer:', { address, amount, priority })
if (!state.manager) throw new Error('Monero wallet RPC not started')
const result = await walletTransfer(state.manager, address, BigInt(amount), priority ?? 0, accountIndex ?? 0)
console.log('[rpc] moneroTransfer complete:', result.txHash)
return result
},
moneroGetTransferDetails: async ({ txid }: { txid: string }) => {
console.log('[rpc] moneroGetTransferDetails:', txid)
if (!state.manager)
throw new Error('Monero wallet RPC not started')
const details = await state.manager.getTransferDetails(txid)
console.log(
'[rpc] moneroGetTransferDetails complete:',
details?.hash,
)
if (!state.manager) throw new Error('Monero wallet RPC not started')
const details = await walletGetTransferDetails(state.manager, txid)
console.log('[rpc] moneroGetTransferDetails complete:', details?.hash)
return details
},
moneroGetFeeEstimate: async () => {
console.log('[rpc] moneroGetFeeEstimate')
if (!state.manager)
throw new Error('Monero wallet RPC not started')
if (!state.manager) throw new Error('Monero wallet RPC not started')
try {
const fee = await state.manager.getFeeEstimate()
const fee = await walletGetFeeEstimate(state.manager)
console.log('[monero][gas][fee]', fee)
return fee
} catch (error) {
console.log('[monero][gas][error]', error)
return null
return
}
},
}
@@ -1,4 +1,9 @@
import { electrobun, type MoneroTxEntry, type MoneroAccountEntry, type MoneroSendResult } from '$lib/electrobun'
import {
electrobun,
type MoneroTxEntry,
type MoneroAccountEntry,
type MoneroSendResult,
} from '$lib/electrobun'
import { tryCatch } from '@koins/utils'
export const moneroNetwork = {
@@ -13,7 +18,10 @@ export const moneroNetwork = {
export const atomicToXmr = (atomic: string): string => {
const n = BigInt(atomic)
const whole = n / 10_000_000_000_000n
const frac = (n % 10_000_000_000_000n).toString().padStart(12, '0').replace(/0+$/, '')
const frac = (n % 10_000_000_000_000n)
.toString()
.padStart(12, '0')
.replace(/0+$/, '')
return frac ? `${whole}.${frac}` : `${whole}`
}
@@ -52,7 +60,9 @@ export const MoneroWallet = () => {
const biometricAuth = async (): Promise<boolean> => {
if (!electrobun.rpc) return false
const [authed] = await tryCatch(
electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }),
electrobun.rpc.request.biometricAuth({
reason: 'Unlock wallet',
}),
)
return authed === true
}
@@ -81,17 +91,29 @@ export const MoneroWallet = () => {
accountType = null
}
const moneroGetStoredPassword = async (name: string): Promise<string | null> => {
const moneroGetStoredPassword = async (
name: string,
): Promise<string | null> => {
if (!electrobun.rpc) return null
const [raw] = await tryCatch(
electrobun.rpc.request.getSecret({ service: 'koins', name: `monero_pw_${name}` }),
electrobun.rpc.request.getSecret({
service: 'koins',
name: `monero_pw_${name}`,
}),
)
return raw ?? null
}
const moneroStorePassword = async (name: string, password: string) => {
const moneroStorePassword = async (
name: string,
password: string,
) => {
if (!electrobun.rpc) return
await electrobun.rpc.request.setSecret({ service: 'koins', name: `monero_pw_${name}`, value: password })
await electrobun.rpc.request.setSecret({
service: 'koins',
name: `monero_pw_${name}`,
value: password,
})
}
const fetchAccounts = async () => {
@@ -112,11 +134,17 @@ export const MoneroWallet = () => {
wallets = result ?? []
}
const openExistingWallet = async (name: string, password?: string) => {
const openExistingWallet = async (
name: string,
password?: string,
) => {
if (!electrobun.rpc) throw new Error('RPC not available')
const pw = password ?? await moneroGetStoredPassword(name)
const pw = password ?? (await moneroGetStoredPassword(name))
if (!pw) throw new Error('Password required')
await electrobun.rpc.request.moneroOpenWallet({ name, password: pw })
await electrobun.rpc.request.moneroOpenWallet({
name,
password: pw,
})
walletName = name
walletOpen = true
await refresh()
@@ -173,9 +201,16 @@ export const MoneroWallet = () => {
wallets = []
}
const createWallet = async (name: string, password: string, storePw?: boolean) => {
const createWallet = async (
name: string,
password: string,
storePw?: boolean,
) => {
if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroCreateWallet({ name, password })
const result = await electrobun.rpc.request.moneroCreateWallet({
name,
password,
})
walletName = name
walletOpen = true
address = result.address
@@ -184,10 +219,19 @@ export const MoneroWallet = () => {
return result
}
const restoreWallet = async (name: string, password: string, mnemonic: string, restoreHeight?: number, storePw?: boolean) => {
const restoreWallet = async (
name: string,
password: string,
mnemonic: string,
restoreHeight?: number,
storePw?: boolean,
) => {
if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroRestoreWallet({
name, password, mnemonic, restoreHeight,
name,
password,
mnemonic,
restoreHeight,
})
walletName = name
walletOpen = true
@@ -250,34 +294,90 @@ export const MoneroWallet = () => {
}
return {
get accountType() { return accountType },
get ready() { return ready },
get biometricAvailable() { return biometricAvailable },
get running() { return running },
get walletOpen() { return walletOpen },
get connected() { return connected },
get balAtomic() { return balAtomic },
get unlockedAtomic() { return unlockedAtomic },
get address() { return address },
get height() { return height },
get daemonHeight() { return daemonHeight },
get txs() { return txs },
get installed() { return installed },
get downloading() { return downloading },
get walletName() { return walletName },
get accounts() { return accounts },
get wallets() { return wallets },
get loading() { return loading },
get error() { return error },
get balance() { return atomicToXmr(balAtomic) },
get unlocked() { return atomicToXmr(unlockedAtomic) },
set error(v: string) { error = v },
set installed(v: boolean) { installed = v },
set downloading(v: boolean) { downloading = v },
set running(v: boolean) { running = v },
set connected(v: boolean) { connected = v },
get selectedAccountIndex() { return selectedAccountIndex },
set selectedAccountIndex(v: number) { selectedAccountIndex = v },
get accountType() {
return accountType
},
get ready() {
return ready
},
get biometricAvailable() {
return biometricAvailable
},
get running() {
return running
},
get walletOpen() {
return walletOpen
},
get connected() {
return connected
},
get balAtomic() {
return balAtomic
},
get unlockedAtomic() {
return unlockedAtomic
},
get address() {
return address
},
get height() {
return height
},
get daemonHeight() {
return daemonHeight
},
get txs() {
return txs
},
get installed() {
return installed
},
get downloading() {
return downloading
},
get walletName() {
return walletName
},
get accounts() {
return accounts
},
get wallets() {
return wallets
},
get loading() {
return loading
},
get error() {
return error
},
get balance() {
return atomicToXmr(balAtomic)
},
get unlocked() {
return atomicToXmr(unlockedAtomic)
},
set error(v: string) {
error = v
},
set installed(v: boolean) {
installed = v
},
set downloading(v: boolean) {
downloading = v
},
set running(v: boolean) {
running = v
},
set connected(v: boolean) {
connected = v
},
get selectedAccountIndex() {
return selectedAccountIndex
},
set selectedAccountIndex(v: number) {
selectedAccountIndex = v
},
init,
login,
logout,
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
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 {
Card,
@@ -17,6 +18,7 @@
if (!initStarted) {
initStarted = true
w.init()
moneroWallet.init()
}
})
</script>
+46 -10
View File
@@ -1,5 +1,16 @@
<script lang="ts">
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
@@ -15,7 +26,9 @@
<Card>
<CardHeader>
<CardTitle>Monero Settings</CardTitle>
<CardDescription>Server and wallet preferences</CardDescription>
<CardDescription>
Server and wallet preferences
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4">
@@ -24,7 +37,10 @@
<Button
variant="outline"
size="sm"
onclick={async () => { await w.stop(); await w.start() }}
onclick={async () => {
await w.stop()
await w.start()
}}
disabled={w.loading}>
{#if w.loading}
<Loader />
@@ -34,23 +50,43 @@
</div>
{#if w.biometricAvailable}
<div>
<p class="text-xs font-medium mb-2">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>
<p class="text-xs font-medium mb-2">
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">
<Input type="password" placeholder="Wallet password" bind:value={moneroSettingsPw} />
<Button size="sm" onclick={async () => {
await w.moneroStorePassword(w.walletName, moneroSettingsPw)
<Input
type="password"
placeholder="Wallet password"
bind:value={moneroSettingsPw} />
<Button
size="sm"
onclick={async () => {
await w.moneroStorePassword(
w.walletName,
moneroSettingsPw,
)
moneroSettingsPw = ''
w.error = ''
}} disabled={!moneroSettingsPw || w.loading}>Save</Button>
}}
disabled={!moneroSettingsPw || w.loading}>
Save
</Button>
</div>
</div>
{: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}
<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
</Button>
</div>