Files

210 lines
8.7 KiB
TypeScript

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<string>;
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<typeof setTimeout> | undefined;
private shutdownTimer: ReturnType<typeof setTimeout> | 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<string> {
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<Result | null> {
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<T>(command: string, parse: (line: string) => T): Promise<T> {
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<T>((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();
}
}
}