752 lines
21 KiB
TypeScript
752 lines
21 KiB
TypeScript
import { MoneroWalletRpc, MoneroRpcConnection, connectToWalletRpc, connectToDaemonRpc } from 'monero-ts'
|
|
import { getBinaryPath, getWalletDir } from './binary'
|
|
import { existsSync, readdirSync } from 'fs'
|
|
import { log, logError } from '../logger'
|
|
|
|
const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081'
|
|
|
|
export type MoneroWalletState = {
|
|
wallet: MoneroWalletRpc | null
|
|
process: Bun.Subprocess | null
|
|
saveTimer: ReturnType<typeof setInterval> | null
|
|
rpcPort: number
|
|
rpcUser: string
|
|
rpcPassword: string
|
|
daemonAddress: string
|
|
}
|
|
|
|
export function createMoneroWalletState(): MoneroWalletState {
|
|
return {
|
|
wallet: null,
|
|
process: null,
|
|
saveTimer: null,
|
|
rpcPort: 28084,
|
|
rpcUser: 'koins',
|
|
rpcPassword: 'koins-local-rpc',
|
|
daemonAddress: DEFAULT_DAEMON,
|
|
}
|
|
}
|
|
|
|
function rpcUrl(state: MoneroWalletState): string {
|
|
return `http://127.0.0.1:${state.rpcPort}`
|
|
}
|
|
|
|
function rpcConnection(
|
|
state: MoneroWalletState,
|
|
): MoneroRpcConnection {
|
|
return new MoneroRpcConnection({
|
|
uri: rpcUrl(state),
|
|
username: state.rpcUser,
|
|
password: state.rpcPassword,
|
|
})
|
|
}
|
|
|
|
function withTimeout<T>(
|
|
promise: Promise<T>,
|
|
ms: number,
|
|
label: string,
|
|
): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(
|
|
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
|
ms,
|
|
)
|
|
promise.then(
|
|
(v) => {
|
|
clearTimeout(timer)
|
|
resolve(v)
|
|
},
|
|
(e) => {
|
|
clearTimeout(timer)
|
|
reject(e)
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
async function rawRpc(
|
|
state: MoneroWalletState,
|
|
method: string,
|
|
params: Record<string, any> = {},
|
|
): Promise<any> {
|
|
const conn = rpcConnection(state)
|
|
const res = await withTimeout(
|
|
conn.sendJsonRequest(method, params),
|
|
30000,
|
|
`wallet-rpc ${method}`,
|
|
)
|
|
if (res?.error)
|
|
throw new Error(res.error.message || JSON.stringify(res.error))
|
|
return res?.result
|
|
}
|
|
|
|
async function waitForRpc(
|
|
state: MoneroWalletState,
|
|
timeoutMs = 30000,
|
|
): Promise<void> {
|
|
const start = Date.now()
|
|
let lastErr = ''
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const conn = rpcConnection(state)
|
|
const res = await withTimeout(
|
|
conn.sendJsonRequest('get_version', {}),
|
|
5000,
|
|
'wallet-rpc probe',
|
|
)
|
|
if (res?.result?.version) {
|
|
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
|
|
return
|
|
}
|
|
lastErr = JSON.stringify(res)
|
|
} 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 function connectClient(
|
|
state: MoneroWalletState,
|
|
): Promise<MoneroWalletRpc> {
|
|
let lastErr = ''
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
try {
|
|
const client = (await connectToWalletRpc(
|
|
rpcUrl(state),
|
|
state.rpcUser,
|
|
state.rpcPassword,
|
|
)) as MoneroWalletRpc
|
|
console.log(`[monero] MoneroWalletRpc connected (attempt ${attempt})`)
|
|
return client
|
|
} catch (e) {
|
|
lastErr = String(e)
|
|
console.log(`[monero] connectToWalletRpc attempt ${attempt} failed:`, e)
|
|
if (attempt < 3) await new Promise((r) => setTimeout(r, 1000))
|
|
}
|
|
}
|
|
throw new Error(`Failed to connect to monero-wallet-rpc: ${lastErr}`)
|
|
}
|
|
|
|
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
|
|
|
|
// Kill any existing monero-wallet-rpc on the port (stale process from
|
|
// dev reloads or an unclean quit). Prefer SIGTERM so a live wallet gets
|
|
// a chance to save its scan progress before exiting.
|
|
try {
|
|
const proc = Bun.spawnSync(['lsof', '-ti', `tcp:${state.rpcPort}`], {
|
|
stdout: 'pipe',
|
|
})
|
|
const pids = proc.stdout.toString().trim().split('\n').filter(Boolean)
|
|
if (pids.length) {
|
|
console.log(
|
|
`[monero] stale process(es) on port ${state.rpcPort}: ${pids.join(', ')}`,
|
|
)
|
|
for (const pid of pids) {
|
|
try {
|
|
process.kill(Number(pid), 'SIGTERM')
|
|
} catch {}
|
|
}
|
|
const deadline = Date.now() + 5000
|
|
while (Date.now() < deadline) {
|
|
const check = Bun.spawnSync(
|
|
['lsof', '-ti', `tcp:${state.rpcPort}`],
|
|
{ stdout: 'pipe' },
|
|
)
|
|
if (!check.stdout.toString().trim()) break
|
|
await new Promise((r) => setTimeout(r, 250))
|
|
}
|
|
const leftover = Bun.spawnSync(
|
|
['lsof', '-ti', `tcp:${state.rpcPort}`],
|
|
{ stdout: 'pipe' },
|
|
)
|
|
for (const pid of leftover.stdout.toString().trim().split('\n').filter(Boolean)) {
|
|
try {
|
|
process.kill(Number(pid), 'SIGKILL')
|
|
console.log(
|
|
`[monero] killed unresponsive process ${pid} on port ${state.rpcPort}`,
|
|
)
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
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 ?? '',
|
|
)
|
|
log(
|
|
`[monero] wallet-rpc process exited with code ${exitCode} ${error?.message ?? ''}`,
|
|
)
|
|
if (state.saveTimer) {
|
|
clearInterval(state.saveTimer)
|
|
state.saveTimer = null
|
|
}
|
|
state.wallet = null
|
|
state.process = null
|
|
},
|
|
})
|
|
console.log(`[monero] wallet-rpc pid: ${state.process.pid}`)
|
|
|
|
// Auto-save the wallet every 60s so scan progress survives an
|
|
// unexpected kill (without `store`, the wallet file keeps its old
|
|
// refresh height and every restart resyncs from block 0).
|
|
state.saveTimer = setInterval(async () => {
|
|
try {
|
|
if (state.wallet) {
|
|
await rawRpc(state, 'store')
|
|
console.log(`[monero] wallet auto-saved`)
|
|
}
|
|
} catch (e) {
|
|
console.log(`[monero] wallet auto-save failed:`, e)
|
|
}
|
|
}, 60000)
|
|
|
|
const reader = (state.process!.stdout as ReadableStream<Uint8Array>).getReader()
|
|
;(async () => {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
console.log(
|
|
`[monero:stdout] ${new TextDecoder().decode(value)}`,
|
|
)
|
|
}
|
|
})()
|
|
|
|
const stderr = (state.process!.stderr as ReadableStream<Uint8Array>).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 connectClient(state)
|
|
console.log(`[monero] MoneroWalletRpc connected successfully`)
|
|
log(`[monero] wallet-rpc started, RPC ready`)
|
|
}
|
|
|
|
export async function stop(state: MoneroWalletState) {
|
|
console.log(`[monero] stopping wallet manager...`)
|
|
if (state.saveTimer) {
|
|
clearInterval(state.saveTimer)
|
|
state.saveTimer = null
|
|
}
|
|
// Persist scan progress before shutting down.
|
|
try {
|
|
if (state.wallet) {
|
|
await rawRpc(state, 'store')
|
|
console.log(`[monero] wallet saved before stop`)
|
|
log(`[monero] wallet saved before stop`)
|
|
}
|
|
} catch (e) {
|
|
console.log(`[monero] wallet save before stop failed:`, e)
|
|
}
|
|
if (state.process) {
|
|
const proc = state.process
|
|
console.log(`[monero] sending SIGTERM to wallet-rpc (pid ${proc.pid})`)
|
|
proc.kill()
|
|
await Promise.race([proc.exited, sleep(3000)])
|
|
if (proc.exitCode === null) {
|
|
console.log(
|
|
`[monero] wallet-rpc did not exit gracefully, sending SIGKILL`,
|
|
)
|
|
proc.kill('SIGKILL')
|
|
}
|
|
state.process = null
|
|
}
|
|
state.wallet = null
|
|
console.log(`[monero] wallet manager stopped`)
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms))
|
|
}
|
|
|
|
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 withTimeout(
|
|
state.wallet.getAddress(0, 0),
|
|
30000,
|
|
'wallet get_address',
|
|
)) as string
|
|
console.log(`[monero] wallet created: ${name} -> ${address}`)
|
|
return { mnemonic: result.mnemonic, address }
|
|
}
|
|
|
|
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}`)
|
|
}
|
|
|
|
export async function openWallet(
|
|
state: MoneroWalletState,
|
|
name: string,
|
|
password: string,
|
|
) {
|
|
console.log(`[monero] opening wallet: ${name}`)
|
|
// Self-heal: the client connection may have been lost while the
|
|
// wallet-rpc process is still running (e.g. stale-process cleanup).
|
|
if (!state.wallet) {
|
|
if (!state.process) throw new Error('Wallet RPC not started')
|
|
log(`[monero] reconnecting wallet client before open (${name})`)
|
|
state.wallet = await connectClient(state)
|
|
}
|
|
try {
|
|
await withTimeout(state.wallet.openWallet(name, password), 30000, `open_wallet ${name}`)
|
|
log(`[monero] wallet opened: ${name}`)
|
|
} catch (e) {
|
|
logError(`[monero] wallet open failed (${name})`, e)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
export async function closeWallet(state: MoneroWalletState) {
|
|
console.log(`[monero] closing current wallet`)
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
await state.wallet.close()
|
|
console.log(`[monero] wallet closed`)
|
|
}
|
|
|
|
export async function getBalance(
|
|
state: MoneroWalletState,
|
|
): Promise<{ balance: bigint; unlocked: bigint }> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
const balance = (await withTimeout(
|
|
state.wallet.getBalance(),
|
|
30000,
|
|
'wallet get_balance',
|
|
)) as bigint
|
|
const unlocked = (await withTimeout(
|
|
state.wallet.getUnlockedBalance(),
|
|
30000,
|
|
'wallet get_unlocked_balance',
|
|
)) as bigint
|
|
console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
|
|
return { balance, unlocked }
|
|
}
|
|
|
|
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 withTimeout(
|
|
state.wallet.getAddress(accountIdx, subIdx),
|
|
30000,
|
|
'wallet get_address',
|
|
)) as string
|
|
console.log(`[monero] address: ${address}`)
|
|
return address
|
|
}
|
|
|
|
export async function createAccount(
|
|
state: MoneroWalletState,
|
|
label?: string,
|
|
): Promise<{ index: number; address: string }> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
const result = await rawRpc(state, 'create_account', {
|
|
label: label ?? '',
|
|
})
|
|
console.log(`[monero] created account ${result.account_index}: ${result.address}`)
|
|
return { index: result.account_index, address: result.address }
|
|
}
|
|
|
|
export async function createSubaddress(
|
|
state: MoneroWalletState,
|
|
accountIndex: number,
|
|
label?: string,
|
|
): Promise<{ index: number; address: string }> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
const result = await rawRpc(state, 'create_address', {
|
|
account_index: accountIndex,
|
|
label: label ?? '',
|
|
})
|
|
console.log(`[monero] created subaddress ${result.address_index} for account ${accountIndex}: ${result.address}`)
|
|
return { index: result.address_index, address: result.address }
|
|
}
|
|
|
|
export async function getAccounts(
|
|
state: MoneroWalletState,
|
|
): Promise<any[]> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
const accounts = (await withTimeout(
|
|
state.wallet.getAccounts(true),
|
|
30000,
|
|
'wallet get_accounts',
|
|
)) 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)}...`,
|
|
)
|
|
const has0 = subs.some((s: any) => s.getIndex() === 0)
|
|
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()}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return accounts?.map((a) => a.toJson()) ?? []
|
|
}
|
|
|
|
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] ?? []
|
|
for (const t of transfers) {
|
|
all.push({
|
|
hash: t.txid,
|
|
amount: t.amount?.toString() ?? '0',
|
|
timestamp: String(t.timestamp ?? 0),
|
|
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}` : ''}`,
|
|
)
|
|
return all
|
|
}
|
|
|
|
export async function getTransferDetails(
|
|
state: MoneroWalletState,
|
|
txid: string,
|
|
): Promise<any> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
let t: any = null
|
|
let dests: any[] = []
|
|
try {
|
|
console.log('[monero] trying get_transfer_by_txid for', txid)
|
|
const result = await rawRpc(state, 'get_transfer_by_txid', { txid })
|
|
console.log('[monero] get_transfer_by_txid result:', result ? 'has result' : 'null')
|
|
if (result?.transfer) {
|
|
t = result.transfer
|
|
dests =
|
|
t.destinations?.map((d: any) => ({
|
|
address: d.address,
|
|
amount: d.amount?.toString() ?? '0',
|
|
})) ?? []
|
|
}
|
|
} catch (e: any) {
|
|
console.log('[monero] get_transfer_by_txid failed:', e?.message?.slice(0, 100))
|
|
}
|
|
if (!t) {
|
|
console.log('[monero] falling back to get_transfers scan')
|
|
const params: Record<string, any> = {
|
|
in: true,
|
|
out: true,
|
|
pending: true,
|
|
pool: true,
|
|
}
|
|
const result = await rawRpc(state, 'get_transfers', params)
|
|
console.log('[monero] get_transfers result keys:', Object.keys(result ?? {}))
|
|
let found: any = null
|
|
for (const dir of ['in', 'out', 'pending', 'pool'] as const) {
|
|
const transfers = result[dir] ?? []
|
|
console.log(`[monero] ${dir}: ${transfers.length} transfers`)
|
|
for (const tr of transfers) {
|
|
if (tr.txid === txid) {
|
|
found = { ...tr, type: dir === 'in' ? 'in' : 'out' }
|
|
break
|
|
}
|
|
}
|
|
if (found) break
|
|
}
|
|
if (!found) {
|
|
console.log('[monero] txid not found in get_transfers. Available txids:',
|
|
['in', 'out', 'pending', 'pool'].flatMap(d => (result[d] ?? []).map((t: any) => t.txid?.slice(0, 12))))
|
|
return null
|
|
}
|
|
console.log('[monero] found transfer in get_transfers')
|
|
t = found
|
|
}
|
|
const isIn = t.type === 'in'
|
|
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,
|
|
}))
|
|
return {
|
|
hash: t.txid,
|
|
direction: isIn ? ('in' as const) : ('out' as const),
|
|
amount: t.amount?.toString() ?? '0',
|
|
fee: t.fee?.toString() ?? '0',
|
|
height: t.height ?? 0,
|
|
timestamp: String(t.timestamp ?? 0),
|
|
confirmations: t.confirmations ?? 0,
|
|
unlockTime: t.unlock_time ?? 0,
|
|
locked: t.locked ?? false,
|
|
doubleSpend: t.double_spend_seen ?? false,
|
|
note: t.note,
|
|
paymentId: t.payment_id ?? '',
|
|
destinations: dests,
|
|
subaddrIndices,
|
|
}
|
|
}
|
|
|
|
export async function getHeight(
|
|
state: MoneroWalletState,
|
|
): Promise<number> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
const height = (await withTimeout(
|
|
state.wallet.getHeight(),
|
|
30000,
|
|
'wallet get_height',
|
|
)) as number
|
|
console.log(`[monero] wallet height: ${height}`)
|
|
return height
|
|
}
|
|
|
|
export async function getFeeEstimate(
|
|
state: MoneroWalletState,
|
|
): Promise<{ fee: string; fees: string[]; estimatedFee: string }> {
|
|
try {
|
|
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(),
|
|
}
|
|
} catch (e) {
|
|
console.log('[monero] getFeeEstimate error:', e)
|
|
return { fee: '0', fees: ['0', '0', '0', '0'], estimatedFee: '0' }
|
|
}
|
|
}
|
|
|
|
export async function getDaemonHeight(
|
|
state: MoneroWalletState,
|
|
): Promise<number> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
try {
|
|
const daemon = await connectToDaemonRpc(
|
|
`http://${state.daemonAddress}`,
|
|
)
|
|
const height = (await withTimeout(
|
|
daemon.getHeight(),
|
|
15000,
|
|
'daemon get_height',
|
|
)) as number
|
|
console.log(`[monero] daemon height: ${height}`)
|
|
return height
|
|
} catch (e) {
|
|
console.log(`[monero] failed to get daemon height:`, e)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
export async function sweepAll(
|
|
state: MoneroWalletState,
|
|
address: string,
|
|
priority: number = 0,
|
|
accountIndex: number = 0,
|
|
): Promise<{ txHash: string; fee: string; amount: string }> {
|
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
|
console.log(
|
|
`[monero] sweepAll: ${address} priority=${priority} account=${accountIndex}`,
|
|
)
|
|
const result = await rawRpc(state, 'sweep_all', {
|
|
address,
|
|
priority,
|
|
account_index: accountIndex,
|
|
get_tx_keys: true,
|
|
get_tx_hex: false,
|
|
get_tx_metadata: false,
|
|
})
|
|
const txHash = result.tx_hash_list?.[0] ?? ''
|
|
const fee = result.fee_list?.[0]?.toString() ?? '0'
|
|
const amount = result.amount_list?.[0]?.toString() ?? '0'
|
|
console.log(
|
|
`[monero] sweepAll result: tx_hash=${txHash} fee=${fee} amount=${amount}`,
|
|
)
|
|
return { txHash, fee, amount }
|
|
}
|
|
|
|
export async function transfer(
|
|
state: MoneroWalletState,
|
|
address: string,
|
|
amount: bigint,
|
|
priority: number = 0,
|
|
accountIndex: number = 0,
|
|
): 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,
|
|
get_tx_key: true,
|
|
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(),
|
|
}
|
|
}
|
|
|
|
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 withTimeout(state.wallet.getHeight(), 8000, 'wallet get_height')
|
|
return true
|
|
} catch (e) {
|
|
// A timeout usually means the wallet-rpc is busy syncing, not that
|
|
// the wallet is closed. Assume still-open so the UI doesn't flip to
|
|
// "closed" and try to re-open an already-open wallet.
|
|
if (e instanceof Error && e.message.includes('timed out')) return true
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function isConnected(
|
|
state: MoneroWalletState,
|
|
): Promise<boolean> {
|
|
if (!state.wallet) return false
|
|
try {
|
|
const daemon = await withTimeout(
|
|
connectToDaemonRpc(`http://${state.daemonAddress}`),
|
|
8000,
|
|
'daemon connect',
|
|
)
|
|
await withTimeout(daemon.getHeight(), 8000, 'daemon get_height')
|
|
return true
|
|
} catch (e) {
|
|
console.log(`[monero] connection check failed:`, e)
|
|
return false
|
|
}
|
|
}
|