import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { Buffer } from 'node:buffer'; import { accessSync, constants } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export interface PowEngine { hash(key: string, blob: Uint8Array): Promise; close(): void; } type Result = { hash: string; nonce: number }; type Request = { command: string; parse: (line: string) => unknown; resolve: (value: unknown) => void; reject: (error: Error) => void; }; const TIMEOUT_MS = 120_000; const MAX_QUEUE = 64; const MAX_RESPONSE = 128; const HEX64 = /^[0-9a-fA-F]{64}$/; function hex64(value: string, name: string): string { if (typeof value !== 'string' || value.length !== 64 || !HEX64.test(value)) { throw new TypeError(`${name} must be exactly 64 hexadecimal characters`); } return value.toLowerCase(); } function blobHex(blob: Uint8Array): string { if (!(blob instanceof Uint8Array) || blob.byteLength < 1 || blob.byteLength > 8192) { throw new RangeError('blob must contain 1..8192 bytes'); } return Buffer.from(blob).toString('hex'); // Snapshot: queued work cannot be changed by its caller. } /** The production testnet's native CPU RandomX proof engine; never substitutes a web hash. */ export class RandomX implements PowEngine { private readonly child: ChildProcessWithoutNullStreams; private readonly queue: Request[] = []; private active: Request | undefined; private timer: ReturnType | undefined; private shutdownTimer: ReturnType | undefined; private failure: Error | undefined; private output = ''; private stderr = ''; private exited = false; constructor(options: { binary?: string; fast?: boolean } = {}) { const defaultBinary = fileURLToPath(new URL( `../../native/build/wikideck-randomx${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url, )); const binary = resolve(options.binary ?? process.env.RANDOMX_BINARY ?? defaultBinary); // Missing/non-executable binaries fail immediately, not silently on first mining attempt. accessSync(binary, constants.X_OK); this.child = spawn(binary, options.fast ? ['--fast'] : [], { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }); this.child.on('error', () => this.fail(new Error('RandomX subprocess failed to start'))); this.child.stdin.on('error', () => this.fail(new Error('RandomX stdin failed'))); this.child.stdout.on('error', () => this.fail(new Error('RandomX stdout failed'))); this.child.stderr.on('error', () => this.fail(new Error('RandomX stderr failed'))); this.child.stderr.on('data', (chunk: Buffer) => { // Always drain stderr, but never accumulate unbounded native diagnostics. this.stderr = (this.stderr + chunk.subarray(0, 1024).toString('utf8')).slice(-1024); }); this.child.stdout.on('data', (chunk: Buffer) => this.receive(chunk)); this.child.stdout.on('end', () => { if (!this.failure) this.fail(new Error('RandomX stdout closed unexpectedly')); }); this.child.on('exit', (code, signal) => { this.exited = true; if (this.shutdownTimer) clearTimeout(this.shutdownTimer); const detail = this.stderr.replace(/[^\x20-\x7e]/g, ' ').trim(); this.fail(new Error(`RandomX exited (${signal ?? code})${detail ? `: ${detail}` : ''}`)); }); } async hash(key: string, blob: Uint8Array): Promise { const command = `hash ${hex64(key, 'key')} ${blobHex(blob)}`; return this.request(command, (line) => { if (!/^[0-9a-f]{64}$/.test(line)) throw new Error('Invalid RandomX hash response'); return line; }); } diagnostics() { return { available: !this.failure && !this.exited, active: !!this.active, queued: this.queue.length }; } async mine( key: string, blob: Uint8Array, target: string, nonceOffset: number, maxAttempts: number, ): Promise { const normalizedKey = hex64(key, 'key'); const encodedBlob = blobHex(blob); const normalizedTarget = hex64(target, 'target'); const size = encodedBlob.length / 2; if (!Number.isSafeInteger(nonceOffset) || nonceOffset < 0 || nonceOffset > size - 8) { throw new RangeError('nonceOffset must leave 8 bytes within blob'); } if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10000) { throw new RangeError('maxAttempts must be in 1..10000'); } const start = Buffer.from(encodedBlob, 'hex').readBigUInt64LE(nonceOffset); const last = start + BigInt(maxAttempts - 1); // The public API returns number, so never silently round a 64-bit native nonce. if (last > BigInt(Number.MAX_SAFE_INTEGER)) { throw new RangeError('nonce range must fit Number.MAX_SAFE_INTEGER'); } const command = `mine ${normalizedKey} ${encodedBlob} ${normalizedTarget} ${nonceOffset} ${maxAttempts}`; return this.request(command, (line) => { if (line === 'exhausted') return null; const match = /^found ([0-9a-f]{64}) (0|[1-9][0-9]{0,19})$/.exec(line); if (!match) throw new Error('Invalid RandomX mine response'); const nonce = BigInt(match[2]); if (nonce < start || nonce > last || match[1] > normalizedTarget) { throw new Error('RandomX result outside requested nonce range/target'); } return { hash: match[1], nonce: Number(nonce) }; }); } private request(command: string, parse: (line: string) => T): Promise { if (this.failure) return Promise.reject(this.failure); if (this.queue.length + (this.active ? 1 : 0) >= MAX_QUEUE) { return Promise.reject(new Error('RandomX request queue full')); } return new Promise((resolve, reject) => { this.queue.push({ command, parse, resolve: (value) => resolve(value as T), reject }); this.dispatch(); }); } private dispatch(): void { if (this.failure || this.active) return; const request = this.queue.shift(); if (!request) return; this.active = request; this.timer = setTimeout(() => this.fail(new Error('RandomX command timed out after 120s')), TIMEOUT_MS); this.child.stdin.write(`${request.command}\n`, 'ascii', (error) => { if (error) this.fail(new Error('RandomX command write failed')); }); } private receive(chunk: Buffer): void { if (this.failure) return; // Exactly one bounded ASCII response per outstanding request, including CRLF on Windows. for (const byte of chunk) { if (byte > 127 || (byte < 32 && byte !== 10 && byte !== 13)) { this.fail(new Error('Invalid RandomX output encoding')); return; } } if (this.output.length + chunk.length > MAX_RESPONSE) { this.fail(new Error('RandomX response too long')); return; } this.output += chunk.toString('ascii'); const newline = this.output.indexOf('\n'); if (newline === -1) return; const request = this.active; if (!request || newline !== this.output.length - 1) { this.fail(new Error('Unsolicited RandomX output')); return; } const line = this.output.slice(0, newline).replace(/\r$/, ''); try { const result = request.parse(line); if (this.timer) clearTimeout(this.timer); this.timer = undefined; this.output = ''; this.active = undefined; request.resolve(result); // Let all current output be checked before allowing another command onto stdin. queueMicrotask(() => this.dispatch()); } catch (error) { this.fail(error instanceof Error ? error : new Error('Invalid RandomX response')); } } private fail(error: Error): void { if (this.failure) return; this.failure = error; if (this.timer) clearTimeout(this.timer); this.timer = undefined; this.active?.reject(error); this.active = undefined; for (const request of this.queue.splice(0)) request.reject(error); if (!this.exited) this.child.kill('SIGKILL'); } close(): void { if (this.failure) return; const error = new Error('RandomX engine closed'); this.failure = error; if (this.timer) clearTimeout(this.timer); this.timer = undefined; this.active?.reject(error); this.active = undefined; for (const request of this.queue.splice(0)) request.reject(error); // Idle engines exit cleanly on EOF and run RAII destructors. Bound busy shutdown too. this.child.stdin.end(); if (!this.exited) { this.shutdownTimer = setTimeout(() => this.child.kill('SIGKILL'), 1000); this.shutdownTimer.unref(); } } }