fix: process stdout/stderr ReadableStream types, add sv-router/generated path mapping

This commit is contained in:
2026-05-26 03:24:28 +08:00
parent 2993df29cb
commit 915f6f36f6
2 changed files with 206 additions and 64 deletions
+204 -63
View File
@@ -33,7 +33,9 @@ function rpcUrl(state: MoneroWalletState): string {
return `http://127.0.0.1:${state.rpcPort}` return `http://127.0.0.1:${state.rpcPort}`
} }
function rpcConnection(state: MoneroWalletState): MoneroRpcConnection { function rpcConnection(
state: MoneroWalletState,
): MoneroRpcConnection {
return new MoneroRpcConnection({ return new MoneroRpcConnection({
uri: rpcUrl(state), uri: rpcUrl(state),
username: state.rpcUser, username: state.rpcUser,
@@ -41,14 +43,22 @@ function rpcConnection(state: MoneroWalletState): MoneroRpcConnection {
}) })
} }
async function rawRpc(state: MoneroWalletState, method: string, params: Record<string, any> = {}): Promise<any> { async function rawRpc(
state: MoneroWalletState,
method: string,
params: Record<string, any> = {},
): Promise<any> {
const conn = rpcConnection(state) const conn = rpcConnection(state)
const res = await conn.sendJsonRequest(method, params) 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 return res?.result
} }
async function waitForRpc(state: MoneroWalletState, 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) {
@@ -57,7 +67,9 @@ async function waitForRpc(state: MoneroWalletState, timeoutMs = 30000): Promise<
hostname: '127.0.0.1', hostname: '127.0.0.1',
port: state.rpcPort, port: state.rpcPort,
socket: { socket: {
open(s) { s.end() }, open(s) {
s.end()
},
close() {}, close() {},
data() {}, data() {},
error() {}, error() {},
@@ -70,12 +82,19 @@ async function waitForRpc(state: MoneroWalletState, timeoutMs = 30000): Promise<
} }
await new Promise((r) => setTimeout(r, 500)) 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}`,
)
} }
export async function start(state: MoneroWalletState, daemonAddress: string = DEFAULT_DAEMON) { export async function start(
state: MoneroWalletState,
daemonAddress: string = DEFAULT_DAEMON,
) {
if (!existsSync(getBinaryPath())) if (!existsSync(getBinaryPath()))
throw new Error('monero-wallet-rpc not installed. Call downloadBinary() first.') throw new Error(
'monero-wallet-rpc not installed. Call downloadBinary() first.',
)
state.daemonAddress = daemonAddress state.daemonAddress = daemonAddress
console.log(`[monero] starting wallet-rpc process...`) console.log(`[monero] starting wallet-rpc process...`)
@@ -84,11 +103,16 @@ export async function start(state: MoneroWalletState, daemonAddress: string = DE
const cmd = [ const cmd = [
getBinaryPath(), getBinaryPath(),
'--daemon-address', state.daemonAddress, '--daemon-address',
'--rpc-bind-port', String(state.rpcPort), state.daemonAddress,
'--rpc-bind-ip', '127.0.0.1', '--rpc-bind-port',
'--wallet-dir', getWalletDir(), String(state.rpcPort),
'--rpc-login', `${state.rpcUser}:${state.rpcPassword}`, '--rpc-bind-ip',
'127.0.0.1',
'--wallet-dir',
getWalletDir(),
'--rpc-login',
`${state.rpcUser}:${state.rpcPassword}`,
'--trusted-daemon', '--trusted-daemon',
] ]
@@ -98,42 +122,57 @@ export async function start(state: MoneroWalletState, daemonAddress: string = DE
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
onExit(_proc, exitCode, _signalCode, error) { onExit(_proc, exitCode, _signalCode, error) {
console.log(`[monero] wallet-rpc process exited with code ${exitCode}`, error?.message ?? '') console.log(
`[monero] wallet-rpc process exited with code ${exitCode}`,
error?.message ?? '',
)
}, },
}) })
console.log(`[monero] wallet-rpc pid: ${state.process.pid}`) console.log(`[monero] wallet-rpc pid: ${state.process.pid}`)
const stdout = state.process.stdout.getReader() const reader = (state.process!.stdout as ReadableStream<Uint8Array>).getReader()
;(async () => { ;(async () => {
while (true) { while (true) {
const { done, value } = await stdout.read() const { done, value } = await reader.read()
if (done) break if (done) break
console.log(`[monero:stdout] ${new TextDecoder().decode(value)}`) console.log(
`[monero:stdout] ${new TextDecoder().decode(value)}`,
)
} }
})() })()
const stderr = state.process.stderr.getReader() const stderr = (state.process!.stderr as ReadableStream<Uint8Array>).getReader()
;(async () => { ;(async () => {
while (true) { while (true) {
const { done, value } = await stderr.read() const { done, value } = await stderr.read()
if (done) break if (done) break
console.log(`[monero:stderr] ${new TextDecoder().decode(value)}`) console.log(
`[monero:stderr] ${new TextDecoder().decode(value)}`,
)
} }
})() })()
console.log(`[monero] waiting for RPC server to become available...`) console.log(
`[monero] waiting for RPC server to become available...`,
)
await waitForRpc(state) await waitForRpc(state)
console.log(`[monero] RPC server is ready`) console.log(`[monero] RPC server is ready`)
console.log(`[monero] connecting MoneroWalletRpc client...`) console.log(`[monero] connecting MoneroWalletRpc client...`)
state.wallet = await connectToWalletRpc(rpcUrl(state), state.rpcUser, state.rpcPassword) as MoneroWalletRpc state.wallet = (await connectToWalletRpc(
rpcUrl(state),
state.rpcUser,
state.rpcPassword,
)) as MoneroWalletRpc
console.log(`[monero] MoneroWalletRpc connected successfully`) console.log(`[monero] MoneroWalletRpc connected successfully`)
} }
export async function stop(state: MoneroWalletState) { export async function stop(state: MoneroWalletState) {
console.log(`[monero] stopping wallet manager...`) console.log(`[monero] stopping wallet manager...`)
if (state.process) { if (state.process) {
console.log(`[monero] killing wallet-rpc process (pid ${state.process.pid})`) console.log(
`[monero] killing wallet-rpc process (pid ${state.process.pid})`,
)
state.process.kill() state.process.kill()
state.process = null state.process = null
} }
@@ -141,24 +180,48 @@ export async function stop(state: MoneroWalletState) {
console.log(`[monero] wallet manager stopped`) console.log(`[monero] wallet manager stopped`)
} }
export async function createWallet(state: MoneroWalletState, name: string, password: string) { export async function createWallet(
state: MoneroWalletState,
name: string,
password: string,
) {
console.log(`[monero] creating wallet: ${name}`) console.log(`[monero] creating wallet: ${name}`)
const result = await rawRpc(state, 'create_wallet', { filename: name, password, language: 'English' }) const result = await rawRpc(state, 'create_wallet', {
filename: name,
password,
language: 'English',
})
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const address = await state.wallet.getAddress(0, 0) 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 }
} }
export async function restoreWallet(state: MoneroWalletState, name: string, password: string, mnemonic: string, restoreHeight?: number) { export async function restoreWallet(
console.log(`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`) 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', { await rawRpc(state, 'restore_deterministic_wallet', {
filename: name, password, seed: mnemonic, restore_height: restoreHeight ?? 0, language: 'English', filename: name,
password,
seed: mnemonic,
restore_height: restoreHeight ?? 0,
language: 'English',
}) })
console.log(`[monero] wallet restored: ${name}`) console.log(`[monero] wallet restored: ${name}`)
} }
export async function openWallet(state: MoneroWalletState, 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 (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
await state.wallet.openWallet(name, password) await state.wallet.openWallet(name, password)
@@ -172,42 +235,68 @@ export async function closeWallet(state: MoneroWalletState) {
console.log(`[monero] wallet closed`) console.log(`[monero] wallet closed`)
} }
export async function getBalance(state: MoneroWalletState): Promise<{ balance: bigint; unlocked: bigint }> { export async function getBalance(
state: MoneroWalletState,
): Promise<{ balance: bigint; unlocked: bigint }> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const balance = await state.wallet.getBalance() as bigint const balance = (await state.wallet.getBalance()) as bigint
const unlocked = await state.wallet.getUnlockedBalance() as bigint const unlocked = (await state.wallet.getUnlockedBalance()) as bigint
console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`) console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
return { balance, unlocked } return { balance, unlocked }
} }
export async function getAddress(state: MoneroWalletState, accountIdx = 0, subIdx = 0): Promise<string> { export async function getAddress(
state: MoneroWalletState,
accountIdx = 0,
subIdx = 0,
): Promise<string> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const address = await state.wallet.getAddress(accountIdx, subIdx) as string const address = (await state.wallet.getAddress(
accountIdx,
subIdx,
)) as string
console.log(`[monero] address: ${address}`) console.log(`[monero] address: ${address}`)
return address return address
} }
export async function getAccounts(state: MoneroWalletState): Promise<any[]> { export async function getAccounts(
state: MoneroWalletState,
): Promise<any[]> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const accounts = await state.wallet.getAccounts(true) as any[] const accounts = (await state.wallet.getAccounts(true)) as any[]
console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`) console.log(`[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(`[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) 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) { 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()) ?? [] return accounts?.map((a) => a.toJson()) ?? []
} }
export async function getTransactions(state: MoneroWalletState, accountIndex?: number): Promise<any[]> { export async function getTransactions(
state: MoneroWalletState,
accountIndex?: number,
): Promise<any[]> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const params: Record<string, any> = { in: true, out: true, pending: false, pool: false } const params: Record<string, any> = {
in: true,
out: true,
pending: false,
pool: false,
}
if (accountIndex !== undefined) params.account_index = accountIndex if (accountIndex !== undefined) params.account_index = accountIndex
const result = await rawRpc(state, 'get_transfers', params) const result = await rawRpc(state, 'get_transfers', params)
const all: any[] = [] const all: any[] = []
@@ -224,22 +313,41 @@ export async function getTransactions(state: MoneroWalletState, accountIndex?: n
}) })
} }
} }
all.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp)) all.sort(
console.log(`[monero] transactions: ${all.length} returned${accountIndex !== undefined ? ` for account ${accountIndex}` : ''}`) (a: any, b: any) => Number(b.timestamp) - Number(a.timestamp),
)
console.log(
`[monero] transactions: ${all.length} returned${accountIndex !== undefined ? ` for account ${accountIndex}` : ''}`,
)
return all return all
} }
export async function getTransferDetails(state: MoneroWalletState, txid: string): Promise<any> { export async function getTransferDetails(
state: MoneroWalletState,
txid: string,
): Promise<any> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const result = await rawRpc(state, '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 = 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 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),
@@ -258,36 +366,55 @@ export async function getTransferDetails(state: MoneroWalletState, txid: string)
} }
} }
export async function getHeight(state: MoneroWalletState): Promise<number> { export async function getHeight(
state: MoneroWalletState,
): Promise<number> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const height = await state.wallet.getHeight() as number const height = (await state.wallet.getHeight()) as number
console.log(`[monero] wallet height: ${height}`) console.log(`[monero] wallet height: ${height}`)
return height return height
} }
export async function getFeeEstimate(state: MoneroWalletState): Promise<{ fee: string; fees: string[]; estimatedFee: string }> { export async function getFeeEstimate(
state: MoneroWalletState,
): Promise<{ fee: string; fees: string[]; estimatedFee: string }> {
try { try {
const conn = new MoneroRpcConnection({ uri: `http://${state.daemonAddress}` }) const conn = new MoneroRpcConnection({
const res = await conn.sendJsonRequest('get_fee_estimate', { grace_blocks: 10 }) uri: `http://${state.daemonAddress}`,
})
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) => BigInt(f).toString()) const fees = (result.fees ?? []).map((f: string) =>
const estimated = (feePerKb * BigInt(Math.round(txSizeKb * 10))) / 10n BigInt(f).toString(),
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 { fee: '0', fees: ['0', '0', '0', '0'], estimatedFee: '0' } return { fee: '0', fees: ['0', '0', '0', '0'], estimatedFee: '0' }
} }
} }
export async function getDaemonHeight(state: MoneroWalletState): Promise<number> { export async function getDaemonHeight(
state: MoneroWalletState,
): Promise<number> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
try { try {
const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`) const daemon = await connectToDaemonRpc(
const height = await daemon.getHeight() as number `http://${state.daemonAddress}`,
)
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) {
@@ -304,7 +431,9 @@ export async function transfer(
accountIndex: number = 0, accountIndex: number = 0,
): Promise<{ txHash: string; fee: string; amount: string }> { ): Promise<{ txHash: string; fee: string; amount: string }> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
console.log(`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`) console.log(
`[monero] transfer: ${address} amount=${amount.toString()} priority=${priority} account=${accountIndex}`,
)
const result = await rawRpc(state, 'transfer', { const result = await rawRpc(state, 'transfer', {
destinations: [{ address, amount: amount.toString() }], destinations: [{ address, amount: amount.toString() }],
priority, priority,
@@ -313,8 +442,14 @@ export async function transfer(
get_tx_hex: false, get_tx_hex: false,
get_tx_metadata: false, get_tx_metadata: false,
}) })
console.log(`[monero] transfer result: tx_hash=${result.tx_hash} fee=${result.fee} amount=${result.amount}`) console.log(
return { txHash: result.tx_hash, fee: result.fee?.toString() ?? '0', amount: result.amount?.toString() ?? amount.toString() } `[monero] transfer result: tx_hash=${result.tx_hash} fee=${result.fee} amount=${result.amount}`,
)
return {
txHash: result.tx_hash,
fee: result.fee?.toString() ?? '0',
amount: result.amount?.toString() ?? amount.toString(),
}
} }
export function listWallets(): string[] { export function listWallets(): string[] {
@@ -325,7 +460,9 @@ export function listWallets(): string[] {
.map((f) => f.replace(/\.keys$/, '')) .map((f) => f.replace(/\.keys$/, ''))
} }
export async function isWalletOpen(state: MoneroWalletState): Promise<boolean> { export async function isWalletOpen(
state: MoneroWalletState,
): Promise<boolean> {
if (!state.wallet) return false if (!state.wallet) return false
try { try {
await state.wallet.getHeight() await state.wallet.getHeight()
@@ -335,10 +472,14 @@ export async function isWalletOpen(state: MoneroWalletState): Promise<boolean> {
} }
} }
export async function isConnected(state: MoneroWalletState): Promise<boolean> { export async function isConnected(
state: MoneroWalletState,
): Promise<boolean> {
if (!state.wallet) return false if (!state.wallet) return false
try { try {
const daemon = await connectToDaemonRpc(`http://${state.daemonAddress}`) const daemon = await connectToDaemonRpc(
`http://${state.daemonAddress}`,
)
await daemon.getHeight() await daemon.getHeight()
return true return true
} catch (e) { } catch (e) {
+2 -1
View File
@@ -11,7 +11,8 @@
"skipLibCheck": true, "skipLibCheck": true,
"paths": { "paths": {
"$lib": ["./src/lib"], "$lib": ["./src/lib"],
"$lib/*": ["./src/lib/*"] "$lib/*": ["./src/lib/*"],
"sv-router/generated": ["./.router/router.ts"]
}, },
"types": ["bun-types", "node"] "types": ["bun-types", "node"]
} }