import { randomUUID } from 'node:crypto'; import { lstat, readFile } from 'node:fs/promises'; import { z } from 'zod'; import { parseAtomicXmr, XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts'; import { validateXmrAddress } from './xmr.ts'; const MAX_CONFIG_BYTES = 4096; const MAX_RESPONSE_BYTES = 1024 * 1024; const RPC_TIMEOUT_MS = 5000; const MAX_TRANSFERS = 4096; const configSchema = z.object({ network: z.literal('mainnet'), url: z.string().min(1).max(2048), username: z.string().min(1).max(256), password: z.string().min(1).max(1024), }).strict(); const rpcEnvelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()), }).strict(); const integerString = z.string().regex(/^(0|[1-9]\d{0,19})$/); const heightResultSchema = z.object({ height: integerString, status: z.literal('OK') }).passthrough(); const addressResultSchema = z.object({ address: z.string().length(95), addresses: z.array(z.unknown()).optional(), status: z.literal('OK') }).passthrough(); const incomingTransferSchema = z.object({ txid: z.string().regex(/^[a-f0-9]{64}$/), address: z.string().length(95), amount: integerString, height: integerString, timestamp: integerString, unlock_time: integerString, locked: z.boolean(), double_spend_seen: z.boolean(), subaddr_index: z.object({ major: integerString, minor: integerString }).strict(), }).passthrough(); const transfersResultSchema = z.object({ in: z.array(incomingTransferSchema).max(MAX_TRANSFERS).default([]), status: z.literal('OK'), }).passthrough(); export type MoneroIncomingObservation = { observationId: string; transactionId: string; address: string; amountAtomicXmr: string; height: number; confirmations: number; timestamp: number; unlockTime: string; accountIndex: number; addressIndex: number; locked: boolean; doubleSpendSeen: boolean; }; export type MoneroObservationPage = { network: 'mainnet'; walletHeight: number; fromHeight: number; throughHeight: number; primaryAddress: string; incoming: MoneroIncomingObservation[]; }; export type MoneroFixtureTransport = (url: string, init: RequestInit) => Promise; function integer(value: string, maximum: bigint, name: string): number { const parsed = BigInt(value); if (parsed > maximum || parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`Invalid Monero ${name}`); return Number(parsed); } /** JSON.parse rounds uint64 amounts. Convert JSON numeric tokens to strings before parsing, * preserving exact wallet-RPC integer text so all monetary values can be checked as bigint. */ function parseLosslessJson(text: string): unknown { let output = '', inString = false, escaped = false; for (let i = 0; i < text.length;) { const char = text[i]; if (inString) { output += char; if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '"') inString = false; i++; continue; } if (char === '"') { inString = true; output += char; i++; continue; } if (char === '-' || (char >= '0' && char <= '9')) { const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i)); if (!match) throw new Error('Invalid JSON numeric token'); output += JSON.stringify(match[0]); i += match[0].length; continue; } output += char; i++; } return JSON.parse(output) as unknown; } function validateEndpoint(value: string): URL { let url: URL; try { url = new URL(value); } catch { throw new Error('Invalid Monero wallet-RPC endpoint'); } if (url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') { throw new Error('Monero wallet-RPC endpoint must be a bare /json_rpc URL'); } const loopback = url.hostname === '127.0.0.1' || url.hostname === '[::1]'; if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { throw new Error('Wallet RPC requires HTTPS, except loopback-only HTTP'); } if (!url.port && !['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid Monero wallet-RPC port'); return url; } export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.env) { if (env.WIKIDECK_MONERO_OBSERVER !== '1') throw new Error('Monero observer is disabled'); const path = env.WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE; if (!path) throw new Error('Monero wallet-RPC config file is required'); try { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error(); if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); const parsed = configSchema.parse(JSON.parse(await readFile(path, 'utf8'))); validateEndpoint(parsed.url); if (/[\r\n\x00]/.test(parsed.username) || /[\r\n\x00]/.test(parsed.password)) throw new Error(); return parsed; } catch { throw new Error('Invalid or inaccessible Monero wallet-RPC config; credentials must be in a protected regular file'); } } /** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */ export function createMoneroWalletObserver(config: z.infer, transport: MoneroFixtureTransport = fetch) { const endpoint = validateEndpoint(config.url); const authorization = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`; async function rpc(method: 'get_height' | 'get_address' | 'get_transfers', params: Record = {}) { const id = randomUUID(), controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref(); const abort = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('Monero wallet-RPC deadline exceeded')), { once: true })); const operation = (async () => { let response: Response; try { response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal, headers: { authorization, accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) }); } catch { throw new Error('Monero wallet-RPC request failed'); } if (!response.ok || response.redirected || !response.body || !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) { await response.body?.cancel(); throw new Error('Monero wallet-RPC response rejected'); } const contentLength = response.headers.get('content-length'); if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) { await response.body.cancel(); throw new Error('Monero wallet-RPC response exceeds size limit'); } const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0; try { for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.byteLength; if (size > MAX_RESPONSE_BYTES) throw new Error('Monero wallet-RPC response exceeds size limit'); chunks.push(part.value); } } finally { await reader.cancel(); reader.releaseLock(); } let raw: unknown; try { raw = parseLosslessJson(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); } catch { throw new Error('Monero wallet-RPC returned malformed JSON'); } const envelope = rpcEnvelopeSchema.parse(raw); if (envelope.id !== id) throw new Error('Monero wallet-RPC response ID mismatch'); return envelope.result; })(); try { return await Promise.race([operation, abort]); } finally { clearTimeout(timeout); } } return { async verifyMainnetWallet(): Promise { const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 })); return validateXmrAddress(result.address); }, async scanIncoming(fromHeight: number): Promise { if (!Number.isSafeInteger(fromHeight) || fromHeight < 0) throw new Error('Invalid Monero scan height'); const [heightRaw, primaryAddress] = await Promise.all([ rpc('get_height'), this.verifyMainnetWallet(), ]); const walletHeight = integer(heightResultSchema.parse(heightRaw).height, BigInt(Number.MAX_SAFE_INTEGER), 'height'); if (fromHeight > walletHeight) throw new Error('Monero observer cursor is ahead of wallet height'); const transferRaw = transfersResultSchema.parse(await rpc('get_transfers', { in: true, filter_by_height: true, min_height: fromHeight, max_height: walletHeight, })); if (transferRaw.in.length >= MAX_TRANSFERS) throw new Error('Monero transfer page reached its safety limit; refusing to advance scan cursor'); const incoming = await Promise.all(transferRaw.in.map(async item => { const amount = BigInt(item.amount); if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero amount'); const address = item.address; await validateXmrAddress(address); const height = integer(item.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height'); if (height < fromHeight || height > walletHeight) throw new Error('Wallet returned transfer outside requested height range'); const major = integer(item.subaddr_index.major, 0xffffffffn, 'account index'); const minor = integer(item.subaddr_index.minor, 0xffffffffn, 'address index'); return { observationId: `${item.txid}:${major}:${minor}`, transactionId: item.txid, address, amountAtomicXmr: amount.toString(), height, confirmations: walletHeight - height + 1, timestamp: integer(item.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'), unlockTime: item.unlock_time, accountIndex: major, addressIndex: minor, locked: item.locked, doubleSpendSeen: item.double_spend_seen }; })); const unique = new Set(incoming.map(row => row.observationId)); if (unique.size !== incoming.length) throw new Error('Duplicate Monero wallet output observation'); return { network: 'mainnet', walletHeight, fromHeight, throughHeight: walletHeight, primaryAddress, incoming }; }, }; }