fix tx amount/direction mapping, add date display, sort by latest

This commit is contained in:
2026-05-17 11:35:15 +08:00
parent b4d5304587
commit 9c1b157107
2 changed files with 140 additions and 46 deletions
+137 -41
View File
@@ -1,4 +1,9 @@
import { MoneroWalletRpc, MoneroRpcConnection, connectToWalletRpc, connectToDaemonRpc } from 'monero-ts' import {
MoneroWalletRpc,
MoneroRpcConnection,
connectToWalletRpc,
connectToDaemonRpc,
} 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' import { join } from 'path'
@@ -24,9 +29,11 @@ export class MoneroWalletManager {
if (!existsSync(dir)) return [] if (!existsSync(dir)) return []
const files = readdirSync(dir) const files = readdirSync(dir)
const wallets = files const wallets = files
.filter(f => f.endsWith('.keys')) .filter((f) => f.endsWith('.keys'))
.map(f => f.replace(/\.keys$/, '')) .map((f) => f.replace(/\.keys$/, ''))
console.log(`[monero] wallets on disk: ${wallets.join(', ') || 'none'}`) console.log(
`[monero] wallets on disk: ${wallets.join(', ') || 'none'}`,
)
return wallets return wallets
} }
@@ -43,7 +50,10 @@ export class MoneroWalletManager {
} }
async start(daemonAddress: string = DEFAULT_DAEMON) { async start(daemonAddress: string = DEFAULT_DAEMON) {
if (!existsSync(getBinaryPath())) throw new Error('monero-wallet-rpc not installed. Call downloadBinary() first.') if (!existsSync(getBinaryPath()))
throw new Error(
'monero-wallet-rpc not installed. Call downloadBinary() first.',
)
this.daemonAddress = daemonAddress this.daemonAddress = daemonAddress
console.log(`[monero] starting wallet-rpc process...`) console.log(`[monero] starting wallet-rpc process...`)
@@ -52,11 +62,16 @@ export class MoneroWalletManager {
const cmd = [ const cmd = [
getBinaryPath(), getBinaryPath(),
'--daemon-address', this.daemonAddress, '--daemon-address',
'--rpc-bind-port', String(this.rpcPort), this.daemonAddress,
'--rpc-bind-ip', '127.0.0.1', '--rpc-bind-port',
'--wallet-dir', getWalletDir(), String(this.rpcPort),
'--rpc-login', `${this.rpcUser}:${this.rpcPassword}`, '--rpc-bind-ip',
'127.0.0.1',
'--wallet-dir',
getWalletDir(),
'--rpc-login',
`${this.rpcUser}:${this.rpcPassword}`,
'--trusted-daemon', '--trusted-daemon',
] ]
@@ -66,7 +81,10 @@ export class MoneroWalletManager {
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: ${this.process.pid}`) console.log(`[monero] wallet-rpc pid: ${this.process.pid}`)
@@ -76,7 +94,9 @@ export class MoneroWalletManager {
while (true) { while (true) {
const { done, value } = await stdout.read() const { done, value } = await stdout.read()
if (done) break if (done) break
console.log(`[monero:stdout] ${new TextDecoder().decode(value)}`) console.log(
`[monero:stdout] ${new TextDecoder().decode(value)}`,
)
} }
})() })()
@@ -85,23 +105,35 @@ export class MoneroWalletManager {
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 this.waitForRpc() await this.waitForRpc()
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...`)
this.wallet = await connectToWalletRpc(this.rpcUrl, this.rpcUser, this.rpcPassword) as MoneroWalletRpc this.wallet = (await connectToWalletRpc(
this.rpcUrl,
this.rpcUser,
this.rpcPassword,
)) as MoneroWalletRpc
console.log(`[monero] MoneroWalletRpc connected successfully`) console.log(`[monero] MoneroWalletRpc connected successfully`)
} }
private async rawRpc(method: string, params: Record<string, any> = {}): Promise<any> { private async rawRpc(
method: string,
params: Record<string, any> = {},
): Promise<any> {
const conn = this.rpcConnection const conn = this.rpcConnection
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
} }
@@ -113,22 +145,35 @@ export class MoneroWalletManager {
await Bun.connect({ await Bun.connect({
hostname: '127.0.0.1', hostname: '127.0.0.1',
port: this.rpcPort, port: this.rpcPort,
socket: { open(s) { s.end() }, close() {}, data() {}, error() {} }, socket: {
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 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(`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() { async stop() {
console.log(`[monero] stopping wallet manager...`) console.log(`[monero] stopping wallet manager...`)
if (this.process) { if (this.process) {
console.log(`[monero] killing wallet-rpc process (pid ${this.process.pid})`) console.log(
`[monero] killing wallet-rpc process (pid ${this.process.pid})`,
)
this.process.kill() this.process.kill()
this.process = null this.process = null
} }
@@ -149,8 +194,15 @@ export class MoneroWalletManager {
return { mnemonic: result.mnemonic, address } return { mnemonic: result.mnemonic, address }
} }
async restoreWallet(name: string, password: string, mnemonic: string, restoreHeight?: number) { async restoreWallet(
console.log(`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`) name: string,
password: string,
mnemonic: string,
restoreHeight?: number,
) {
console.log(
`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`,
)
await this.rawRpc('restore_deterministic_wallet', { await this.rawRpc('restore_deterministic_wallet', {
filename: name, filename: name,
password, password,
@@ -177,55 +229,95 @@ export class MoneroWalletManager {
async getBalance(): Promise<{ balance: bigint; unlocked: bigint }> { async getBalance(): Promise<{ balance: bigint; unlocked: bigint }> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!this.wallet) throw new Error('Wallet RPC not started')
const bal = await this.wallet.getBalance() as bigint const bal = (await this.wallet.getBalance()) as bigint
const unlocked = await this.wallet.getUnlockedBalance() as bigint const unlocked =
(await this.wallet.getUnlockedBalance()) as bigint
console.log(`[monero] balance: ${bal} (unlocked: ${unlocked})`) console.log(`[monero] balance: ${bal} (unlocked: ${unlocked})`)
return { balance: bal, unlocked } return { balance: bal, unlocked }
} }
async getAddress(accountIdx = 0, subIdx = 0): Promise<string> { async getAddress(accountIdx = 0, subIdx = 0): Promise<string> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!this.wallet) throw new Error('Wallet RPC not started')
const address = await this.wallet.getAddress(accountIdx, subIdx) as string const address = (await this.wallet.getAddress(
accountIdx,
subIdx,
)) as string
console.log(`[monero] address: ${address}`) console.log(`[monero] address: ${address}`)
return address return address
} }
async getAccounts(): Promise<any[]> { async getAccounts(): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!this.wallet) throw new Error('Wallet RPC not started')
const accounts = await this.wallet.getAccounts(true) as any[] const accounts = (await this.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) { 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(`[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()) ?? []
} }
async getTransactions(): Promise<any[]> { async getTransactions(): Promise<any[]> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!this.wallet) throw new Error('Wallet RPC not started')
const txs = await this.wallet.getTxs() as any[] const txs = (await this.wallet.getTxs()) as any[]
console.log(`[monero] transactions: ${txs?.length ?? 0} returned`) console.log(`[monero] transactions: ${txs?.length ?? 0} returned`)
if (txs?.length) { if (txs?.length) {
for (const tx of txs.slice(0, 5)) { for (const tx of txs.slice(0, 5)) {
console.log(`[monero] tx: hash=${tx.hash?.substring(0, 12) ?? '?'} amount=${tx.amount?.toString() ?? '0'} dir=${tx.direction} height=${tx.height ?? 0}`) const isIn = tx.getIsIncoming()
const isOut = tx.getIsOutgoing()
const amt = isIn
? (tx.getIncomingAmount() ?? 0n)
: (tx.getOutgoingAmount() ?? 0n)
console.log(
`[monero] tx: hash=${(tx.getHash() ?? '').substring(0, 12)} amount=${amt.toString()} dir=${isIn ? 'in' : 'out'} height=${tx.getHeight() ?? 0}`,
)
} }
if (txs.length > 5) console.log(`[monero] ... and ${txs.length - 5} more`) if (txs.length > 5)
console.log(`[monero] ... and ${txs.length - 5} more`)
} }
return txs ?? [] const result = (txs || []).map((tx: any) => {
const isIn = tx.getIsIncoming()
const isOut = tx.getIsOutgoing()
const amt = isIn
? (tx.getIncomingAmount() ?? 0n)
: (tx.getOutgoingAmount() ?? 0n)
const block = tx.getBlock()
const ts =
block?.getTimestamp() ?? tx.getReceivedTimestamp() ?? 0
return {
hash: tx.getHash(),
amount: amt?.toString() ?? '0',
timestamp: String(ts),
direction: isIn ? ('in' as const) : ('out' as const),
height: tx.getHeight() ?? 0,
}
})
result.sort(
(a: any, b: any) => Number(b.timestamp) - Number(a.timestamp),
)
return result
} }
async getHeight(): Promise<number> { async getHeight(): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!this.wallet) throw new Error('Wallet RPC not started')
const height = await this.wallet.getHeight() as number const height = (await this.wallet.getHeight()) as number
console.log(`[monero] wallet height: ${height}`) console.log(`[monero] wallet height: ${height}`)
return height return height
} }
@@ -233,8 +325,10 @@ export class MoneroWalletManager {
async getDaemonHeight(): Promise<number> { async getDaemonHeight(): Promise<number> {
if (!this.wallet) throw new Error('Wallet RPC not started') if (!this.wallet) throw new Error('Wallet RPC not started')
try { try {
const daemon = await connectToDaemonRpc(`http://${this.daemonAddress}`) const daemon = await connectToDaemonRpc(
const height = await daemon.getHeight() as number `http://${this.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) {
@@ -256,7 +350,9 @@ export class MoneroWalletManager {
async isConnected(): Promise<boolean> { async isConnected(): Promise<boolean> {
if (!this.wallet) return false if (!this.wallet) return false
try { try {
const daemon = await connectToDaemonRpc(`http://${this.daemonAddress}`) const daemon = await connectToDaemonRpc(
`http://${this.daemonAddress}`,
)
await daemon.getHeight() await daemon.getHeight()
return true return true
} catch (e) { } catch (e) {
+3 -5
View File
@@ -268,12 +268,10 @@
{tx.direction === 'in' ? 'Received' : 'Sent'} {tx.direction === 'in' ? 'Received' : 'Sent'}
</p> </p>
<p class="font-mono text-muted-foreground"> <p class="font-mono text-muted-foreground">
{Number(tx.amount) > 0 {atomicToXmr(tx.amount)} XMR
? `${tx.direction === 'in' ? '+' : '-'}${(BigInt(tx.amount) / 10_000_000_000_000n).toString()} XMR`
: '0 XMR'}
</p> </p>
{#if tx.height > 0} {#if tx.timestamp && tx.timestamp !== '0'}
<p class="text-muted-foreground">Block {tx.height}</p> <p class="text-muted-foreground">{new Date(Number(tx.timestamp) * 1000).toLocaleString()}</p>
{/if} {/if}
<p class="text-muted-foreground/50 truncate font-mono"> <p class="text-muted-foreground/50 truncate font-mono">
{tx.hash.slice(0, 12)}... {tx.hash.slice(0, 12)}...