Read-only Monero wallet-RPC observer
This commit is contained in:
Executable
+200
@@ -0,0 +1,200 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import { isIP } from 'node:net';
|
||||
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}$/),
|
||||
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;
|
||||
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<Response>;
|
||||
|
||||
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 = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname) || isIP(url.hostname) === 6 && 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<typeof configSchema>, 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<string, unknown> = {}) {
|
||||
const id = randomUUID(), controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref();
|
||||
const abort = new Promise<never>((_, 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<string> {
|
||||
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
|
||||
return validateXmrAddress(result.address);
|
||||
},
|
||||
async scanIncoming(fromHeight: number): Promise<MoneroObservationPage> {
|
||||
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,
|
||||
}));
|
||||
const incoming = transferRaw.in.map(item => {
|
||||
const amount = BigInt(item.amount);
|
||||
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero amount');
|
||||
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,
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user