add monero accounts/addresses, fix rpc auth, fix binary extraction

This commit is contained in:
2026-05-17 10:44:36 +08:00
parent 6b7a773845
commit 1555144400
9 changed files with 1286 additions and 233 deletions
+182 -1
View File
@@ -21,6 +21,13 @@ import { db } from './lib/db'
import { join } from 'path'
import { getENV } from './lib/get-env'
import { getClient, getTransactionDetails } from './lib/viem'
import {
isBinaryInstalled,
downloadBinary,
getBinaryPath,
getBinaryDir,
MoneroWalletManager,
} from './lib/monero'
const env = getENV()
@@ -169,6 +176,71 @@ type RPC = {
}
response: void
}
moneroBinaryStatus: {
params: {}
response: {
installed: boolean
downloading: boolean
error?: string
}
}
moneroDownloadBinary: {
params: {}
response: void
}
moneroStart: {
params: { daemonAddress?: string }
response: {
running: boolean
walletOpen: boolean
walletName?: string
connected: boolean
}
}
moneroStop: {
params: {}
response: void
}
moneroCreateWallet: {
params: { name: string; password: string }
response: { mnemonic: string; address: string }
}
moneroRestoreWallet: {
params: { name: string; password: string; mnemonic: string; restoreHeight?: number }
response: { address: string }
}
moneroOpenWallet: {
params: { name: string; password: string }
response: void
}
moneroGetBalance: {
params: {}
response: {
balance: string
unlocked: string
address: string
height: number
}
}
moneroGetTransactions: {
params: {}
response: Array<{
hash: string
amount: string
timestamp: string
direction: 'in' | 'out'
height: number
}>
}
moneroWalletStatus: {
params: {}
response: {
running: boolean
walletOpen: boolean
walletName?: string
connected: boolean
}
}
}
messages: {}
}>
@@ -274,8 +346,11 @@ function mapTransfer(t: any): TxEntry {
}
}
let moneroManager: MoneroWalletManager | null = null
let moneroDownloading = false
const rpc = BrowserView.defineRPC<RPC>({
maxRequestTime: 10000,
maxRequestTime: 120000,
handlers: {
requests: {
getSecret: async ({ name, service }) => {
@@ -441,6 +516,112 @@ const rpc = BrowserView.defineRPC<RPC>({
return null
}
},
moneroBinaryStatus: async () => {
const status = { installed: isBinaryInstalled(), downloading: moneroDownloading }
console.log('[rpc] moneroBinaryStatus:', status)
return status
},
moneroDownloadBinary: async () => {
console.log('[rpc] moneroDownloadBinary: starting download...')
moneroDownloading = true
try {
await downloadBinary()
console.log('[rpc] moneroDownloadBinary: complete')
} catch (e) {
console.log('[rpc] moneroDownloadBinary failed:', e)
throw e
} finally {
moneroDownloading = false
}
},
moneroStart: async ({ daemonAddress }) => {
console.log('[rpc] moneroStart:', { daemonAddress })
if (!moneroManager) {
moneroManager = new MoneroWalletManager()
}
try {
await moneroManager.start(daemonAddress)
} catch (e) {
console.log('[rpc] moneroStart error:', e)
return { running: false, walletOpen: false, connected: false }
}
const connected = await moneroManager.isConnected()
console.log('[rpc] moneroStart complete, connected:', connected)
return { running: true, walletOpen: false, connected }
},
moneroStop: async () => {
console.log('[rpc] moneroStop')
if (moneroManager) {
await moneroManager.stop()
moneroManager = null
}
},
moneroCreateWallet: async ({ name, password }) => {
console.log('[rpc] moneroCreateWallet:', name)
if (!moneroManager) throw new Error('Monero wallet RPC not started')
const result = await moneroManager.createWallet(name, password)
console.log('[rpc] moneroCreateWallet complete')
return result
},
moneroRestoreWallet: async ({ name, password, mnemonic, restoreHeight }) => {
console.log('[rpc] moneroRestoreWallet:', name)
if (!moneroManager) throw new Error('Monero wallet RPC not started')
await moneroManager.restoreWallet(name, password, mnemonic, restoreHeight)
const address = await moneroManager.getAddress()
console.log('[rpc] moneroRestoreWallet complete, address:', address)
return { address }
},
moneroOpenWallet: async ({ name, password }) => {
console.log('[rpc] moneroOpenWallet:', name)
if (!moneroManager) throw new Error('Monero wallet RPC not started')
await moneroManager.openWallet(name, password)
},
moneroGetBalance: async () => {
console.log('[rpc] moneroGetBalance')
if (!moneroManager) throw new Error('Monero wallet RPC not started')
const { balance, unlocked } = await moneroManager.getBalance()
const address = await moneroManager.getAddress()
const height = await moneroManager.getHeight()
const daemonHeight = await moneroManager.getDaemonHeight()
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 () => {
console.log('[rpc] moneroGetTransactions')
if (!moneroManager) throw new Error('Monero wallet RPC not started')
const txs = await moneroManager.getTransactions()
const result = (txs || []).map((tx: any) => ({
hash: tx.hash,
amount: tx.amount?.toString() ?? '0',
timestamp: String(tx.timestamp ?? 0),
direction: tx.direction === 'in' ? 'in' as const : 'out' as const,
height: tx.height ?? 0,
}))
console.log(`[rpc] moneroGetTransactions: ${result.length} txs`)
return result
},
moneroWalletStatus: async () => {
if (!moneroManager) {
console.log('[rpc] moneroWalletStatus: not running')
return { running: false, walletOpen: false, connected: false }
}
const connected = await moneroManager.isConnected()
console.log('[rpc] moneroWalletStatus: running, connected:', connected)
return { running: true, walletOpen: true, connected }
},
moneroGetAccounts: async () => {
console.log('[rpc] moneroGetAccounts')
if (!moneroManager) throw new Error('Monero wallet RPC not started')
const accounts = await moneroManager.getAccounts()
console.log(`[rpc] moneroGetAccounts: ${accounts.length} accounts`)
return accounts
},
},
messages: {},
},
+110
View File
@@ -0,0 +1,110 @@
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import envPaths from 'env-paths'
import { getENV } from '../get-env'
const env = getENV()
const { data } = envPaths(env.appName, { suffix: '' })
export const MONERO_VERSION = '0.18.5.0'
const PLATFORM_MAP: Record<string, Record<string, string>> = {
darwin: { arm64: 'mac-armv8', x64: 'mac-x64' },
linux: { arm64: 'linux-armv8', x64: 'linux-x64' },
}
function getPlatform(): string {
const os = PLATFORM_MAP[process.platform]
if (!os)
throw new Error(
`Unsupported platform: ${process.platform} ${process.arch}`,
)
const arch = os[process.arch]
if (!arch)
throw new Error(
`Unsupported arch: ${process.arch} on ${process.platform}`,
)
return arch
}
export function getDownloadUrl(): string {
return `https://downloads.getmonero.org/cli/monero-${getPlatform()}-v${MONERO_VERSION}.tar.bz2`
}
export function getBinaryDir(): string {
const dir = join(data, 'monero-bin')
mkdirSync(dir, { recursive: true })
return dir
}
export function getBinaryPath(): string {
return join(getBinaryDir(), 'monero-wallet-rpc')
}
export function getWalletDir(): string {
const dir = join(data, 'monero-wallets')
mkdirSync(dir, { recursive: true })
return dir
}
export function isBinaryInstalled(): boolean {
return existsSync(getBinaryPath())
}
export async function downloadBinary(): Promise<void> {
const url = getDownloadUrl()
const archivePath = join(
getBinaryDir(),
`monero-v${MONERO_VERSION}.tar.bz2`,
)
console.log(`Downloading Monero CLI ${MONERO_VERSION}...`)
console.log(`URL: ${url}`)
const response = await fetch(url)
if (!response.ok)
throw new Error(
`Download failed: ${response.status} ${response.statusText}`,
)
const total = Number(response.headers.get('content-length') ?? 0)
console.log(`Download size: ${(total / 1024 / 1024).toFixed(1)} MB`)
const reader = response.body?.getReader()
if (!reader) throw new Error('No response body')
const chunks: Uint8Array[] = []
let received = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
const pct = total ? ((received / total) * 100).toFixed(0) : '?'
console.log(
`Download progress: ${pct}% (${(received / 1024 / 1024).toFixed(1)} MB)`,
)
}
const buffer = new Uint8Array(received)
let offset = 0
for (const chunk of chunks) {
buffer.set(chunk, offset)
offset += chunk.length
}
await Bun.write(archivePath, buffer)
console.log(`Downloaded to ${archivePath}`)
console.log('Extracting...')
const result = Bun.spawnSync(['tar', '-xjf', archivePath, '--strip-components=1', '-C', getBinaryDir()])
if (result.exitCode !== 0) throw new Error(`Extraction failed: ${result.stderr.toString()}`)
Bun.spawnSync(['rm', archivePath])
const binaryDst = getBinaryPath()
if (!existsSync(binaryDst)) throw new Error(`monero-wallet-rpc not found after extraction`)
Bun.spawnSync(['chmod', '+x', binaryDst])
console.log(`Installed monero-wallet-rpc at ${binaryDst}`)
}
+2
View File
@@ -0,0 +1,2 @@
export * from './binary'
export { MoneroWalletManager } from './wallet'
+225
View File
@@ -0,0 +1,225 @@
import { MoneroWalletRpc, MoneroRpcConnection, connectToWalletRpc, connectToDaemonRpc } from 'monero-ts'
import { getBinaryPath, getWalletDir } from './binary'
import { existsSync } from 'fs'
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
constructor() {
this.rpcPort = 28084
this.rpcUser = 'koins'
this.rpcPassword = crypto.randomUUID()
}
private get rpcUrl() {
return `http://127.0.0.1:${this.rpcPort}`
}
private get rpcConnection(): MoneroRpcConnection {
return new MoneroRpcConnection({
uri: this.rpcUrl,
username: this.rpcUser,
password: this.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
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> {
const start = Date.now()
let lastErr = ''
while (Date.now() - start < timeoutMs) {
try {
await Bun.connect({
hostname: '127.0.0.1',
port: this.rpcPort,
socket: { open(s) { s.end() }, close() {}, data() {}, error() {} },
})
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
return
} catch (e) {
lastErr = String(e)
}
await new Promise(r => setTimeout(r, 500))
}
throw new Error(`monero-wallet-rpc failed to start within ${timeoutMs}ms: ${lastErr}`)
}
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`)
}
async createWallet(name: string, password: string) {
console.log(`[monero] creating wallet: ${name}`)
const result = await this.rawRpc('create_wallet', {
filename: name,
password,
language: 'English',
})
if (!this.wallet) throw new Error('Wallet RPC not started')
const address = await this.wallet.getAddress(0, 0)
console.log(`[monero] wallet created: ${name} -> ${address}`)
return { mnemonic: result.mnemonic, address }
}
async restoreWallet(name: string, password: string, mnemonic: string, restoreHeight?: number) {
console.log(`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`)
await this.rawRpc('restore_deterministic_wallet', {
filename: name,
password,
seed: mnemonic,
restore_height: restoreHeight ?? 0,
language: 'English',
})
console.log(`[monero] wallet restored: ${name}`)
}
async openWallet(name: string, password: string) {
console.log(`[monero] opening wallet: ${name}`)
if (!this.wallet) throw new Error('Wallet RPC not started')
await this.wallet.openWallet(name, password)
console.log(`[monero] wallet opened: ${name}`)
}
async closeWallet() {
console.log(`[monero] closing current wallet`)
if (!this.wallet) throw new Error('Wallet RPC not started')
await this.wallet.close()
console.log(`[monero] wallet closed`)
}
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 }
}
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
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`)
return accounts?.map(a => a.toJson()) ?? []
}
async getTransactions(): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const txs = await this.wallet.getTxs() as any[]
console.log(`[monero] transactions: ${txs?.length ?? 0} returned`)
return txs ?? []
}
async getHeight(): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started')
const height = await this.wallet.getHeight() as number
console.log(`[monero] wallet height: ${height}`)
return height
}
async getDaemonHeight(): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started')
try {
const daemon = await connectToDaemonRpc(`http://${this.daemonAddress}`)
const height = await daemon.getHeight() as number
console.log(`[monero] daemon height: ${height}`)
return height
} catch (e) {
console.log(`[monero] failed to get daemon height:`, e)
return 0
}
}
async isConnected(): Promise<boolean> {
if (!this.wallet) return false
try {
await this.wallet.getHeight()
return true
} catch (e) {
console.log(`[monero] connection check failed:`, e)
return false
}
}
}