209 lines
8.5 KiB
TypeScript
209 lines
8.5 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;
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|